diff --git a/config/services.yaml b/config/services.yaml index 2bcccee..39b350d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -1,55 +1,69 @@ +imports: + - { resource: 'retriex/runtime.yaml' } + - { resource: 'retriex/index.yaml' } + - { resource: 'retriex/vector.yaml' } + - { resource: 'retriex/commerce.yaml' } + - { resource: 'retriex/model.yaml' } + - { resource: 'retriex/prompt.yaml' } + - { resource: 'retriex/agent.yaml' } + - { resource: 'retriex/retrieval.yaml' } + # ------------------------------------------------------------ # Parameters # ------------------------------------------------------------ parameters: - mto.root: '%kernel.project_dir%' + mto.root: '%retriex.root%' mto.kernel.dir: '%mto.root%' - mto.locks.dir: '%mto.knowledge.root%/locks' + mto.locks.dir: '%retriex.locks.dir%' - mto.knowledge.root: '%mto.root%/var/knowledge' + mto.knowledge.root: '%retriex.knowledge.root%' - mto.knowledge.ndjson: '%mto.knowledge.root%/index.ndjson' - mto.knowledge.index_meta: '%mto.knowledge.root%/index_meta.json' - mto.knowledge.vector_index: '%mto.knowledge.root%/vector.index' - mto.knowledge.vector_index_meta: '%mto.knowledge.root%/vector.index.meta.json' - mto.runtime.meta: '%mto.knowledge.root%/index_runtime.json' - mto.knowledge.upload: '%mto.knowledge.root%/uploads' + mto.knowledge.ndjson: '%retriex.knowledge.ndjson%' + mto.knowledge.index_meta: '%retriex.knowledge.index_meta%' + mto.knowledge.vector_index: '%retriex.knowledge.vector_index%' + mto.knowledge.vector_index_meta: '%retriex.knowledge.vector_index_meta%' + mto.runtime.meta: '%retriex.knowledge.runtime_meta%' + mto.knowledge.upload: '%retriex.knowledge.upload%' - mto.knowledge.tags_ndjson: '%mto.knowledge.root%/tags.ndjson' - mto.knowledge.vector_tags_index: '%mto.knowledge.root%/vector_tags.index' - mto.knowledge.vector_tags_index_meta: '%mto.knowledge.root%/vector_tags.index.meta.json' + mto.knowledge.tags_ndjson: '%retriex.knowledge.tags_ndjson%' + mto.knowledge.vector_tags_index: '%retriex.knowledge.vector_tags_index%' + mto.knowledge.vector_tags_index_meta: '%retriex.knowledge.vector_tags_index_meta%' - mto.vector.script_dir: '%mto.root%/python/vector' + mto.vector.script_dir: '%retriex.vector.script_dir%' - mto.vector.ingest_tags_script: '%mto.vector.script_dir%/vector_ingest_tags.py' - mto.vector.search_tags_script: '%mto.vector.script_dir%/vector_search_tags.py' + mto.vector.ingest_tags_script: '%retriex.vector.ingest_tags_script%' + mto.vector.search_tags_script: '%retriex.vector.search_tags_script%' - mto.tags.rebuild_lock: '%mto.locks.dir%/tag_rebuild.lock' + mto.tags.rebuild_lock: '%retriex.tags.rebuild_lock%' mto.vector.data.upload.path: '%mto.knowledge.upload%' - mto.index.chunk_size: 250 - mto.index.chunk_overlap: 50 - mto.index.embedding_model: 'intfloat/multilingual-e5-base' - mto.index.embedding_dimension: 768 - mto.index.scoring_version: 1 + mto.index.chunk_size: '%retriex.index.chunk_size%' + mto.index.chunk_overlap: '%retriex.index.chunk_overlap%' + mto.index.embedding_model: '%retriex.index.embedding_model%' + mto.index.embedding_dimension: '%retriex.index.embedding_dimension%' + mto.index.scoring_version: '%retriex.index.scoring_version%' - mto.vector.python_bin: '%kernel.project_dir%/.venv/bin/python3' - mto.vector.ingest_script: '%mto.vector.script_dir%/vector_ingest.py' - mto.vector.search_script: '%mto.vector.script_dir%/vector_search.py' - mto.vector.timeout: 600 - mto.vector.service_url: 'http://127.0.0.1:8090' + mto.vector.python_bin: '%retriex.vector.python_bin%' + mto.vector.control_script: '%retriex.vector.control_script%' + mto.vector.ingest_script: '%retriex.vector.ingest_script%' + mto.vector.search_script: '%retriex.vector.search_script%' + mto.vector.timeout: '%retriex.vector.timeout%' + mto.vector.service_url: '%retriex.vector.service_url%' + mto.vector.host: '%retriex.vector.host%' + mto.vector.port: '%retriex.vector.port%' - mto.commerce.enabled: true - mto.commerce.max_shop_results: '%env(SHOPWARE_STORE_API_MAX_RESULT)%' - mto.commerce.shop_timeout: 5 - mto.commerce.store_api_base_url: '%env(SHOPWARE_STORE_API_BASE_URL)%' - mto.commerce.sales_channel_access_key: '%env(SHOPWARE_SALES_CHANNEL_ACCESS_KEY)%' + mto.commerce.enabled: '%retriex.commerce.enabled%' + mto.commerce.max_shop_results: '%retriex.commerce.max_shop_results%' + mto.commerce.shop_timeout: '%retriex.commerce.shop_timeout%' + mto.commerce.store_api_base_url: '%retriex.commerce.store_api_base_url%' + mto.commerce.sales_channel_access_key: '%retriex.commerce.sales_channel_access_key%' + + mto.commerce.search_repair.enabled: '%retriex.commerce.search_repair.enabled%' + mto.commerce.search_repair.max_queries: '%retriex.commerce.search_repair.max_queries%' + mto.commerce.search_repair.min_primary_results_without_repair: '%retriex.commerce.search_repair.min_primary_results_without_repair%' - mto.commerce.search_repair.enabled: true - mto.commerce.search_repair.max_queries: 3 - mto.commerce.search_repair.min_primary_results_without_repair: 2 # ------------------------------------------------------------ @@ -80,10 +94,35 @@ services: # AI Agent – Infrastructure # ------------------------------------------------------------ + # ------------------------------------------------------------ + # RetrieX Config Facades + # ------------------------------------------------------------ + + App\Config\ModelGenerationDefaultsConfig: + arguments: + $modelName: '%retriex.model.default_name%' + $stream: '%retriex.model.default_stream%' + $temperature: '%retriex.model.default_temperature%' + $topK: '%retriex.model.default_top_k%' + $topP: '%retriex.model.default_top_p%' + $repeatPenalty: '%retriex.model.default_repeat_penalty%' + $numCtx: '%retriex.model.default_num_ctx%' + $retrievalMaxChunks: '%retriex.model.default_retrieval_max_chunks%' + $retrievalVectorTopK: '%retriex.model.default_retrieval_vector_top_k%' + + App\Config\PromptBuilderConfig: + arguments: + $config: '%retriex.prompt.config%' + + App\Config\AgentRunnerConfig: + arguments: + $config: '%retriex.agent.config%' + App\Infrastructure\OllamaClient: arguments: $apiUrl: '%env(AI_LLM_API_URL)%' - $timeoutSeconds: 600 + $timeoutSeconds: '%retriex.llm.timeout_seconds%' + $configProvider: '@App\Service\ModelGenerationConfigProvider' # ------------------------------------------------------------ @@ -120,6 +159,12 @@ services: App\Commerce\CommerceQueryParser: ~ + App\Config\SearchRepairConfig: + arguments: + $enabled: '%retriex.commerce.search_repair.enabled%' + $maxRepairQueries: '%retriex.commerce.search_repair.max_queries%' + $minPrimaryResultsWithoutRepair: '%retriex.commerce.search_repair.min_primary_results_without_repair%' + App\Commerce\SearchRepairService: ~ App\Shopware\ShopwareCriteriaBuilder: ~ @@ -167,6 +212,9 @@ services: arguments: $serviceUrl: '%mto.vector.service_url%' $agentLogger: '@monolog.logger.agent' + $minScore: '%retriex.vector.search.min_score%' + $maxLimit: '%retriex.vector.search.max_limit%' + $timeoutSeconds: '%retriex.vector.search.http_timeout%' App\Vector\VectorIndexBuilder: arguments: @@ -215,8 +263,20 @@ services: arguments: $serviceUrl: '%mto.vector.service_url%' $agentLogger: '@monolog.logger.agent' + $minScore: '%retriex.vector.tags.min_score%' + $defaultLimit: '%retriex.vector.tags.default_limit%' + $maxLimit: '%retriex.vector.tags.max_limit%' + $timeoutSeconds: '%retriex.vector.tags.http_timeout%' - App\Tag\TagRoutingService: ~ + App\Tag\TagRoutingService: + arguments: + $defaultTopK: '%retriex.vector.tag_routing.default_topk%' + $minBestScore: '%retriex.vector.tag_routing.min_best_score%' + $maxScoreDropFromBest: '%retriex.vector.tag_routing.max_score_drop_from_best%' + $maxRoutingTags: '%retriex.vector.tag_routing.max_routing_tags%' + $maxCandidateDocs: '%retriex.vector.tag_routing.max_candidate_docs%' + $multiTagBonusPerExtraTag: '%retriex.vector.tag_routing.multi_tag_bonus_per_extra_tag%' + $maxMultiTagBonus: '%retriex.vector.tag_routing.max_multi_tag_bonus%' App\Tag\TagVectorIndexHealthService: arguments: @@ -237,6 +297,23 @@ services: arguments: $lockFilePath: '%mto.tags.rebuild_lock%' + App\Command\VectorControlCommand: + arguments: + $vectorPythonBin: '%mto.vector.python_bin%' + $vectorControlScript: '%mto.vector.control_script%' + $defaultHost: '%mto.vector.host%' + $defaultPort: '%mto.vector.port%' + $timeoutSeconds: '%mto.vector.timeout%' + + App\Command\SystemRebuildCommand: + arguments: + $projectDir: '%mto.root%' + $vectorPythonBin: '%mto.vector.python_bin%' + $vectorControlScript: '%mto.vector.control_script%' + $vectorHost: '%mto.vector.host%' + $vectorPort: '%mto.vector.port%' + $vectorTimeoutSeconds: '%mto.vector.timeout%' + # ------------------------------------------------------------ # Admin Utilities # ------------------------------------------------------------ @@ -250,4 +327,4 @@ services: arguments: $indexNdjsonPath: '%mto.knowledge.ndjson%' $vectorIndexPath: '%mto.knowledge.vector_index%' - $vectorMetaPath: '%mto.knowledge.vector_index_meta%' \ No newline at end of file + $vectorMetaPath: '%mto.knowledge.vector_index_meta%' diff --git a/src/Command/SystemRebuildCommand.php b/src/Command/SystemRebuildCommand.php index 54f141f..64b2256 100644 --- a/src/Command/SystemRebuildCommand.php +++ b/src/Command/SystemRebuildCommand.php @@ -35,6 +35,11 @@ final class SystemRebuildCommand extends Command private readonly VectorIndexHealthService $health, private readonly TagVectorIndexHealthService $tagHealth, private readonly string $projectDir, + private readonly string $vectorPythonBin = '.venv/bin/python', + private readonly string $vectorControlScript = 'python/vector/vector_control.py', + private readonly string $vectorHost = '0.0.0.0', + private readonly int $vectorPort = 8090, + private readonly int $vectorTimeoutSeconds = 600, ) { parent::__construct(); } @@ -166,17 +171,17 @@ final class SystemRebuildCommand extends Command } $cmd = [ - '.venv/bin/python', - 'python/vector/vector_control.py', + $this->vectorPythonBin, + $this->vectorControlScript, '--install', '--start', '--reload', - '--port', '8090', - '--host', '0.0.0.0', + '--port', (string) $this->vectorPort, + '--host', $this->vectorHost, ]; $process = new Process($cmd, $this->projectDir); - $process->setTimeout(600); + $process->setTimeout($this->vectorTimeoutSeconds); $process->run(); $stdout = trim($process->getOutput()); diff --git a/src/Command/VectorControlCommand.php b/src/Command/VectorControlCommand.php index 98f28de..9d01263 100644 --- a/src/Command/VectorControlCommand.php +++ b/src/Command/VectorControlCommand.php @@ -17,6 +17,16 @@ use Symfony\Component\Process\Process; )] final class VectorControlCommand extends Command { + public function __construct( + private readonly string $vectorPythonBin = '.venv/bin/python', + private readonly string $vectorControlScript = 'python/vector/vector_control.py', + private readonly string $defaultHost = '0.0.0.0', + private readonly int $defaultPort = 8090, + private readonly int $timeoutSeconds = 300, + ) { + parent::__construct(); + } + protected function configure(): void { $this @@ -27,13 +37,13 @@ final class VectorControlCommand extends Command ->addOption('reload', null, InputOption::VALUE_NONE, 'Trigger /reload') ->addOption('status', null, InputOption::VALUE_NONE, 'Print status') ->addOption('foreground', null, InputOption::VALUE_NONE, 'Start in foreground (rare)') - ->addOption('port', null, InputOption::VALUE_OPTIONAL, 'Port (default 8090)', '8090') - ->addOption('host', null, InputOption::VALUE_OPTIONAL, 'Host (default 0.0.0.0)', '0.0.0.0'); + ->addOption('port', null, InputOption::VALUE_OPTIONAL, 'Port', (string) $this->defaultPort) + ->addOption('host', null, InputOption::VALUE_OPTIONAL, 'Host', $this->defaultHost); } protected function execute(InputInterface $input, OutputInterface $output): int { - $cmd = ['.venv/bin/python', 'python/vector/vector_control.py']; + $cmd = [$this->vectorPythonBin, $this->vectorControlScript]; if ($input->getOption('install')) { $cmd[] = '--install'; @@ -58,17 +68,17 @@ final class VectorControlCommand extends Command } $cmd[] = '--port'; - $cmd[] = (string)$input->getOption('port'); + $cmd[] = (string) $input->getOption('port'); $cmd[] = '--host'; - $cmd[] = (string)$input->getOption('host'); + $cmd[] = (string) $input->getOption('host'); $process = new Process($cmd); - $process->setTimeout(300); + $process->setTimeout($this->timeoutSeconds); $process->run(); $output->writeln($process->getOutput()); return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE; } -} \ No newline at end of file +} diff --git a/src/Config/AgentRunnerConfig.php b/src/Config/AgentRunnerConfig.php index 0884d8e..8e0ebbc 100644 --- a/src/Config/AgentRunnerConfig.php +++ b/src/Config/AgentRunnerConfig.php @@ -6,24 +6,32 @@ namespace App\Config; final class AgentRunnerConfig { + /** + * @param array $config + */ + public function __construct( + private readonly array $config = [], + ) { + } + public function getCommerceHistoryBudgetChars(): int { - return 1000; + return $this->getInt('commerce_history_budget_chars', 1000); } public function getProductSearchKnowledgeChunkLimit(): int { - return 6; + return $this->getInt('product_search_knowledge_chunk_limit', 6); } public function getAdvisoryProductSearchKnowledgeChunkLimit(): int { - return 9; + return $this->getInt('advisory_product_search_knowledge_chunk_limit', 9); } public function getOptimizedShopQueryPrefixPattern(): string { - return '/^(?:keywords?|suchquery|search\s*query|query)\s*:\s*/iu'; + return $this->getString('optimized_shop_query_prefix_pattern', '/^(?:keywords?|suchquery|search\\s*query|query)\\s*:\\s*/iu'); } public function getOptimizedShopQueryTrimCharacters(): string @@ -31,6 +39,20 @@ final class AgentRunnerConfig return " \t\n\r\0\x0B\"'`"; } + private function getInt(string $key, int $default): int + { + $value = $this->config[$key] ?? $default; + + return is_numeric($value) ? (int) $value : $default; + } + + private function getString(string $key, string $default): string + { + $value = $this->config[$key] ?? $default; + + return is_string($value) && $value !== '' ? $value : $default; + } + public function getEmptyPromptMessage(): string { return '❌ Empty prompt.'; diff --git a/src/Config/PromptBuilderConfig.php b/src/Config/PromptBuilderConfig.php index 7e44631..6ee2bbc 100644 --- a/src/Config/PromptBuilderConfig.php +++ b/src/Config/PromptBuilderConfig.php @@ -6,64 +6,101 @@ namespace App\Config; final class PromptBuilderConfig { + /** + * @param array $config + */ + public function __construct( + private readonly array $config = [], + ) { + } + public function getCharsPerToken(): int { - return 4; + return $this->getInt('budget.chars_per_token', 4); } public function getHistoryPaddingChars(): int { - return 400; + return $this->getInt('budget.history_padding_chars', 400); } public function getOutputReserveRatio(): float { - return 0.25; + return $this->getFloat('budget.output_reserve_ratio', 0.25); } public function getOutputReserveMinTokens(): int { - return 768; + return $this->getInt('budget.output_reserve_min_tokens', 768); } public function getOutputReserveMaxTokens(): int { - return 6000; + return $this->getInt('budget.output_reserve_max_tokens', 6000); } public function getSafetyReserveRatio(): float { - return 0.05; + return $this->getFloat('budget.safety_reserve_ratio', 0.05); } public function getSafetyReserveMinTokens(): int { - return 256; + return $this->getInt('budget.safety_reserve_min_tokens', 256); } public function getSafetyReserveMaxTokens(): int { - return 1024; + return $this->getInt('budget.safety_reserve_max_tokens', 1024); } public function getMinPromptBudgetTokens(): int { - return 1024; + return $this->getInt('budget.min_prompt_budget_tokens', 1024); } public function getMaxShopResultsInPrompt(): int { - return 24; + return $this->getInt('shop_results.max_results_in_prompt', 24); } public function getDetailedShopResultsMaxCount(): int { - return 5; + return $this->getInt('shop_results.detailed_max_count', 5); } public function getTechnicalProductKeywordMatchThreshold(): int { - return 2; + return $this->getInt('technical_product_keyword_match_threshold', 2); + } + + private function getInt(string $path, int $default): int + { + $value = $this->getValue($path, $default); + + return is_numeric($value) ? (int) $value : $default; + } + + private function getFloat(string $path, float $default): float + { + $value = $this->getValue($path, $default); + + return is_numeric($value) ? (float) $value : $default; + } + + private function getValue(string $path, mixed $default): mixed + { + $current = $this->config; + + foreach (explode('.', $path) as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return $default; + } + + $current = $current[$segment]; + } + + return $current; } public function getSystemSectionLabel(): string diff --git a/src/Config/SearchRepairConfig.php b/src/Config/SearchRepairConfig.php index 038363b..fee9bea 100644 --- a/src/Config/SearchRepairConfig.php +++ b/src/Config/SearchRepairConfig.php @@ -6,19 +6,26 @@ namespace App\Config; final class SearchRepairConfig { + public function __construct( + private readonly bool $enabled = true, + private readonly int $maxRepairQueries = 3, + private readonly int $minPrimaryResultsWithoutRepair = 2, + ) { + } + public function isEnabled(): bool { - return true; + return $this->enabled; } public function getMaxRepairQueries(): int { - return 3; + return $this->maxRepairQueries; } public function getMinPrimaryResultsWithoutRepair(): int { - return 2; + return $this->minPrimaryResultsWithoutRepair; } public function getTopProductLogLimit(): int diff --git a/src/Service/Admin/ModelGenerationConfigAdminService.php b/src/Service/Admin/ModelGenerationConfigAdminService.php index 7b446f3..f8d772f 100644 --- a/src/Service/Admin/ModelGenerationConfigAdminService.php +++ b/src/Service/Admin/ModelGenerationConfigAdminService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service\Admin; +use App\Config\ModelGenerationDefaultsConfig; use App\Entity\ModelGenerationConfig; use App\Knowledge\Retrieval\NdjsonHybridRetriever; use App\Repository\ModelGenerationConfigRepository; @@ -17,6 +18,7 @@ final class ModelGenerationConfigAdminService private readonly EntityManagerInterface $em, private readonly ModelGenerationConfigManager $manager, private readonly NdjsonHybridRetriever $retriever, + private readonly ModelGenerationDefaultsConfig $defaults, ) {} public function list(): array @@ -36,15 +38,15 @@ final class ModelGenerationConfigAdminService $config = new ModelGenerationConfig( modelName: $modelName, version: $version, - stream: (bool)($data['stream'] ?? false), - temperature: (float)($data['temperature'] ?? 0.7), - topK: (int)($data['top_k'] ?? 40), - topP: (float)($data['top_p'] ?? 0.9), - repeatPenalty: (float)($data['repeat_penalty'] ?? 1.1), - numCtx: (int)($data['num_ctx'] ?? 4096), + stream: (bool)($data['stream'] ?? $this->defaults->isStream()), + temperature: (float)($data['temperature'] ?? $this->defaults->getTemperature()), + topK: (int)($data['top_k'] ?? $this->defaults->getTopK()), + topP: (float)($data['top_p'] ?? $this->defaults->getTopP()), + repeatPenalty: (float)($data['repeat_penalty'] ?? $this->defaults->getRepeatPenalty()), + numCtx: (int)($data['num_ctx'] ?? $this->defaults->getNumCtx()), active: false, - retrievalMaxChunks: (int)($data['retrieval_max_chunks'] ?? 25), - retrievalVectorTopK: (int)($data['retrieval_vector_top_k'] ?? 25), + retrievalMaxChunks: (int)($data['retrieval_max_chunks'] ?? $this->defaults->getRetrievalMaxChunks()), + retrievalVectorTopK: (int)($data['retrieval_vector_top_k'] ?? $this->defaults->getRetrievalVectorTopK()), ); $this->em->persist($config); @@ -61,7 +63,7 @@ final class ModelGenerationConfigAdminService public function delete(ModelGenerationConfig $config): void { if ($config->isActive()) { - throw new \RuntimeException('Aktive Konfiguration kann nicht gelöscht werden.'); + throw new \RuntimeException('Aktive Konfiguration kann nicht geloescht werden.'); } $this->em->remove($config); @@ -74,7 +76,7 @@ final class ModelGenerationConfigAdminService return []; } - return $this->retriever->retrieveDebug($prompt,$config); + return $this->retriever->retrieveDebug($prompt, $config); } private function requireString(mixed $value, string $field): string @@ -84,4 +86,4 @@ final class ModelGenerationConfigAdminService } return trim($value); } -} \ No newline at end of file +} diff --git a/src/Service/ModelGenerationConfigProvider.php b/src/Service/ModelGenerationConfigProvider.php index e487959..d379141 100644 --- a/src/Service/ModelGenerationConfigProvider.php +++ b/src/Service/ModelGenerationConfigProvider.php @@ -4,15 +4,16 @@ declare(strict_types=1); namespace App\Service; +use App\Config\ModelGenerationDefaultsConfig; use App\Entity\ModelGenerationConfig; use App\Repository\ModelGenerationConfigRepository; final readonly class ModelGenerationConfigProvider { public function __construct( - private ModelGenerationConfigRepository $repository - ) - { + private ModelGenerationConfigRepository $repository, + private ModelGenerationDefaultsConfig $defaults, + ) { } public function getActiveForModel(): ModelGenerationConfig @@ -23,19 +24,18 @@ final readonly class ModelGenerationConfigProvider return $config; } - // ------------------------------ - // Safe Enterprise Default Fallback - // ------------------------------ return new ModelGenerationConfig( - modelName: 'mto-model', + modelName: $this->defaults->getModelName(), version: 0, - stream: false, - temperature: 0.1, - topK: 20, - topP: 0.8, - repeatPenalty: 1.05, - numCtx: 4096, - active: false + stream: $this->defaults->isStream(), + temperature: $this->defaults->getTemperature(), + topK: $this->defaults->getTopK(), + topP: $this->defaults->getTopP(), + repeatPenalty: $this->defaults->getRepeatPenalty(), + numCtx: $this->defaults->getNumCtx(), + active: false, + retrievalMaxChunks: $this->defaults->getRetrievalMaxChunks(), + retrievalVectorTopK: $this->defaults->getRetrievalVectorTopK(), ); } @@ -45,4 +45,4 @@ final readonly class ModelGenerationConfigProvider return max(512, $numCtx); } -} \ No newline at end of file +} diff --git a/src/Tag/TagRoutingService.php b/src/Tag/TagRoutingService.php index 5a497e3..d0bcede 100644 --- a/src/Tag/TagRoutingService.php +++ b/src/Tag/TagRoutingService.php @@ -15,41 +15,16 @@ final class TagRoutingService /** * Number of raw tag hits requested from the vector service. */ - private const DEFAULT_TOPK = 8; - - /** - * Hard minimum confidence required to activate tag-based document routing. - * - * This intentionally aligns with the tag vector client gate to avoid - * misleading secondary thresholds in this class. - */ - private const MIN_BEST_SCORE = 0.72; - - /** - * Only keep tag hits that stay reasonably close to the best hit. - * This reduces semantic spillover into weakly related document spaces. - */ - private const MAX_SCORE_DROP_FROM_BEST = 0.08; - - /** - * Maximum number of tag hits that may influence routing. - */ - private const MAX_ROUTING_TAGS = 5; - - /** - * Maximum number of candidate documents passed into scoped chunk search. - */ - private const MAX_CANDIDATE_DOCS = 80; - - /** - * Small bonus for documents matched by multiple routed tags. - */ - private const MULTI_TAG_BONUS_PER_EXTRA_TAG = 0.05; - private const MAX_MULTI_TAG_BONUS = 0.15; - public function __construct( private readonly TagVectorSearchClient $tagSearch, private readonly EntityManagerInterface $em, + private readonly int $defaultTopK = 8, + private readonly float $minBestScore = 0.72, + private readonly float $maxScoreDropFromBest = 0.08, + private readonly int $maxRoutingTags = 5, + private readonly int $maxCandidateDocs = 80, + private readonly float $multiTagBonusPerExtraTag = 0.05, + private readonly float $maxMultiTagBonus = 0.15, ) { } @@ -71,7 +46,7 @@ final class TagRoutingService } $hits = $this->filterRoutingHits( - $this->tagSearch->search($query, self::DEFAULT_TOPK) + $this->tagSearch->search($query, $this->defaultTopK) ); if ($hits === []) { @@ -159,8 +134,8 @@ final class TagRoutingService if ($matchedTagCount > 1) { $documentScores[$documentId] += min( - self::MAX_MULTI_TAG_BONUS, - ($matchedTagCount - 1) * self::MULTI_TAG_BONUS_PER_EXTRA_TAG + $this->maxMultiTagBonus, + ($matchedTagCount - 1) * $this->multiTagBonusPerExtraTag ); } } @@ -170,7 +145,7 @@ final class TagRoutingService return array_slice( array_keys($documentScores), 0, - self::MAX_CANDIDATE_DOCS + $this->maxCandidateDocs ); } @@ -196,13 +171,13 @@ final class TagRoutingService $bestScore = (float) ($hits[0]['score'] ?? 0.0); - if ($bestScore < self::MIN_BEST_SCORE) { + if ($bestScore < $this->minBestScore) { return []; } $minimumAcceptedScore = max( - self::MIN_BEST_SCORE, - $bestScore - self::MAX_SCORE_DROP_FROM_BEST + $this->minBestScore, + $bestScore - $this->maxScoreDropFromBest ); $filtered = []; @@ -230,7 +205,7 @@ final class TagRoutingService 'tag_type' => $tagType, ]; - if (count($filtered) >= self::MAX_ROUTING_TAGS) { + if (count($filtered) >= $this->maxRoutingTags) { break; } } diff --git a/src/Tag/TagVectorSearchClient.php b/src/Tag/TagVectorSearchClient.php index cec3537..304d985 100644 --- a/src/Tag/TagVectorSearchClient.php +++ b/src/Tag/TagVectorSearchClient.php @@ -9,46 +9,20 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; final readonly class TagVectorSearchClient { - /** - * Minimum similarity score required for a tag to be considered. - */ - public const MIN_SCORE = 0.72; - - /** - * Default result size when callers do not specify a limit. - */ - private const DEFAULT_LIMIT = 8; - - /** - * Hard limit to prevent excessive requests. - */ - private const MAX_LIMIT = 50; - - /** - * HTTP timeout for the Python vector service. - */ - private const TIMEOUT_SECONDS = 10; - public function __construct( private HttpClientInterface $http, private string $serviceUrl, private LoggerInterface $agentLogger, + private float $minScore = 0.72, + private int $defaultLimit = 8, + private int $maxLimit = 50, + private int $timeoutSeconds = 10, ) { } /** * Executes a vector search against the Python tag index. * - * Expected response rows: - * [ - * { - * "tag_id": "...", - * "score": 0.73, - * "label": "Geräte", - * "tag_type": "catalog_entity" - * } - * ] - * * @return list */ - public function search(string $query, int $limit = self::DEFAULT_LIMIT): array + public function search(string $query, ?int $limit = null): array { $query = trim($query); @@ -64,7 +38,7 @@ final readonly class TagVectorSearchClient return []; } - $limit = max(1, min($limit, self::MAX_LIMIT)); + $limit = $this->clampLimit($limit ?? $this->defaultLimit); $serviceUrl = rtrim(trim($this->serviceUrl), '/'); if ($serviceUrl === '') { @@ -82,7 +56,7 @@ final readonly class TagVectorSearchClient 'query' => $query, 'limit' => $limit, ], - 'timeout' => self::TIMEOUT_SECONDS, + 'timeout' => $this->timeoutSeconds, ] ); @@ -141,7 +115,7 @@ final readonly class TagVectorSearchClient $score = (float) $score; - if ($score < self::MIN_SCORE) { + if ($score < $this->minScore) { continue; } @@ -186,4 +160,9 @@ final readonly class TagVectorSearchClient return array_slice($hits, 0, $limit); } -} \ No newline at end of file + + private function clampLimit(int $limit): int + { + return max(1, min($limit, $this->maxLimit)); + } +} diff --git a/src/Vector/VectorSearchClient.php b/src/Vector/VectorSearchClient.php index 5932615..523d46a 100644 --- a/src/Vector/VectorSearchClient.php +++ b/src/Vector/VectorSearchClient.php @@ -9,17 +9,6 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; final class VectorSearchClient { - /** - * Soft minimum similarity threshold. - * Lower than tag gate to allow broader recall. - */ - private const MIN_SCORE = 0.30; - - /** - * Hard limit clamp to avoid abusive queries. - */ - private const MAX_LIMIT = 200; - private HttpClientInterface $http; private string $serviceUrl; private LoggerInterface $agentLogger; @@ -27,7 +16,10 @@ final class VectorSearchClient public function __construct( HttpClientInterface $http, string $serviceUrl, - LoggerInterface $agentLogger + LoggerInterface $agentLogger, + private readonly float $minScore = 0.30, + private readonly int $maxLimit = 200, + private readonly int $timeoutSeconds = 10, ) { $this->http = $http; $this->serviceUrl = rtrim($serviceUrl, '/'); @@ -100,7 +92,7 @@ final class VectorSearchClient $this->serviceUrl . '/search-chunks', [ 'json' => $payload, - 'timeout' => 10, + 'timeout' => $this->timeoutSeconds, ] ); @@ -143,8 +135,7 @@ final class VectorSearchClient $score = (float)$score; - // 🔥 Soft Confidence Gate - if ($score < self::MIN_SCORE) { + if ($score < $this->minScore) { continue; } @@ -179,10 +170,10 @@ final class VectorSearchClient return 1; } - if ($limit > self::MAX_LIMIT) { - return self::MAX_LIMIT; + if ($limit > $this->maxLimit) { + return $this->maxLimit; } return $limit; } -} \ No newline at end of file +}