first commit

This commit is contained in:
team 1
2026-04-20 16:36:28 +02:00
parent a0ec07a99c
commit 2587ac8b4b
41 changed files with 5126 additions and 2280 deletions

View File

@@ -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;
}