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,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\ContentSyncStateRepository;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:content:check-freshness',
|
||||
description: 'Fails when the last accepted content manifest is too old.',
|
||||
)]
|
||||
/**
|
||||
* The health check of the content sync. A stalled sync otherwise shows only as a chatbot
|
||||
* giving stale answers. Scheduled, a failure reaches the schedule's email_on_failure.
|
||||
*/
|
||||
class ContentCheckFreshnessCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContentSyncStateRepository $stateRepository,
|
||||
private readonly ClockInterface $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Maximum age of the last good manifest, in minutes', '60');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$state = $this->stateRepository->get();
|
||||
$generatedAt = $state->getManifestGeneratedAt();
|
||||
$maxAge = (int) $input->getOption('max-age');
|
||||
|
||||
if (null === $generatedAt) {
|
||||
$io->error('No content manifest has been accepted yet.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$ageMinutes = (int) floor(($this->clock->now()->getTimestamp() - $generatedAt->getTimestamp()) / 60);
|
||||
|
||||
if ($ageMinutes > $maxAge) {
|
||||
$io->error(sprintf('The last good content manifest is %d minutes old (generated %s). Last run error: %s', $ageMinutes, $generatedAt->format('c'), $state->getLastError() ?? 'none'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('The last good content manifest is %d minutes old.', $ageMinutes));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\ContentFeed\ContentSyncer;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Command\LockableTrait;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:content:sync',
|
||||
description: 'Mirrors the TYPO3 content feed for the chatbot MCP server.',
|
||||
)]
|
||||
/**
|
||||
* Console entrypoint for the content feed sync.
|
||||
*
|
||||
* Meant to run every 15 minutes, plus once a week with --refresh in the night window.
|
||||
* Outside 01:00-05:00 Europe/Berlin a run fetches at most 50 pages; --ignore-window lifts
|
||||
* that for a deliberate manual run, such as the first sync on staging.
|
||||
*/
|
||||
class ContentSyncCommand extends Command
|
||||
{
|
||||
use LockableTrait;
|
||||
|
||||
public function __construct(private readonly ContentSyncer $syncer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('refresh', null, InputOption::VALUE_NONE, sprintf('Also refetch items stored more than %d days ago, oldest first', ContentSyncer::REFRESH_AFTER_DAYS))
|
||||
->addOption('ignore-window', null, InputOption::VALUE_NONE, sprintf('Fetch every due page, not just %d, outside the night window', ContentSyncer::PAGES_OUTSIDE_WINDOW))
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if (!$this->lock()) {
|
||||
$io->warning('Another content sync is running.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$report = $this->syncer->run((bool) $input->getOption('refresh'), (bool) $input->getOption('ignore-window'));
|
||||
} finally {
|
||||
$this->release();
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['Added' => $report->added],
|
||||
['Deleted' => $report->deleted],
|
||||
['Records fetched' => $report->records],
|
||||
['Pages fetched' => $report->pages],
|
||||
['Failed' => $report->failed],
|
||||
['Pages deferred' => $report->deferred],
|
||||
);
|
||||
|
||||
if (null !== $report->pagePhaseEndedBy) {
|
||||
$io->warning('Page phase ended early: '.$report->pagePhaseEndedBy);
|
||||
}
|
||||
|
||||
if (null !== $report->stoppedBy) {
|
||||
$io->error('Run stopped: '.$report->stoppedBy);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('Content feed synced.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user