feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed
This commit is contained in:
1 parent
ff7ddf7a9e
commit
1b4384e3f3
52 files changed
+5117
-4
No files matched your search
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ContentDocument;
|
||||
use App\Enum\ContentFetchState;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ContentDocument>
|
||||
*/
|
||||
class ContentDocumentRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ContentDocument::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ContentDocument> keyed by ContentDocument::getKey()
|
||||
*/
|
||||
public function findAllByKey(): array
|
||||
{
|
||||
$documents = [];
|
||||
|
||||
foreach ($this->findAll() as $document) {
|
||||
$documents[$document->getKey()] = $document;
|
||||
}
|
||||
|
||||
return $documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search over the indexed documents that can be linked to.
|
||||
*
|
||||
* DBAL rather than DQL: Doctrine has no MATCH ... AGAINST. The score is weighted by type
|
||||
* so that, at similar relevance, what people book ranks above what they read.
|
||||
*
|
||||
* @param string $booleanQuery a MySQL boolean-mode query, built by ContentSearchQuery
|
||||
* @param list<string> $types empty for all
|
||||
* @param array<string, float> $typeWeights
|
||||
*
|
||||
* @return list<array{type: string, uid: int, title: string, url: string, site: ?string, breadcrumb: ?string, body_md: string, score: float}>
|
||||
*/
|
||||
public function searchFulltext(string $booleanQuery, array $types, ?string $site, array $typeWeights, int $limit): array
|
||||
{
|
||||
$weight = 'CASE type';
|
||||
$params = ['query' => $booleanQuery, 'limit' => $limit];
|
||||
$paramTypes = ['limit' => ParameterType::INTEGER];
|
||||
|
||||
foreach (array_keys($typeWeights) as $i => $type) {
|
||||
$weight .= sprintf(' WHEN :wt%d THEN :wv%d', $i, $i);
|
||||
$params['wt'.$i] = $type;
|
||||
$params['wv'.$i] = $typeWeights[$type];
|
||||
}
|
||||
$weight .= ' ELSE 1 END';
|
||||
|
||||
$sql = 'SELECT type, uid, title, url, site, breadcrumb, body_md,'
|
||||
.' MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE) * '.$weight.' AS score'
|
||||
.' FROM content_document'
|
||||
.' WHERE indexed = 1 AND url IS NOT NULL'
|
||||
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)';
|
||||
|
||||
if ([] !== $types) {
|
||||
$sql .= ' AND type IN (:types)';
|
||||
$params['types'] = $types;
|
||||
$paramTypes['types'] = ArrayParameterType::STRING;
|
||||
}
|
||||
|
||||
if (null !== $site) {
|
||||
$sql .= ' AND site = :site';
|
||||
$params['site'] = $site;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY score DESC, uid ASC LIMIT :limit';
|
||||
|
||||
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative($sql, $params, $paramTypes);
|
||||
|
||||
return array_map(static fn (array $row): array => [
|
||||
'type' => (string) $row['type'],
|
||||
'uid' => (int) $row['uid'],
|
||||
'title' => (string) $row['title'],
|
||||
'url' => (string) $row['url'],
|
||||
'site' => null === $row['site'] ? null : (string) $row['site'],
|
||||
'breadcrumb' => null === $row['breadcrumb'] ? null : (string) $row['breadcrumb'],
|
||||
'body_md' => (string) $row['body_md'],
|
||||
'score' => (float) $row['score'],
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `(type, uid)` of every fetched record whose text matches, for resolving free text
|
||||
* in the trip search. Unlike searchFulltext(), records without a URL count: a hotel with
|
||||
* no page of its own still leads to the products it belongs to.
|
||||
*
|
||||
* @param list<string> $types
|
||||
*
|
||||
* @return list<array{type: string, uid: int}>
|
||||
*/
|
||||
public function matchRecords(string $booleanQuery, array $types, string $language): array
|
||||
{
|
||||
$rows = $this->getEntityManager()->getConnection()->fetchAllAssociative(
|
||||
'SELECT type, uid FROM content_document'
|
||||
.' WHERE fetch_state = :ok AND language = :language AND type IN (:types)'
|
||||
.' AND MATCH(title, breadcrumb, body_md) AGAINST(:query IN BOOLEAN MODE)',
|
||||
['ok' => ContentFetchState::Ok->value, 'language' => $language, 'types' => $types, 'query' => $booleanQuery],
|
||||
['types' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
return array_map(static fn (array $row): array => ['type' => (string) $row['type'], 'uid' => (int) $row['uid']], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched records of the given types in one language, keyed by `type:uid`.
|
||||
*
|
||||
* @param list<string> $types
|
||||
*
|
||||
* @return array<string, ContentDocument>
|
||||
*/
|
||||
public function findRecords(array $types, string $language): array
|
||||
{
|
||||
/** @var list<ContentDocument> $documents */
|
||||
$documents = $this->createQueryBuilder('d')
|
||||
->where('d.type IN (:types)')
|
||||
->andWhere('d.language = :language')
|
||||
->andWhere('d.fetchState = :ok')
|
||||
->setParameter('types', $types)
|
||||
->setParameter('language', $language)
|
||||
->setParameter('ok', ContentFetchState::Ok)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$indexed = [];
|
||||
|
||||
foreach ($documents as $document) {
|
||||
$indexed[$document->getRecordRef()] = $document;
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The indexed document published at a URL. Trailing slashes are not significant.
|
||||
*/
|
||||
public function findOneIndexedByUrl(string $url): ?ContentDocument
|
||||
{
|
||||
$trimmed = rtrim($url, '/');
|
||||
|
||||
/** @var list<ContentDocument> $documents */
|
||||
$documents = $this->createQueryBuilder('d')
|
||||
->where('d.url IN (:urls)')
|
||||
->andWhere('d.indexed = true')
|
||||
->setParameter('urls', [$trimmed, $trimmed.'/'])
|
||||
->orderBy('d.id', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return $documents[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ContentSyncState;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ContentSyncState>
|
||||
*/
|
||||
class ContentSyncStateRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ContentSyncState::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single state row, created unpersisted on first use.
|
||||
*/
|
||||
public function get(): ContentSyncState
|
||||
{
|
||||
return $this->findOneBy([], ['id' => 'ASC']) ?? new ContentSyncState();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user