Files
MtoRagSystem/src/Catalog/EntityCatalogService.php
2026-04-20 16:36:28 +02:00

144 lines
3.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Catalog;
use App\Config\CatalogIntentConfig;
use App\Entity\Document;
use App\Tag\TagTypes;
use App\Tag\TagVectorSearchClient;
use Doctrine\DBAL\Connection;
use Symfony\Component\Uid\Uuid;
/**
* Builds deterministic catalog lists from a validated catalog entity term.
*
* This service is intentionally conservative:
* - only strong catalog_entity matches may open the catalog path
* - ambiguous matches fall back to normal retrieval
* - only ACTIVE documents are listed
*/
final class EntityCatalogService
{
private const SEARCH_LIMIT = 3;
public function __construct(
private readonly TagVectorSearchClient $tagVectorClient,
private readonly Connection $connection,
) {
}
/**
* Returns a catalog text block or null when no safe catalog path exists.
*/
public function listByTerm(string $entityTerm): ?string
{
$entityTerm = trim($entityTerm);
if ($entityTerm === '') {
return null;
}
$hits = $this->tagVectorClient->search($entityTerm, self::SEARCH_LIMIT);
if ($hits === []) {
return null;
}
$best = $hits[0];
$bestScore = (float) ($best['score'] ?? 0.0);
if ($bestScore < CatalogIntentConfig::MIN_SCORE) {
return null;
}
if (($best['tag_type'] ?? null) !== TagTypes::CATALOG_ENTITY) {
return null;
}
if (isset($hits[1])) {
$secondScore = (float) ($hits[1]['score'] ?? 0.0);
if (abs($bestScore - $secondScore) < CatalogIntentConfig::AMBIGUITY_DELTA) {
return null;
}
}
$tagId = trim((string) ($best['tag_id'] ?? ''));
if ($tagId === '') {
return null;
}
try {
$tagBinaryId = Uuid::fromString($tagId)->toBinary();
} catch (\Throwable) {
return null;
}
$tagLabel = trim((string) ($best['label'] ?? ''));
$rows = $this->connection->fetchAllAssociative(
'
SELECT DISTINCT d.title
FROM document d
INNER JOIN document_tag dt ON dt.document_id = d.id
WHERE dt.tag_id = :tagId
AND d.status = :status
ORDER BY d.title ASC
',
[
'tagId' => $tagBinaryId,
'status' => Document::STATUS_ACTIVE,
]
);
if ($rows === []) {
return null;
}
$titles = [];
foreach ($rows as $row) {
$title = trim((string) ($row['title'] ?? ''));
if ($title === '') {
continue;
}
$titles[$title] = $title;
}
if ($titles === []) {
return null;
}
return $this->buildTextBlock(
$tagLabel !== '' ? $tagLabel : null,
array_values($titles)
);
}
/**
* Builds a stable human-readable list block for the prompt.
*
* @param list<string> $titles
*/
private function buildTextBlock(?string $tagLabel, array $titles): string
{
$headline = 'Folgende Einträge sind verfügbar:';
if ($tagLabel !== null && trim($tagLabel) !== '') {
$headline = sprintf('Folgende %s sind verfügbar:', trim($tagLabel));
}
$lines = [];
foreach ($titles as $title) {
$lines[] = '- ' . $title;
}
return $headline . "\n\n" . implode("\n", $lines);
}
}