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

+288
View File
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\ContentFeed;
/**
* Builds the searchable Markdown body and the title of a feed record.
*
* Every record follows the same pattern: the name as heading, the short introductory
* fields as paragraphs, then each `text` field as a section under a German heading, in a
* fixed order. Structured facts that people ask about (WLAN, Sauna, Pistenkilometer) are
* rendered as a list as well, so the full-text search finds them.
*/
class DocumentBodyBuilder
{
/**
* Introductory fields, rendered without a heading. Plain text in the feed.
*/
private const LEAD_FIELDS = ['subline', 'headline'];
/**
* `text` fields per type, in output order, with their section heading. A null heading
* renders the field as an introduction straight under the title.
*/
private const TEXT_SECTIONS = [
'product' => [
'teaser' => null,
'teaserLong' => 'Beschreibung',
'feature' => 'Highlights',
'conceptDescription' => 'Reisekonzept',
'boardDescription' => 'Verpflegung',
'includedServices' => 'Leistungen',
'additionalServices' => 'Zusatzleistungen',
'programmeDescription' => 'Programm',
'skiPassDescription' => 'Skipass',
'nonBookableInfo' => 'Hinweise zur Buchbarkeit',
],
'hotel' => [
'teaser' => null,
'description' => 'Beschreibung',
'features' => 'Ausstattung',
'roomTypes' => 'Zimmer',
'additionalInformation' => 'Weitere Informationen',
],
'region' => [
'teaser' => null,
'description' => 'Beschreibung',
'feature' => 'Highlights',
'skiArea' => 'Skigebiet',
'skiAreaExtended' => 'Skigebiet im Detail',
'skiPass' => 'Skipass',
'news' => 'Neuigkeiten',
],
'country' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
],
'city' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
'party' => 'Party',
'leisure' => 'Freizeit',
],
'concept' => [
'teaser' => null,
'description' => 'Beschreibung',
'descriptionExtended' => 'Mehr erfahren',
'popupText' => 'Kurz erklärt',
],
];
/**
* Facts per type, with their German label.
*/
private const FACTS = [
'hotel' => [
'bedsCount' => 'Betten',
'parkingCount' => 'Parkplätze',
'altitude' => 'Höhenlage',
'distanceBus' => 'Entfernung zur Bushaltestelle',
'distanceLift' => 'Entfernung zum Lift',
'shower' => 'Dusche/WC',
'partyroom' => 'Partyraum',
'gamesroom' => 'Spieleraum',
'sauna' => 'Sauna',
'wifi' => 'WLAN',
],
'region' => [
'length' => 'Pistenkilometer',
'height' => 'Höhe',
'lifts' => 'Lifte',
],
];
public function __construct(private readonly HtmlToMarkdownConverter $converter)
{
}
/**
* @param array<string, mixed> $record
*/
public function title(string $type, array $record): string
{
$title = 'news' === $type ? ($record['title'] ?? null) : ($record['name'] ?? null);
return is_string($title) ? trim($title) : '';
}
/**
* @param array<string, mixed> $record
*/
public function build(string $type, array $record): string
{
$baseUrl = is_string($record['url'] ?? null) ? $record['url'] : null;
$blocks = [];
$title = $this->title($type, $record);
if ('' !== $title) {
$blocks[] = '# '.$title;
}
if ('news' === $type) {
return $this->join(array_merge($blocks, $this->newsBlocks($record, $baseUrl)));
}
foreach (self::LEAD_FIELDS as $field) {
$value = $this->plain($record[$field] ?? null);
if ('' !== $value && $value !== $title) {
$blocks[] = $value;
}
}
if ('hotel' === $type) {
$address = $this->plain($record['address'] ?? null);
if ('' !== $address) {
$blocks[] = 'Adresse: '.$address;
}
}
$text = is_array($record['text'] ?? null) ? $record['text'] : [];
foreach (self::TEXT_SECTIONS[$type] ?? [] as $field => $heading) {
$markdown = $this->converter->convert(is_string($text[$field] ?? null) ? $text[$field] : null, $baseUrl);
if ('' === $markdown) {
continue;
}
$blocks[] = null === $heading ? $markdown : "## {$heading}\n\n{$markdown}";
}
$facts = $this->facts($type, is_array($record['facts'] ?? null) ? $record['facts'] : []);
if ('' !== $facts) {
$blocks[] = "## Fakten\n\n".$facts;
}
if ('region' === $type && is_string($record['webcam'] ?? null) && '' !== trim($record['webcam'])) {
$blocks[] = 'Webcam: '.trim($record['webcam']);
}
if ('product' === $type) {
$faqs = $this->faqs(is_array($record['faqs'] ?? null) ? $record['faqs'] : [], $baseUrl);
if ('' !== $faqs) {
$blocks[] = "## Häufige Fragen\n\n".$faqs;
}
}
return $this->join($blocks);
}
/**
* @param array<string, mixed> $record
*
* @return list<string>
*/
private function newsBlocks(array $record, ?string $baseUrl): array
{
$blocks = [];
if (is_string($record['datetime'] ?? null) && false !== ($date = date_create_immutable($record['datetime']))) {
$blocks[] = 'Veröffentlicht am '.$date->format('d.m.Y');
}
foreach (['teaser', 'bodytext'] as $field) {
$markdown = $this->converter->convert(is_string($record[$field] ?? null) ? $record[$field] : null, $baseUrl);
if ('' !== $markdown) {
$blocks[] = $markdown;
}
}
$tags = $this->labels($record['tags'] ?? null);
if ([] !== $tags) {
$blocks[] = 'Themen: '.implode(', ', $tags);
}
return $blocks;
}
/**
* @param array<string, mixed> $facts
*/
private function facts(string $type, array $facts): string
{
$lines = [];
foreach (self::FACTS[$type] ?? [] as $field => $label) {
$value = $facts[$field] ?? null;
$rendered = match (true) {
is_bool($value) => $value ? 'ja' : 'nein',
is_int($value), is_float($value) => (string) $value,
is_string($value) => $this->plain($value),
default => '',
};
if ('' !== $rendered) {
$lines[] = "- {$label}: {$rendered}";
}
}
return implode("\n", $lines);
}
/**
* @param array<mixed> $faqs
*/
private function faqs(array $faqs, ?string $baseUrl): string
{
$blocks = [];
foreach ($faqs as $faq) {
if (!is_array($faq)) {
continue;
}
$question = $this->plain($faq['question'] ?? null);
$answer = $this->converter->convert(is_string($faq['answer'] ?? null) ? $faq['answer'] : null, $baseUrl);
if ('' !== $question && '' !== $answer) {
$blocks[] = "### {$question}\n\n{$answer}";
}
}
return $this->join($blocks);
}
/**
* Tags and categories may arrive as strings or as objects with a title or name.
*
* @return list<string>
*/
private function labels(mixed $values): array
{
if (!is_array($values)) {
return [];
}
$labels = [];
foreach ($values as $value) {
$label = is_array($value) ? ($value['title'] ?? $value['name'] ?? null) : $value;
if (is_string($label) && '' !== trim($label)) {
$labels[] = trim($label);
}
}
return $labels;
}
/**
* A field that is plain text in the feed, defensively stripped of any markup.
*/
private function plain(mixed $value): string
{
if (!is_string($value)) {
return '';
}
return trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')));
}
/**
* @param list<string> $blocks
*/
private function join(array $blocks): string
{
return implode("\n\n", $blocks);
}
}