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,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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user