Files
TicketAssistent/app/Services/AIClassifierService.php
SitiWeb f939133fe0 Add admin views for quick replies, settings, and ticket details
- Created `quick-replies.blade.php` for managing quick replies.
- Added `settings.blade.php` for admin settings management.
- Implemented `ticket-show.blade.php` to display ticket details.
- Introduced `timeline-card.blade.php` component for displaying timeline information.

Enhance quick reply management functionality

- Developed `quick-reply-manager.blade.php` for creating and editing quick replies.
- Integrated Livewire for dynamic interaction and validation.

Implement settings page for AI configuration

- Created `settings-page.blade.php` for managing AI settings, including prompts and provider instances.
- Added functionality for managing models and embeddings.

Add ticket show functionality with real-time updates

- Implemented ticket details view with processing status and tool call logs.
- Added support for displaying article suggestions and error messages.

Create unit tests for AI classifier and domain info tool

- Added `AIClassifierServiceTest.php` to validate AI classifier functionality.
- Implemented `DomainInfoToolTest.php` for domain parameter validation.
- Created `OxxaClientTest.php` to test API interactions and password hashing.
2026-04-30 01:50:21 +02:00

115 lines
4.4 KiB
PHP

<?php
namespace App\Services;
use App\DTOs\ArticleCandidateDTO;
use App\DTOs\ClassificationResultDTO;
use App\Services\Llm\LlmClientInterface;
class AIClassifierService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
private readonly ClassifierPromptBuilder $promptBuilder,
private readonly LlmJsonDecoder $jsonDecoder,
private readonly ToolCallRequestValidator $toolCallValidator,
) {}
/** @param array<ArticleCandidateDTO> $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),
];
}
}