first commit
This commit is contained in:
@@ -4,77 +4,84 @@ 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;
|
||||
|
||||
/**
|
||||
* EntityCatalogService
|
||||
* Builds deterministic catalog lists from a validated catalog entity term.
|
||||
*
|
||||
* Deterministische Katalog-Listen auf Basis eines Entity-Terms:
|
||||
* - TagVectorSearch (Score-Gate + Ambiguity-Check)
|
||||
* - DB Query auf document_tag + document (ACTIVE)
|
||||
* - Rückgabe als EIN Textblock (string) oder null (Fallback auf normalen Retrieval)
|
||||
*
|
||||
* Schritt-3 Änderung:
|
||||
* - Headline ist NICHT mehr hardcoded
|
||||
* - Headline basiert dynamisch auf dem gefundenen Tag
|
||||
* 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 MIN_SCORE = 0.55;
|
||||
private const AMBIGUITY_DELTA = 0.05;
|
||||
private const SEARCH_LIMIT = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly TagVectorSearchClient $tagVectorClient,
|
||||
private readonly Connection $connection,
|
||||
) {}
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Textblock oder null (wenn kein sicherer Catalog möglich ist)
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 1) Tag-Vektorsuche (Top 3 für Ambiguity-Prüfung)
|
||||
$hits = $this->tagVectorClient->search($entityTerm, 3);
|
||||
$hits = $this->tagVectorClient->search($entityTerm, self::SEARCH_LIMIT);
|
||||
|
||||
if ($hits === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$best = $hits[0];
|
||||
$bestScore = (float) ($best['score'] ?? 0.0);
|
||||
|
||||
$bestScore = isset($best['score']) ? (float)$best['score'] : 0.0;
|
||||
if ($bestScore < self::MIN_SCORE) {
|
||||
if ($bestScore < CatalogIntentConfig::MIN_SCORE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($best['tag_type'] ?? null) !== TagTypes::CATALOG_ENTITY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Ambiguity: wenn Top2 zu nah ist → konservativ abbrechen
|
||||
if (isset($hits[1])) {
|
||||
$secondScore = isset($hits[1]['score']) ? (float)$hits[1]['score'] : 0.0;
|
||||
if (abs($bestScore - $secondScore) < self::AMBIGUITY_DELTA) {
|
||||
$secondScore = (float) ($hits[1]['score'] ?? 0.0);
|
||||
|
||||
if (abs($bestScore - $secondScore) < CatalogIntentConfig::AMBIGUITY_DELTA) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$tagHex = (string)($best['tag_id'] ?? '');
|
||||
if ($tagHex === '') {
|
||||
$tagId = trim((string) ($best['tag_id'] ?? ''));
|
||||
|
||||
if ($tagId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// OPTIONAL: Falls TagVectorSearchClient künftig tag_label zurückliefert,
|
||||
// kann das hier direkt verwendet werden.
|
||||
$tagLabel = isset($best['tag_label']) ? (string)$best['tag_label'] : null;
|
||||
try {
|
||||
$tagBinaryId = Uuid::fromString($tagId)->toBinary();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagLabel = trim((string) ($best['label'] ?? ''));
|
||||
|
||||
// 3) DB Query: alle ACTIVE Dokumente zu diesem Tag
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'
|
||||
SELECT d.title
|
||||
SELECT DISTINCT d.title
|
||||
FROM document d
|
||||
INNER JOIN document_tag dt ON dt.document_id = d.id
|
||||
WHERE dt.tag_id = :tagId
|
||||
@@ -82,8 +89,8 @@ final class EntityCatalogService
|
||||
ORDER BY d.title ASC
|
||||
',
|
||||
[
|
||||
'tagId' => Uuid::fromString($tagHex)->toBinary(),
|
||||
'status' => 'ACTIVE',
|
||||
'tagId' => $tagBinaryId,
|
||||
'status' => Document::STATUS_ACTIVE,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -92,37 +99,42 @@ final class EntityCatalogService
|
||||
}
|
||||
|
||||
$titles = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$t = trim((string)($row['title'] ?? ''));
|
||||
if ($t !== '') {
|
||||
$titles[] = $t;
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$titles[$title] = $title;
|
||||
}
|
||||
|
||||
if ($titles === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildTextBlock($tagLabel, $titles);
|
||||
return $this->buildTextBlock(
|
||||
$tagLabel !== '' ? $tagLabel : null,
|
||||
array_values($titles)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamische Headline:
|
||||
* - Wenn Tag-Label vorhanden → verwenden
|
||||
* - Sonst generischer Fallback
|
||||
* 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 (\is_string($tagLabel) && \trim($tagLabel) !== '') {
|
||||
$headline = sprintf(
|
||||
'Folgende %s sind verfügbar:',
|
||||
$tagLabel
|
||||
);
|
||||
if ($tagLabel !== null && trim($tagLabel) !== '') {
|
||||
$headline = sprintf('Folgende %s sind verfügbar:', trim($tagLabel));
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
foreach ($titles as $title) {
|
||||
$lines[] = '- ' . $title;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user