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.
This commit is contained in:
@@ -4,80 +4,111 @@ namespace App\Services;
|
||||
|
||||
use App\DTOs\ArticleCandidateDTO;
|
||||
use App\DTOs\ClassificationResultDTO;
|
||||
use App\Exceptions\OllamaUnavailableException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
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): ClassificationResultDTO
|
||||
public function rank(string $ticketMessage, array $candidates, string $language = 'nl'): ClassificationResultDTO
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return new ClassificationResultDTO(null, 0.0, 'No article candidates available');
|
||||
return new ClassificationResultDTO(null, 0.0, 'No article candidates available', rawResponse: ['mode' => 'none']);
|
||||
}
|
||||
|
||||
$articlesBlock = collect($candidates)
|
||||
->map(fn (ArticleCandidateDTO $item, int $idx) => sprintf(
|
||||
"%d. article_id=%d\nTitle: %s\nContent: %s",
|
||||
$idx + 1,
|
||||
$item->articleId,
|
||||
$item->title,
|
||||
$item->content
|
||||
))
|
||||
->implode("\n\n");
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
You are a support assistant.
|
||||
|
||||
User question:
|
||||
"{$ticketMessage}"
|
||||
|
||||
Articles:
|
||||
{$articlesBlock}
|
||||
|
||||
Task:
|
||||
- Select the best matching article
|
||||
- Return:
|
||||
- article_id
|
||||
- confidence (0-1)
|
||||
- short explanation
|
||||
|
||||
Respond in JSON ONLY.
|
||||
PROMPT;
|
||||
|
||||
$baseUrl = rtrim((string) config('services.ollama.base_url'), '/');
|
||||
$basePrompt = $this->settings->getPrompt('classifier', 'Select best article and return JSON.');
|
||||
$prompt = $this->promptBuilder->build($basePrompt, $ticketMessage, $candidates, $language);
|
||||
|
||||
try {
|
||||
$response = Http::timeout((int) config('services.ollama.timeout', 30))
|
||||
->post($baseUrl.'/api/generate', [
|
||||
'model' => config('services.ollama.chat_model', 'llama3'),
|
||||
'prompt' => $prompt,
|
||||
'stream' => false,
|
||||
])
|
||||
->throw()
|
||||
->json();
|
||||
} catch (Throwable $e) {
|
||||
throw new OllamaUnavailableException('Ollama generate endpoint is unavailable', 0, $e);
|
||||
$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()]
|
||||
);
|
||||
}
|
||||
|
||||
$text = (string) ($response['response'] ?? '{}');
|
||||
$decoded = json_decode($text, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
$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: ['raw' => $text]
|
||||
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: isset($decoded['article_id']) ? (int) $decoded['article_id'] : $candidates[0]->articleId,
|
||||
confidence: isset($decoded['confidence']) ? (float) $decoded['confidence'] : 0.35,
|
||||
explanation: (string) ($decoded['explanation'] ?? 'No explanation provided.'),
|
||||
rawResponse: $decoded
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user