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

+325
View File
@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\ContentFetchState;
use App\Repository\ContentDocumentRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* One item of the TYPO3 content feed, mirrored for the chatbot's MCP tools.
*
* Identity is TYPO3's `(type, uid, language)`. A record (product, hotel, destination,
* concept, news) carries its structured payload in `data` as received; a page carries its
* manifest entry there, including `contentUrl` and `detailOf`.
*
* A page that is the detail page of a record is merged into it: its text is appended to the
* record's `bodyMd`, and the page itself names the record in `mergedInto` and stays out of
* the index, so a product does not turn up twice with half its text each time.
*
* `indexed` is derived, never set: only documents that were fetched, have text and are not
* merged into another are searched. A product TYPO3 keeps out of its own website search
* (`searchable: false`: closed group trips, staff trainings) stays out of this one too.
*/
#[ORM\Entity(repositoryClass: ContentDocumentRepository::class)]
#[ORM\Table(name: 'content_document')]
#[ORM\UniqueConstraint(name: 'content_document_identity', columns: ['type', 'uid', 'language'])]
#[ORM\Index(name: 'content_document_indexed_site', columns: ['indexed', 'site'])]
#[ORM\Index(name: 'content_document_url', columns: ['url'])]
#[ORM\Index(name: 'content_document_fulltext', columns: ['title', 'breadcrumb', 'body_md'], flags: ['fulltext'])]
class ContentDocument
{
public const TYPE_PAGE = 'page';
public const RECORD_TYPES = ['product', 'hotel', 'region', 'country', 'city', 'concept', 'news'];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 16)]
private string $type;
#[ORM\Column]
private int $uid;
#[ORM\Column(length: 8)]
private string $language;
/**
* Null for a record without a reachable detail page.
*/
#[ORM\Column(length: 32, nullable: true)]
private ?string $site = null;
/**
* The public URL to cite. Null for a record without a reachable detail page.
*/
#[ORM\Column(length: 768, nullable: true)]
private ?string $url = null;
#[ORM\Column(length: 255)]
private string $title = '';
/**
* Pages only, joined with ›.
*/
#[ORM\Column(length: 512, nullable: true)]
private ?string $breadcrumb = null;
#[ORM\Column(type: Types::TEXT, length: 16777215)]
private string $bodyMd = '';
/**
* @var array<string, mixed>|null
*/
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $data = null;
/**
* The manifest's `modified`, as last seen.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $modified;
/**
* The manifest's `modified` that the stored fetch outcome belongs to. The item is stale
* while this differs from `modified`.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $fetchedModified = null;
#[ORM\Column(length: 16, enumType: ContentFetchState::class)]
private ContentFetchState $fetchState = ContentFetchState::Pending;
/**
* Last fetch attempt that produced an outcome, whichever it was.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $syncedAt = null;
/**
* `type:uid` of the record this page is the detail page of, in the same language.
*/
#[ORM\Column(length: 32, nullable: true)]
private ?string $mergedInto = null;
#[ORM\Column]
private bool $indexed = false;
public function __construct(string $type, int $uid, string $language, \DateTimeImmutable $modified)
{
$this->type = $type;
$this->uid = $uid;
$this->language = $language;
$this->modified = $modified;
}
public static function key(string $type, int $uid, string $language): string
{
return $type.':'.$uid.':'.$language;
}
public function getKey(): string
{
return self::key($this->type, $this->uid, $this->language);
}
/**
* The `type:uid` a merged page refers to its record by.
*/
public function getRecordRef(): string
{
return $this->type.':'.$this->uid;
}
public function isPage(): bool
{
return self::TYPE_PAGE === $this->type;
}
public function isStale(): bool
{
return null === $this->fetchedModified || $this->fetchedModified != $this->modified;
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getUid(): int
{
return $this->uid;
}
public function getLanguage(): string
{
return $this->language;
}
public function getSite(): ?string
{
return $this->site;
}
public function setSite(?string $site): self
{
$this->site = $site;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): self
{
$this->url = $url;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = mb_substr($title, 0, 255);
return $this;
}
public function getBreadcrumb(): ?string
{
return $this->breadcrumb;
}
public function setBreadcrumb(?string $breadcrumb): self
{
$this->breadcrumb = null === $breadcrumb ? null : mb_substr($breadcrumb, 0, 512);
return $this;
}
public function getBodyMd(): string
{
return $this->bodyMd;
}
public function setBodyMd(string $bodyMd): self
{
$this->bodyMd = $bodyMd;
$this->updateIndexed();
return $this;
}
/**
* @return array<string, mixed>|null
*/
public function getData(): ?array
{
return $this->data;
}
/**
* @param array<string, mixed>|null $data
*/
public function setData(?array $data): self
{
$this->data = $data;
$this->updateIndexed();
return $this;
}
public function getModified(): \DateTimeImmutable
{
return $this->modified;
}
public function setModified(\DateTimeImmutable $modified): self
{
// Compared by value: assigning an equal instance would still schedule an update.
if ($modified != $this->modified) {
$this->modified = $modified;
}
return $this;
}
public function getFetchedModified(): ?\DateTimeImmutable
{
return $this->fetchedModified;
}
public function getFetchState(): ContentFetchState
{
return $this->fetchState;
}
public function getSyncedAt(): ?\DateTimeImmutable
{
return $this->syncedAt;
}
/**
* Records the outcome of a fetch against the `modified` it was made for.
*/
public function markFetched(ContentFetchState $state, \DateTimeImmutable $at): self
{
$this->fetchState = $state;
$this->fetchedModified = $this->modified;
$this->syncedAt = $at;
$this->updateIndexed();
return $this;
}
public function getMergedInto(): ?string
{
return $this->mergedInto;
}
public function setMergedInto(?string $mergedInto): self
{
$this->mergedInto = $mergedInto;
$this->updateIndexed();
return $this;
}
public function isIndexed(): bool
{
return $this->indexed;
}
private function updateIndexed(): void
{
$this->indexed = ContentFetchState::Ok === $this->fetchState
&& '' !== $this->bodyMd
&& null === $this->mergedInto
&& !$this->isHiddenFromSearch();
}
/**
* TYPO3's own "not in the website search" flag, on products.
*/
public function isHiddenFromSearch(): bool
{
return 'product' === $this->type && false === ($this->data['searchable'] ?? null);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ContentSyncStateRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Bookkeeping for the content feed sync. A single row.
*
* `manifestGeneratedAt` is the freshness figure: the `generatedAt` of the last manifest
* that was accepted. A run that stops early still advances it, because the deletions and
* the stale marks it is based on were applied.
*/
#[ORM\Entity(repositoryClass: ContentSyncStateRepository::class)]
#[ORM\Table(name: 'content_sync_state')]
class ContentSyncState
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $manifestGeneratedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $lastRunAt = null;
/**
* How the last run ended, in a few words. Null after a run that completed.
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $lastError = null;
/**
* Set from `Retry-After` when TYPO3 switched the feed off. Runs before it do nothing.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $pausedUntil = null;
public function getId(): ?int
{
return $this->id;
}
public function getManifestGeneratedAt(): ?\DateTimeImmutable
{
return $this->manifestGeneratedAt;
}
public function setManifestGeneratedAt(?\DateTimeImmutable $manifestGeneratedAt): self
{
$this->manifestGeneratedAt = $manifestGeneratedAt;
return $this;
}
public function getLastRunAt(): ?\DateTimeImmutable
{
return $this->lastRunAt;
}
public function setLastRunAt(?\DateTimeImmutable $lastRunAt): self
{
$this->lastRunAt = $lastRunAt;
return $this;
}
public function getLastError(): ?string
{
return $this->lastError;
}
public function setLastError(?string $lastError): self
{
$this->lastError = $lastError;
return $this;
}
public function getPausedUntil(): ?\DateTimeImmutable
{
return $this->pausedUntil;
}
public function setPausedUntil(?\DateTimeImmutable $pausedUntil): self
{
$this->pausedUntil = $pausedUntil;
return $this;
}
}