$candidates */ public function rank(string $ticketMessage, array $candidates, string $language = 'nl'): ClassificationResultDTO { if ($candidates === []) { return new ClassificationResultDTO(null, 0.0, 'No article candidates available', rawResponse: ['mode' => 'none']); } if (! (bool) config('services.llm.ranking_enabled', true)) { return new ClassificationResultDTO( articleId: $candidates[0]->articleId, confidence: 0.20, explanation: 'LLM ranking disabled; using top semantic candidate.', rawResponse: ['mode' => 'semantic_fallback', 'ranking_enabled' => false] ); } $basePrompt = $this->settings->getPrompt('classifier', 'Select best article and return JSON.'); $prompt = $this->promptBuilder->build($basePrompt, $ticketMessage, $candidates, $language); try { $text = $this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'classifier']); } catch (\Throwable $e) { return new ClassificationResultDTO( articleId: $candidates[0]->articleId, confidence: 0.25, explanation: 'LLM unavailable; fallback to top semantic match. Reason: '.$e->getMessage(), rawResponse: ['mode' => 'semantic_fallback', 'error' => $e->getMessage()] ); } $decoded = $this->jsonDecoder->decode($text); if (! is_array($decoded)) { return new ClassificationResultDTO( articleId: $candidates[0]->articleId, confidence: 0.35, explanation: 'LLM returned invalid JSON; defaulted to top semantic match.', rawResponse: ['mode' => 'semantic_fallback', 'raw' => $text] ); } $validated = $this->validateClassificationSchema($decoded, $candidates); if ($validated === null) { return new ClassificationResultDTO( articleId: $candidates[0]->articleId, confidence: 0.35, explanation: 'LLM JSON schema invalid; defaulted to top semantic match.', rawResponse: ['mode' => 'semantic_fallback', 'raw' => $decoded] ); } $validated['_meta'] = [ 'mode' => 'llm', 'provider' => $this->settings->get('llm.provider', (string) config('services.llm.provider')), 'model' => $this->settings->get('llm.models.classifier', (string) config('services.llm.chat_model')), ]; return new ClassificationResultDTO( articleId: (int) $validated['article_id'], confidence: (float) $validated['confidence'], explanation: (string) $validated['explanation'], toolCall: $validated['tool_call'] ?? null, rawResponse: $validated ); } private function validateClassificationSchema(array $decoded, array $candidates): ?array { if (! isset($decoded['article_id'], $decoded['confidence'], $decoded['explanation'])) { return null; } $candidateIds = collect($candidates)->map(fn (ArticleCandidateDTO $c) => $c->articleId)->all(); $articleId = (int) $decoded['article_id']; $confidence = (float) $decoded['confidence']; $explanation = trim((string) $decoded['explanation']); if (! in_array($articleId, $candidateIds, true)) { return null; } if ($confidence < 0 || $confidence > 1) { return null; } if ($explanation === '') { return null; } return [ 'article_id' => $articleId, 'confidence' => round($confidence, 4), 'explanation' => $explanation, 'tool_call' => $this->toolCallValidator->validate($decoded['tool_call'] ?? null), ]; } }