feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed

This commit is contained in:
fromme committed 2026-10-08 11:46:58 +02:00
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;
}
}