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:
SitiWeb
2026-04-30 01:50:21 +02:00
parent 01aa115a49
commit f939133fe0
103 changed files with 4721 additions and 245 deletions

View File

@@ -3,7 +3,7 @@
namespace App\Jobs;
use App\Models\Article;
use App\Services\EmbeddingService;
use App\Services\ArticleIndexingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,18 +14,15 @@ class GenerateArticleEmbeddingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly int $articleId)
{
}
public function __construct(public readonly int $articleId) {}
public function handle(EmbeddingService $embeddingService): void
public function handle(ArticleIndexingService $indexingService): void
{
$article = Article::find($this->articleId);
if ($article === null) {
return;
}
$article->embedding = $embeddingService->embed($article->title."\n".$article->content);
$article->save();
$indexingService->indexArticle($article);
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Jobs;
use App\Models\Ticket;
use App\Services\EmbeddingService;
use App\Services\KnowledgeGapService;
use App\Services\QuickReplyResolver;
use App\Services\SemanticSearchService;
use App\Services\SupportReplyService;
use App\Services\TicketNormalizationService;
use App\Services\TicketProcessingLoggerService;
use App\Services\TicketResultPayloadBuilder;
use App\Services\Tools\TicketToolCallService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class ProcessTicketJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly int $ticketId) {}
public function handle(
EmbeddingService $embeddingService,
SemanticSearchService $semanticSearchService,
TicketNormalizationService $normalizer,
TicketProcessingLoggerService $logger,
KnowledgeGapService $knowledgeGapService,
TicketToolCallService $toolCallService,
QuickReplyResolver $quickReplyResolver,
SupportReplyService $supportReplyService,
TicketResultPayloadBuilder $payloadBuilder,
): void {
$ticket = Ticket::query()->find($this->ticketId);
if ($ticket === null) {
return;
}
$ticket->update(['status' => 'processing']);
$logger->log($ticket, 'start', 'info', 'Ticket processing gestart.', [
'original_question' => $ticket->message,
]);
try {
$logger->log($ticket, 'normalize_question', 'info', 'Vraag normaliseren en PII redacteren.');
$normalized = $normalizer->normalize($ticket->message);
$ticket->normalized_message = $normalized['normalized_message'];
$ticket->redaction_report = $normalized['redaction_report'] ?? null;
$ticket->save();
$logger->log($ticket, 'normalize_question', 'success', 'Vraag genormaliseerd.', [
'normalized_message' => $ticket->normalized_message,
'redaction_report' => $ticket->redaction_report,
]);
if ($ticket->embedding === null) {
$logger->log($ticket, 'embedding', 'info', 'Embedding genereren.');
$ticket->embedding = $embeddingService->embed($ticket->normalized_message ?: $ticket->message);
$embeddingContext = $embeddingService->context();
$ticket->embedding_provider_instance_id = $embeddingContext['provider_instance_id'];
$ticket->embedding_model = $embeddingContext['embedding_model'];
$ticket->embedded_at = now();
$ticket->save();
}
$logger->log($ticket, 'embedding', 'success', 'Embedding beschikbaar.', [
'vector_dimensions' => is_array($ticket->embedding) ? count($ticket->embedding) : 0,
'vector_preview' => is_array($ticket->embedding) ? array_slice($ticket->embedding, 0, 8) : [],
]);
$logger->log($ticket, 'retrieval_ranking', 'info', 'Semantic retrieval en AI ranking uitvoeren.');
$result = $semanticSearchService->findBestArticle($ticket);
$logger->log($ticket, 'retrieval', 'success', 'Top kandidaten uit vector search bepaald.', [
'candidates' => $result['top_5_candidates'] ?? [],
'retrieval_meta' => $result['retrieval_meta'] ?? null,
]);
$logger->log($ticket, 'ranking', 'success', 'Classificatie afgerond.', [
'selected_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
'explanation' => $result['explanation'],
'classifier_raw_response' => $result['classifier_raw_response'] ?? null,
]);
$isKnowledgeGap = $knowledgeGapService->shouldCreateDraft($ticket, $result);
$draftSuggestion = null;
$supportReply = null;
$toolCallRecord = null;
$quickReply = null;
if ($isKnowledgeGap) {
$logger->log($ticket, 'knowledge_gap', 'warning', 'Onvoldoende match gevonden; geen passend artikel in kennisbank.');
$draftSuggestion = $knowledgeGapService->suggestArticleDraft($ticket, $result);
$logger->log($ticket, 'knowledge_gap', 'success', 'Voorstel voor nieuw kennisbankartikel gegenereerd (niet opgeslagen).', [
'suggested_title' => $draftSuggestion['title'] ?? null,
]);
} else {
$quickReply = $quickReplyResolver->resolveForArticle($result['best_article'] ?? null);
if ($quickReply !== null) {
$supportReply = $quickReply->content;
$logger->log($ticket, 'quick_reply', 'success', 'Snelantwoord gebruikt; AI antwoordgeneratie overgeslagen.', [
'quick_reply_id' => $quickReply->id,
'quick_reply_title' => $quickReply->title,
'article_id' => $result['best_article']?->id,
]);
} else {
$toolCallRecord = $toolCallService->executeRequestedTool(
$ticket,
$result['best_article'] ?? null,
$result['requested_tool_call'] ?? null
);
$logger->log($ticket, 'quick_reply', 'info', 'Geen actief snelantwoord gekoppeld; AI maakt conceptantwoord.', [
'article_id' => $result['best_article']?->id,
]);
$supportReply = $supportReplyService->build(
$ticket,
$result['best_article'] ?? null,
(string) $result['explanation'],
$toolCallRecord?->toArray()
);
}
}
$ticket->update([
'status' => 'completed',
'best_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
'explanation' => $result['explanation'],
'support_reply' => $supportReply,
'needs_article_draft' => $isKnowledgeGap,
'draft_article_id' => null,
'result_payload' => $payloadBuilder->build($result, $toolCallRecord, $quickReply, $isKnowledgeGap, $draftSuggestion),
'error_message' => null,
'processed_at' => now(),
]);
if ($supportReply !== null) {
$logger->log($ticket, 'support_reply', 'success', 'Concept supportantwoord opgebouwd.', [
'support_reply_preview' => Str::limit($supportReply, 220),
]);
} else {
$logger->log($ticket, 'support_reply', 'info', 'Geen klantreactie gegenereerd wegens knowledge gap; eerst artikelreview nodig.');
}
$logger->log($ticket, 'completed', 'success', 'Ticket processing afgerond.', [
'best_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
]);
} catch (\Throwable $e) {
$ticket->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'processed_at' => now(),
]);
$logger->log($ticket, 'failed', 'error', 'Ticket processing gefaald.', [
'error' => $e->getMessage(),
]);
throw $e;
}
}
}