144 lines
3.5 KiB
PHP
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);
|
|
}
|
|
} |