diff --git a/.env.docker.example b/.env.docker.example index 8bbeffe..09c8060 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -13,17 +13,26 @@ DB_PORT=5432 DB_DATABASE=ticket_assistant DB_USERNAME=postgres DB_PASSWORD=postgres +DB_SSLMODE=disable QUEUE_CONNECTION=database + +OXXA_API_ENDPOINT= +OXXA_TIMEOUT=60 CACHE_STORE=file -# External Ollama server in your network +LLM_PROVIDER=ollama +LLM_BASE_URL=http://192.168.1.50:11434 +LLM_EMBEDDING_MODEL=nomic-embed-text +LLM_CHAT_MODEL=llama3 +LLM_TIMEOUT=300 + +# legacy compatibility (optional) OLLAMA_BASE_URL=http://192.168.1.50:11434 OLLAMA_EMBED_MODEL=nomic-embed-text OLLAMA_CHAT_MODEL=llama3 OLLAMA_TIMEOUT=30 + EMBEDDING_DIMENSION=768 QUEUE_EMBEDDINGS=false -DB_SSLMODE=disable - diff --git a/.env.example b/.env.example index c0660ea..0dae1d5 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,9 @@ BROADCAST_CONNECTION=log FILESYSTEM_DISK=local QUEUE_CONNECTION=database +OXXA_API_ENDPOINT= +OXXA_TIMEOUT=60 + CACHE_STORE=database # CACHE_PREFIX= @@ -63,3 +66,12 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + + +LLM_PROVIDER=ollama +LLM_BASE_URL=http://localhost:11434 +LLM_EMBEDDING_MODEL=nomic-embed-text +LLM_CHAT_MODEL=llama3 +LLM_TIMEOUT=300 + + diff --git a/app/Casts/VectorCast.php b/app/Casts/VectorCast.php index 6eb6ca7..e92b08a 100644 --- a/app/Casts/VectorCast.php +++ b/app/Casts/VectorCast.php @@ -31,6 +31,7 @@ class VectorCast implements CastsAttributes } $vector = array_map(static fn ($item) => (float) $item, $value); + return '['.implode(',', $vector).']'; } -} \ No newline at end of file +} diff --git a/app/Console/Commands/GenerateArticleEmbeddingsCommand.php b/app/Console/Commands/GenerateArticleEmbeddingsCommand.php new file mode 100644 index 0000000..74c4b75 --- /dev/null +++ b/app/Console/Commands/GenerateArticleEmbeddingsCommand.php @@ -0,0 +1,76 @@ +option('force'); + $limitOption = $this->option('limit'); + $limit = is_numeric($limitOption) ? (int) $limitOption : null; + + $query = Article::query()->orderBy('id'); + if (! $force) { + $query->whereDoesntHave('chunks'); + } + + $total = (clone $query)->count(); + if ($limit !== null && $limit > 0) { + $total = min($total, $limit); + } + + if ($total === 0) { + $this->info('No articles to process.'); + + return self::SUCCESS; + } + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $processed = 0; + $updated = 0; + $failed = 0; + + $query->chunkById(50, function ($articles) use ($indexingService, $limit, &$processed, &$updated, &$failed, $bar) { + foreach ($articles as $article) { + if ($limit !== null && $processed >= $limit) { + return false; + } + + $processed++; + + try { + $indexingService->indexArticle($article); + $updated++; + } catch (\Throwable) { + $failed++; + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(2); + + $this->table(['Metric', 'Value'], [ + ['Processed', $processed], + ['Updated', $updated], + ['Failed', $failed], + ]); + + return self::SUCCESS; + } +} diff --git a/app/DTOs/ArticleCandidateDTO.php b/app/DTOs/ArticleCandidateDTO.php index 38d4c17..952c4c4 100644 --- a/app/DTOs/ArticleCandidateDTO.php +++ b/app/DTOs/ArticleCandidateDTO.php @@ -10,7 +10,11 @@ class ArticleCandidateDTO public readonly int $articleId, public readonly string $title, public readonly string $content, - public readonly float $distance + public readonly float $distance, + public readonly ?string $sourceUrl = null, + public readonly ?string $sourceArticleId = null, + public readonly ?string $note = null, + public readonly array $allowedActions = [] ) {} public static function fromArticle(Article $article, float $distance): self @@ -19,7 +23,11 @@ class ArticleCandidateDTO articleId: $article->id, title: $article->title, content: $article->content, - distance: $distance + distance: $distance, + sourceUrl: $article->source_url, + sourceArticleId: $article->source_article_id, + note: $article->note, + allowedActions: $article->allowed_actions ?? [] ); } @@ -30,6 +38,10 @@ class ArticleCandidateDTO 'title' => $this->title, 'content' => $this->content, 'distance' => $this->distance, + 'source_url' => $this->sourceUrl, + 'source_article_id' => $this->sourceArticleId, + 'note' => $this->note, + 'allowed_actions' => $this->allowedActions, ]; } -} \ No newline at end of file +} diff --git a/app/DTOs/ClassificationResultDTO.php b/app/DTOs/ClassificationResultDTO.php index db0fa23..7edfbb9 100644 --- a/app/DTOs/ClassificationResultDTO.php +++ b/app/DTOs/ClassificationResultDTO.php @@ -8,6 +8,7 @@ class ClassificationResultDTO public readonly ?int $articleId, public readonly float $confidence, public readonly string $explanation, + public readonly ?array $toolCall = null, public readonly array $rawResponse = [] ) {} @@ -17,7 +18,8 @@ class ClassificationResultDTO 'article_id' => $this->articleId, 'confidence' => $this->confidence, 'explanation' => $this->explanation, + 'tool_call' => $this->toolCall, 'raw_response' => $this->rawResponse, ]; } -} \ No newline at end of file +} diff --git a/app/Exceptions/OllamaUnavailableException.php b/app/Exceptions/OllamaUnavailableException.php index 3750229..8e00f01 100644 --- a/app/Exceptions/OllamaUnavailableException.php +++ b/app/Exceptions/OllamaUnavailableException.php @@ -6,4 +6,23 @@ use RuntimeException; class OllamaUnavailableException extends RuntimeException { -} \ No newline at end of file + public function __construct( + string $provider, + string $operation, + ?string $reason = null, + ?\Throwable $previous = null, + ?string $responseSnippet = null, + ) { + $message = sprintf('%s provider unavailable during %s', $provider, $operation); + + if ($reason) { + $message .= sprintf(' | reason: %s', $reason); + } + + if ($responseSnippet) { + $message .= sprintf(' | response: %s', $responseSnippet); + } + + parent::__construct($message, 0, $previous); + } +} diff --git a/app/Http/Controllers/AdminTicketController.php b/app/Http/Controllers/AdminTicketController.php new file mode 100644 index 0000000..24e109c --- /dev/null +++ b/app/Http/Controllers/AdminTicketController.php @@ -0,0 +1,20 @@ +findWithTimeline($ticket); + + abort_if($record === null, 404); + + return view('admin.ticket-show', [ + 'ticket' => $record, + ]); + } +} diff --git a/app/Http/Controllers/Api/ArticleController.php b/app/Http/Controllers/Api/ArticleController.php index 6dcc282..856ed2e 100644 --- a/app/Http/Controllers/Api/ArticleController.php +++ b/app/Http/Controllers/Api/ArticleController.php @@ -25,4 +25,4 @@ class ArticleController extends Controller 'data' => $article, ], 201); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/TicketController.php b/app/Http/Controllers/Api/TicketController.php index 2bc5487..51153a6 100644 --- a/app/Http/Controllers/Api/TicketController.php +++ b/app/Http/Controllers/Api/TicketController.php @@ -5,48 +5,33 @@ namespace App\Http\Controllers\Api; use App\Exceptions\OllamaUnavailableException; use App\Http\Controllers\Controller; use App\Http\Requests\StoreTicketRequest; -use App\Models\Ticket; -use App\Services\EmbeddingService; -use App\Services\SemanticSearchService; +use App\Services\TicketIngestionService; use Illuminate\Http\JsonResponse; class TicketController extends Controller { public function __construct( - private readonly EmbeddingService $embeddingService, - private readonly SemanticSearchService $semanticSearchService, + private readonly TicketIngestionService $ticketIngestionService, ) {} public function store(StoreTicketRequest $request): JsonResponse { try { - $embedding = $this->embeddingService->embed($request->string('message')->toString()); + $ingested = $this->ticketIngestionService->ingest( + $request->string('message')->toString(), + $request->validated('api_credentials') + ); } catch (OllamaUnavailableException $e) { return response()->json([ - 'message' => 'Ollama is unavailable. Could not generate embedding.', + 'message' => 'LLM provider is unavailable. Ticket kon niet verwerkt worden.', + 'reason' => $e->getMessage(), ], 503); } - $ticket = Ticket::query()->create([ - 'message' => $request->string('message')->toString(), - 'embedding' => $embedding, - ]); - - try { - $result = $this->semanticSearchService->findBestArticle($ticket); - } catch (OllamaUnavailableException $e) { - return response()->json([ - 'message' => 'Ticket saved, but Ollama ranking is unavailable.', - 'ticket_id' => $ticket->id, - ], 202); - } - return response()->json([ - 'ticket_id' => $ticket->id, - 'best_article' => $result['best_article'], - 'confidence' => $result['confidence'], - 'explanation' => $result['explanation'], - 'top_3_candidates' => $result['top_3_candidates'], - ], 201); + 'ticket_id' => $ingested['ticket']->id, + 'status' => 'queued', + 'message' => 'Ticket ontvangen en in verwerking gezet.', + ], 202); } -} \ No newline at end of file +} diff --git a/app/Http/Requests/StoreArticleRequest.php b/app/Http/Requests/StoreArticleRequest.php index cad8f81..72c0142 100644 --- a/app/Http/Requests/StoreArticleRequest.php +++ b/app/Http/Requests/StoreArticleRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreArticleRequest extends FormRequest { @@ -16,6 +17,9 @@ class StoreArticleRequest extends FormRequest return [ 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string', 'max:12000'], + 'note' => ['nullable', 'string', 'max:12000'], + 'allowed_actions' => ['nullable', 'array'], + 'allowed_actions.*' => ['string', Rule::in(['domain_inf'])], ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/StoreTicketRequest.php b/app/Http/Requests/StoreTicketRequest.php index 81b6aae..4a424c5 100644 --- a/app/Http/Requests/StoreTicketRequest.php +++ b/app/Http/Requests/StoreTicketRequest.php @@ -15,6 +15,9 @@ class StoreTicketRequest extends FormRequest { return [ 'message' => ['required', 'string', 'min:5', 'max:5000'], + 'api_credentials' => ['nullable', 'array'], + 'api_credentials.apiuser' => ['required_with:api_credentials', 'string', 'max:255'], + 'api_credentials.apipassword' => ['required_with:api_credentials', 'string', 'max:255'], ]; } -} \ No newline at end of file +} diff --git a/app/Jobs/GenerateArticleEmbeddingJob.php b/app/Jobs/GenerateArticleEmbeddingJob.php index 6d8ab6f..8b19d8e 100644 --- a/app/Jobs/GenerateArticleEmbeddingJob.php +++ b/app/Jobs/GenerateArticleEmbeddingJob.php @@ -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); } -} \ No newline at end of file +} diff --git a/app/Jobs/ProcessTicketJob.php b/app/Jobs/ProcessTicketJob.php new file mode 100644 index 0000000..87d89dc --- /dev/null +++ b/app/Jobs/ProcessTicketJob.php @@ -0,0 +1,172 @@ +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; + } + } +} diff --git a/app/Livewire/Admin/ArticleManager.php b/app/Livewire/Admin/ArticleManager.php index 5d1d7f7..09f3593 100644 --- a/app/Livewire/Admin/ArticleManager.php +++ b/app/Livewire/Admin/ArticleManager.php @@ -3,6 +3,7 @@ namespace App\Livewire\Admin; use App\Services\AdminArticleService; +use App\Services\AdminQuickReplyService; use Livewire\Component; use Livewire\WithPagination; @@ -11,26 +12,107 @@ class ArticleManager extends Component use WithPagination; public string $title = ''; + public string $content = ''; + public string $note = ''; + + public array $allowedActions = []; + + public array $articleNotes = []; + + public array $articleAllowedActions = []; + + public array $articleQuickReplies = []; + public function save(AdminArticleService $service): void { $this->validate([ 'title' => ['required', 'string', 'max:255'], 'content' => ['required', 'string', 'max:12000'], + 'note' => ['nullable', 'string', 'max:12000'], + 'allowedActions' => ['array'], + 'allowedActions.*' => ['string', 'in:domain_inf'], ]); - $service->create($this->title, $this->content); + $service->create($this->title, $this->content, $this->note, $this->allowedActions); - $this->reset(['title', 'content']); + $this->reset(['title', 'content', 'note', 'allowedActions']); $this->dispatch('article-saved'); session()->flash('success', 'Article opgeslagen en embedding wordt automatisch verwerkt.'); } - public function render(AdminArticleService $service) + public function deleteArticle(int $articleId, AdminArticleService $service): void { + $deleted = $service->deleteById($articleId); + + if ($deleted) { + session()->flash('success', "Artikel #{$articleId} is verwijderd."); + } else { + session()->flash('success', "Artikel #{$articleId} bestond niet meer."); + } + + $this->resetPage(); + } + + public function saveMetadata(int $articleId, AdminArticleService $service): void + { + $this->validate([ + "articleNotes.{$articleId}" => ['nullable', 'string', 'max:12000'], + "articleAllowedActions.{$articleId}" => ['array'], + "articleAllowedActions.{$articleId}.*" => ['string', 'in:domain_inf'], + "articleQuickReplies.{$articleId}" => ['array'], + "articleQuickReplies.{$articleId}.*" => ['integer', 'exists:quick_replies,id'], + ]); + + $updated = $service->updateMetadata( + $articleId, + $this->articleNotes[$articleId] ?? null, + $this->articleAllowedActions[$articleId] ?? [], + $this->articleQuickReplies[$articleId] ?? [] + ); + + session()->flash('success', $updated + ? "Metadata voor artikel #{$articleId} is opgeslagen." + : "Artikel #{$articleId} bestaat niet meer."); + } + + public function approveDraft(int $articleId, AdminArticleService $service): void + { + $approved = $service->approveDraft($articleId); + + if ($approved) { + session()->flash('success', "Conceptartikel #{$articleId} is gevalideerd en gepubliceerd."); + } else { + session()->flash('success', "Conceptartikel #{$articleId} bestaat niet meer."); + } + } + + public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService) + { + $articles = $service->paginate(10); + $this->hydrateArticleMetadataState($articles->items()); + return view('livewire.admin.article-manager', [ - 'articles' => $service->paginate(10), + 'articles' => $articles, + 'quickReplyOptions' => $quickReplyService->activeOptions(), ]); } + + private function hydrateArticleMetadataState(array $articles): void + { + foreach ($articles as $article) { + if (! array_key_exists($article->id, $this->articleNotes)) { + $this->articleNotes[$article->id] = $article->note ?? ''; + } + + if (! array_key_exists($article->id, $this->articleAllowedActions)) { + $this->articleAllowedActions[$article->id] = $article->allowed_actions ?? []; + } + + if (! array_key_exists($article->id, $this->articleQuickReplies)) { + $this->articleQuickReplies[$article->id] = $article->quickReplies->pluck('id')->map(fn ($id) => (int) $id)->all(); + } + } + } } diff --git a/app/Livewire/Admin/QuickReplyManager.php b/app/Livewire/Admin/QuickReplyManager.php new file mode 100644 index 0000000..8e337d6 --- /dev/null +++ b/app/Livewire/Admin/QuickReplyManager.php @@ -0,0 +1,94 @@ +validate([ + 'title' => ['required', 'string', 'max:255'], + 'content' => ['required', 'string', 'max:12000'], + 'isActive' => ['boolean'], + ]); + + $service->create($this->title, $this->content, $this->isActive); + + $this->reset(['title', 'content']); + $this->isActive = true; + session()->flash('success', 'Snelantwoord opgeslagen.'); + $this->resetPage(); + } + + public function updateQuickReply(int $id, AdminQuickReplyService $service): void + { + $this->validate([ + "editRows.{$id}.title" => ['required', 'string', 'max:255'], + "editRows.{$id}.content" => ['required', 'string', 'max:12000'], + "editRows.{$id}.is_active" => ['boolean'], + ]); + + $row = $this->editRows[$id] ?? []; + $updated = $service->update( + $id, + (string) ($row['title'] ?? ''), + (string) ($row['content'] ?? ''), + (bool) ($row['is_active'] ?? false) + ); + + session()->flash('success', $updated + ? "Snelantwoord #{$id} is opgeslagen." + : "Snelantwoord #{$id} bestaat niet meer."); + } + + public function deleteQuickReply(int $id, AdminQuickReplyService $service): void + { + $deleted = $service->deleteById($id); + + session()->flash('success', $deleted + ? "Snelantwoord #{$id} is verwijderd." + : "Snelantwoord #{$id} bestond niet meer."); + + unset($this->editRows[$id]); + $this->resetPage(); + } + + public function render(AdminQuickReplyService $service) + { + $quickReplies = $service->paginate(10); + $this->hydrateEditRows($quickReplies->items()); + + return view('livewire.admin.quick-reply-manager', [ + 'quickReplies' => $quickReplies, + ]); + } + + private function hydrateEditRows(array $quickReplies): void + { + foreach ($quickReplies as $quickReply) { + if (array_key_exists($quickReply->id, $this->editRows)) { + continue; + } + + $this->editRows[$quickReply->id] = [ + 'title' => $quickReply->title, + 'content' => $quickReply->content, + 'is_active' => $quickReply->is_active, + ]; + } + } +} diff --git a/app/Livewire/Admin/SettingsPage.php b/app/Livewire/Admin/SettingsPage.php new file mode 100644 index 0000000..7fd0585 --- /dev/null +++ b/app/Livewire/Admin/SettingsPage.php @@ -0,0 +1,199 @@ +all(); + $providerSettings = $settings->providerSettings(); + $this->tone_addressing = (string) ($all['tone_addressing'] ?? 'je'); + $this->activeProviderInstanceId = (string) ($providerSettings['active_instance_id'] ?? 'ollama_default'); + $this->llm_timeout = (int) ($all['llm.timeout'] ?? 300); + $this->promptValues = $settings->promptValues(); + $this->providerInstances = $providerSettings['instances'] ?? $settings->defaultProviderInstances(); + $this->modelValues = $settings->modelSettings(); + $this->processSteps = $settings->processSteps(); + $this->providerDefinitions = $settings->providerDefinitions(); + $this->modelTasks = $settings->modelTasks(); + $this->refreshEmbeddingStats(); + $this->loadModels(); + } + + public function setTab(string $tab): void + { + if (in_array($tab, ['process', 'providers', 'models', 'embeddings'], true)) { + $this->activeTab = $tab; + + if ($tab === 'embeddings') { + $this->refreshEmbeddingStats(); + } + } + } + + public function refreshEmbeddingStats(): void + { + $this->embeddingStats = app(ArticleEmbeddingMaintenanceService::class)->stats(); + } + + public function reindexMissingEmbeddings(): void + { + $count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(false); + $this->refreshEmbeddingStats(); + session()->flash('saved', "{$count} artikelen zonder chunks zijn in de queue geplaatst."); + } + + public function reindexAllEmbeddings(): void + { + $count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(true); + $this->refreshEmbeddingStats(); + session()->flash('saved', "{$count} artikelen zijn in de queue geplaatst voor volledige herindex."); + } + + public function addProviderInstance(): void + { + $id = 'provider_'.Str::uuid()->toString(); + $this->providerInstances[] = [ + 'id' => $id, + 'name' => 'Nieuwe provider', + 'type' => 'lmstudio', + 'base_url' => 'http://localhost:1234', + 'chat_model' => '', + 'embedding_model' => '', + ]; + $this->activeProviderInstanceId = $id; + $this->availableModels = []; + $this->modelLoadError = null; + } + + public function removeProviderInstance(string $id): void + { + if (count($this->providerInstances) <= 1) { + return; + } + + $this->providerInstances = array_values(array_filter( + $this->providerInstances, + fn (array $instance) => ($instance['id'] ?? null) !== $id + )); + + if ($this->activeProviderInstanceId === $id) { + $this->activeProviderInstanceId = (string) ($this->providerInstances[0]['id'] ?? ''); + } + + $this->loadModels(); + } + + public function setActiveProviderInstance(string $id): void + { + $ids = array_column($this->providerInstances, 'id'); + if (in_array($id, $ids, true)) { + $this->activeProviderInstanceId = $id; + $this->loadModels(); + } + } + + public function loadModels(bool $refresh = false): void + { + $instance = $this->activeProviderInstance(); + if ($instance === null) { + $this->availableModels = []; + + return; + } + + try { + $this->availableModels = app(LlmModelCatalogService::class)->modelsFor($instance, $refresh); + $this->modelLoadError = null; + } catch (\Throwable $e) { + $this->availableModels = []; + $this->modelLoadError = $e->getMessage(); + } + } + + public function refreshModels(): void + { + $this->loadModels(true); + } + + public function save(AppSettingsService $settings): void + { + $this->validate([ + 'tone_addressing' => ['required', 'in:je,u'], + 'activeProviderInstanceId' => ['required', 'string'], + 'llm_timeout' => ['required', 'integer', 'min:5', 'max:600'], + 'promptValues' => ['array'], + 'promptValues.*' => ['required', 'string', 'min:10'], + 'providerInstances' => ['array', 'min:1'], + 'providerInstances.*.id' => ['required', 'string'], + 'providerInstances.*.name' => ['required', 'string', 'min:1'], + 'providerInstances.*.type' => ['required', 'in:ollama,lmstudio'], + 'providerInstances.*.base_url' => ['required', 'url'], + 'providerInstances.*.chat_model' => ['nullable', 'string'], + 'providerInstances.*.embedding_model' => ['nullable', 'string'], + 'modelValues' => ['array'], + 'modelValues.*' => ['required', 'string', 'min:1'], + ]); + + $settings->saveStructuredSettings( + promptValues: $this->promptValues, + providerInstances: $this->providerInstances, + activeProviderInstanceId: $this->activeProviderInstanceId, + modelValues: $this->modelValues, + timeout: $this->llm_timeout, + tone: $this->tone_addressing, + ); + + session()->flash('saved', 'Settings opgeslagen.'); + $this->refreshEmbeddingStats(); + } + + private function activeProviderInstance(): ?array + { + foreach ($this->providerInstances as $instance) { + if (($instance['id'] ?? null) === $this->activeProviderInstanceId) { + return $instance; + } + } + + return $this->providerInstances[0] ?? null; + } + + public function render() + { + return view('livewire.admin.settings-page'); + } +} diff --git a/app/Livewire/Admin/TicketMonitor.php b/app/Livewire/Admin/TicketMonitor.php index 75428a3..57046c5 100644 --- a/app/Livewire/Admin/TicketMonitor.php +++ b/app/Livewire/Admin/TicketMonitor.php @@ -2,7 +2,9 @@ namespace App\Livewire\Admin; +use App\Exceptions\OllamaUnavailableException; use App\Services\AdminTicketService; +use App\Services\TicketIngestionService; use Livewire\Component; use Livewire\WithPagination; @@ -12,11 +14,61 @@ class TicketMonitor extends Component public int $perPage = 10; + public string $newTicketMessage = ''; + + public string $apiUser = ''; + + public string $apiPassword = ''; + + public ?array $lastResult = null; + + public ?string $submitError = null; + public function updatedPerPage(): void { $this->resetPage(); } + public function submitTicket(TicketIngestionService $ticketIngestionService): void + { + $this->submitError = null; + $this->lastResult = null; + + $this->validate([ + 'newTicketMessage' => ['required', 'string', 'min:5', 'max:5000'], + 'apiUser' => ['nullable', 'string', 'max:255', 'required_with:apiPassword'], + 'apiPassword' => ['nullable', 'string', 'max:255', 'required_with:apiUser'], + ]); + + try { + $ingested = $ticketIngestionService->ingest(trim($this->newTicketMessage), $this->credentialsPayload()); + } catch (OllamaUnavailableException $e) { + $this->submitError = 'LLM provider niet beschikbaar. Reden: '.$e->getMessage(); + + return; + } + + $this->reset(['newTicketMessage', 'apiUser', 'apiPassword']); + $this->resetPage(); + + $this->lastResult = [ + 'ticket_id' => $ingested['ticket']->id, + 'status' => $ingested['ticket']->status, + ]; + } + + private function credentialsPayload(): ?array + { + if (trim($this->apiUser) === '' && trim($this->apiPassword) === '') { + return null; + } + + return [ + 'apiuser' => $this->apiUser, + 'apipassword' => $this->apiPassword, + ]; + } + public function render(AdminTicketService $service) { return view('livewire.admin.ticket-monitor', [ diff --git a/app/Livewire/Admin/TicketShow.php b/app/Livewire/Admin/TicketShow.php new file mode 100644 index 0000000..cd6294d --- /dev/null +++ b/app/Livewire/Admin/TicketShow.php @@ -0,0 +1,53 @@ +ticketId = $ticketId; + } + + public function reprocess(TicketProcessingLoggerService $logger): void + { + $ticket = Ticket::query()->find($this->ticketId); + abort_if($ticket === null, 404); + + $ticket->update([ + 'status' => 'queued', + 'best_article_id' => null, + 'confidence' => null, + 'explanation' => null, + 'support_reply' => null, + 'needs_article_draft' => false, + 'draft_article_id' => null, + 'result_payload' => null, + 'error_message' => null, + 'processed_at' => null, + ]); + + $logger->log($ticket, 'queued', 'info', 'Ticket handmatig opnieuw in queue geplaatst via admin.'); + ProcessTicketJob::dispatch($ticket->id); + + session()->flash('success', 'Ticket is opnieuw in de queue geplaatst.'); + } + + public function render(AdminTicketService $service) + { + $ticket = $service->findWithTimeline($this->ticketId); + abort_if($ticket === null, 404); + + return view('livewire.admin.ticket-show', [ + 'ticket' => $ticket, + ]); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index 9e5a44c..c8a8df0 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Casts\VectorCast; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; class Article extends Model @@ -12,16 +13,23 @@ class Article extends Model protected $fillable = [ 'title', 'content', + 'note', + 'allowed_actions', + 'status', + 'is_ai_draft', 'embedding', 'source', 'source_url', 'source_article_id', 'category_id', 'subcategory_id', + 'source_ticket_id', ]; protected $casts = [ 'embedding' => VectorCast::class, + 'allowed_actions' => 'array', + 'is_ai_draft' => 'boolean', ]; public function decisions(): HasMany @@ -38,4 +46,24 @@ class Article extends Model { return $this->belongsTo(Category::class, 'subcategory_id'); } + + public function sourceTicket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'source_ticket_id'); + } + + public function chunks(): HasMany + { + return $this->hasMany(ArticleChunk::class); + } + + public function quickReplies(): BelongsToMany + { + return $this->belongsToMany(QuickReply::class)->withTimestamps(); + } + + public function activeQuickReplies(): BelongsToMany + { + return $this->quickReplies()->where('is_active', true); + } } diff --git a/app/Models/ArticleChunk.php b/app/Models/ArticleChunk.php new file mode 100644 index 0000000..91ace32 --- /dev/null +++ b/app/Models/ArticleChunk.php @@ -0,0 +1,30 @@ + VectorCast::class, + 'embedded_at' => 'datetime', + ]; + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } +} diff --git a/app/Models/EmbeddingCache.php b/app/Models/EmbeddingCache.php index 16e3fdb..0f08d43 100644 --- a/app/Models/EmbeddingCache.php +++ b/app/Models/EmbeddingCache.php @@ -8,9 +8,9 @@ class EmbeddingCache extends Model { protected $table = 'embedding_cache'; - protected $fillable = ['text_hash', 'text', 'embedding']; + protected $fillable = ['provider_instance_id', 'embedding_model', 'text_hash', 'text', 'embedding']; protected $casts = [ 'embedding' => 'array', ]; -} \ No newline at end of file +} diff --git a/app/Models/Feedback.php b/app/Models/Feedback.php index f91f9db..a63339d 100644 --- a/app/Models/Feedback.php +++ b/app/Models/Feedback.php @@ -9,4 +9,4 @@ class Feedback extends Model protected $table = 'feedback'; protected $fillable = ['ticket_id', 'article_id', 'is_correct', 'notes']; -} \ No newline at end of file +} diff --git a/app/Models/QuickReply.php b/app/Models/QuickReply.php new file mode 100644 index 0000000..ef7e5d9 --- /dev/null +++ b/app/Models/QuickReply.php @@ -0,0 +1,24 @@ + 'boolean', + ]; + + public function articles(): BelongsToMany + { + return $this->belongsToMany(Article::class)->withTimestamps(); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..7400a50 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,10 @@ + VectorCast::class, + 'redaction_report' => 'array', + 'result_payload' => 'array', + 'api_credentials' => 'encrypted:array', + 'needs_article_draft' => 'boolean', + 'embedded_at' => 'datetime', + 'processed_at' => 'datetime', ]; public function decisions(): HasMany @@ -23,4 +49,28 @@ class Ticket extends Model { return $this->hasMany(Feedback::class); } + + public function logs(): HasMany + { + return $this->hasMany(TicketProcessingLog::class) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + public function toolCalls(): HasMany + { + return $this->hasMany(TicketToolCall::class) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + public function bestArticle(): BelongsTo + { + return $this->belongsTo(Article::class, 'best_article_id'); + } + + public function draftArticle(): BelongsTo + { + return $this->belongsTo(Article::class, 'draft_article_id'); + } } diff --git a/app/Models/TicketProcessingLog.php b/app/Models/TicketProcessingLog.php new file mode 100644 index 0000000..4ea8abb --- /dev/null +++ b/app/Models/TicketProcessingLog.php @@ -0,0 +1,20 @@ + 'array', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/app/Models/TicketToolCall.php b/app/Models/TicketToolCall.php new file mode 100644 index 0000000..d83a4ae --- /dev/null +++ b/app/Models/TicketToolCall.php @@ -0,0 +1,36 @@ + 'array', + 'response' => 'array', + 'executed_at' => 'datetime', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } +} diff --git a/app/Observers/ArticleObserver.php b/app/Observers/ArticleObserver.php index 54c9f82..bf60939 100644 --- a/app/Observers/ArticleObserver.php +++ b/app/Observers/ArticleObserver.php @@ -4,7 +4,7 @@ namespace App\Observers; use App\Jobs\GenerateArticleEmbeddingJob; use App\Models\Article; -use App\Services\EmbeddingService; +use App\Services\ArticleIndexingService; class ArticleObserver { @@ -16,12 +16,10 @@ class ArticleObserver if ((bool) config('services.embedding.queue_embeddings')) { GenerateArticleEmbeddingJob::dispatch($article->id); + return; } - $service = app(EmbeddingService::class); - $article->embedding = $service->embed($article->title."\n".$article->content); - - $article->saveQuietly(); + app(ArticleIndexingService::class)->indexArticle($article); } -} \ No newline at end of file +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..80589fb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,35 @@ namespace App\Providers; +use App\Models\Article; +use App\Observers\ArticleObserver; +use App\Repositories\ArticleRepository; +use App\Repositories\Contracts\ArticleRepositoryInterface; +use App\Services\AppSettingsService; +use App\Services\Llm\LlmClientInterface; +use App\Services\Llm\LmStudioClient; +use App\Services\Llm\OllamaClient; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->bind(ArticleRepositoryInterface::class, ArticleRepository::class); + + $this->app->bind(LlmClientInterface::class, function ($app) { + $settings = $app->make(AppSettingsService::class); + $provider = (string) ($settings->activeProviderInstance()['type'] ?? 'ollama'); + + return match ($provider) { + 'lmstudio' => new LmStudioClient($settings), + default => new OllamaClient($settings), + }; + }); } - /** - * Bootstrap any application services. - */ public function boot(): void { - // + Article::observe(ArticleObserver::class); } } diff --git a/app/Repositories/ArticleRepository.php b/app/Repositories/ArticleRepository.php index 1d2f0ad..afde4ea 100644 --- a/app/Repositories/ArticleRepository.php +++ b/app/Repositories/ArticleRepository.php @@ -4,25 +4,43 @@ namespace App\Repositories; use App\DTOs\ArticleCandidateDTO; use App\Models\Article; +use App\Models\ArticleChunk; use App\Repositories\Contracts\ArticleRepositoryInterface; -use Illuminate\Support\Facades\DB; class ArticleRepository implements ArticleRepositoryInterface { - public function findSimilarByEmbedding(array $embedding, int $limit = 5): array + public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array { $vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']'; - $rows = Article::query() - ->select('articles.*') - ->selectRaw('embedding <=> ?::vector as distance', [$vector]) + $chunkDistances = ArticleChunk::query() + ->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector]) ->whereNotNull('embedding') - ->orderByRaw('embedding <=> ?::vector', [$vector]) + ->when($embeddingContext !== [], function ($query) use ($embeddingContext) { + $query + ->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null) + ->where('embedding_model', $embeddingContext['embedding_model'] ?? null); + }) + ->groupBy('article_id') + ->orderByRaw('MIN(embedding <=> ?::vector)', [$vector]) ->limit($limit) ->get(); - return $rows - ->map(fn (Article $article) => ArticleCandidateDTO::fromArticle($article, (float) $article->distance)) + if ($chunkDistances->isEmpty()) { + return []; + } + + $distanceByArticleId = $chunkDistances->pluck('distance', 'article_id'); + $articleIds = $chunkDistances->pluck('article_id')->all(); + + $articles = Article::query() + ->whereIn('id', $articleIds) + ->get() + ->sortBy(fn (Article $a) => (float) ($distanceByArticleId[$a->id] ?? 1)) + ->values(); + + return $articles + ->map(fn (Article $article) => ArticleCandidateDTO::fromArticle($article, (float) ($distanceByArticleId[$article->id] ?? 1))) ->all(); } -} \ No newline at end of file +} diff --git a/app/Repositories/Contracts/ArticleRepositoryInterface.php b/app/Repositories/Contracts/ArticleRepositoryInterface.php index 9071154..9e30d34 100644 --- a/app/Repositories/Contracts/ArticleRepositoryInterface.php +++ b/app/Repositories/Contracts/ArticleRepositoryInterface.php @@ -7,5 +7,5 @@ use App\DTOs\ArticleCandidateDTO; interface ArticleRepositoryInterface { /** @return array */ - public function findSimilarByEmbedding(array $embedding, int $limit = 5): array; -} \ No newline at end of file + public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array; +} diff --git a/app/Services/AIClassifierService.php b/app/Services/AIClassifierService.php index cc9fcea..48a1fd2 100644 --- a/app/Services/AIClassifierService.php +++ b/app/Services/AIClassifierService.php @@ -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 $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 = <<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 ); } -} \ No newline at end of file + + 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), + ]; + } +} diff --git a/app/Services/AdminArticleService.php b/app/Services/AdminArticleService.php index 6b2a7b7..511953e 100644 --- a/app/Services/AdminArticleService.php +++ b/app/Services/AdminArticleService.php @@ -3,19 +3,88 @@ namespace App\Services; use App\Models\Article; +use App\Models\QuickReply; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; class AdminArticleService { - public function paginate(int $perPage = 10) + public function paginate(int $perPage = 10): LengthAwarePaginator { - return Article::query()->latest()->paginate($perPage); + return Article::query() + ->with('quickReplies') + ->latest() + ->paginate($perPage); } - public function create(string $title, string $content): Article + public function create(string $title, string $content, ?string $note = null, array $allowedActions = []): Article { return Article::query()->create([ 'title' => trim($title), 'content' => trim($content), + 'note' => $note !== null && trim($note) !== '' ? trim($note) : null, + 'allowed_actions' => $this->sanitizeAllowedActions($allowedActions), + 'status' => 'published', + 'is_ai_draft' => false, ]); } + + public function deleteById(int $articleId): bool + { + return (bool) Article::query()->whereKey($articleId)->delete(); + } + + public function updateMetadata(int $articleId, ?string $note, array $allowedActions, array $quickReplyIds = []): bool + { + $article = Article::query()->find($articleId); + if ($article === null) { + return false; + } + + DB::transaction(function () use ($article, $note, $allowedActions, $quickReplyIds): void { + $article->note = $note !== null && trim($note) !== '' ? trim($note) : null; + $article->allowed_actions = $this->sanitizeAllowedActions($allowedActions); + $article->save(); + $article->quickReplies()->sync($this->existingQuickReplyIds($quickReplyIds)); + }); + + return true; + } + + public function approveDraft(int $articleId): bool + { + $article = Article::query()->find($articleId); + if ($article === null) { + return false; + } + + $article->status = 'published'; + $article->is_ai_draft = false; + $article->save(); + + return true; + } + + private function sanitizeAllowedActions(array $allowedActions): array + { + return array_values(array_intersect(array_unique($allowedActions), ['domain_inf'])); + } + + private function existingQuickReplyIds(array $quickReplyIds): array + { + $ids = array_values(array_unique(array_filter( + array_map(static fn ($id) => (int) $id, $quickReplyIds), + static fn ($id) => $id > 0 + ))); + + if ($ids === []) { + return []; + } + + return QuickReply::query() + ->whereIn('id', $ids) + ->pluck('id') + ->map(static fn ($id) => (int) $id) + ->all(); + } } diff --git a/app/Services/AdminDashboardService.php b/app/Services/AdminDashboardService.php index 7640f4a..3bc99af 100644 --- a/app/Services/AdminDashboardService.php +++ b/app/Services/AdminDashboardService.php @@ -37,6 +37,7 @@ class AdminDashboardService } $correct = Feedback::query()->where('is_correct', true)->count(); + return round(($correct / $total) * 100, 2); } } diff --git a/app/Services/AdminQuickReplyService.php b/app/Services/AdminQuickReplyService.php new file mode 100644 index 0000000..b896d68 --- /dev/null +++ b/app/Services/AdminQuickReplyService.php @@ -0,0 +1,56 @@ +withCount('articles') + ->latest() + ->paginate($perPage); + } + + public function activeOptions(): Collection + { + return QuickReply::query() + ->where('is_active', true) + ->orderBy('title') + ->get(['id', 'title']); + } + + public function create(string $title, string $content, bool $isActive = true): QuickReply + { + return QuickReply::query()->create([ + 'title' => trim($title), + 'content' => trim($content), + 'is_active' => $isActive, + ]); + } + + public function update(int $id, string $title, string $content, bool $isActive): bool + { + $quickReply = QuickReply::query()->find($id); + if ($quickReply === null) { + return false; + } + + $quickReply->update([ + 'title' => trim($title), + 'content' => trim($content), + 'is_active' => $isActive, + ]); + + return true; + } + + public function deleteById(int $id): bool + { + return (bool) QuickReply::query()->whereKey($id)->delete(); + } +} diff --git a/app/Services/AdminTicketService.php b/app/Services/AdminTicketService.php index d1c950e..4226542 100644 --- a/app/Services/AdminTicketService.php +++ b/app/Services/AdminTicketService.php @@ -9,8 +9,15 @@ class AdminTicketService public function paginateWithDecision(int $perPage = 10) { return Ticket::query() - ->with(['decisions.article']) + ->with(['decisions.article', 'bestArticle']) ->latest() ->paginate($perPage); } + + public function findWithTimeline(int $ticketId): ?Ticket + { + return Ticket::query() + ->with(['bestArticle', 'draftArticle', 'logs', 'decisions.article', 'toolCalls.article']) + ->find($ticketId); + } } diff --git a/app/Services/AppSettingsService.php b/app/Services/AppSettingsService.php new file mode 100644 index 0000000..6fbb540 --- /dev/null +++ b/app/Services/AppSettingsService.php @@ -0,0 +1,283 @@ + 'je', + 'prompt.normalization' => 'You rewrite customer support questions. Keep intent, fix spelling, remove noise, redact PII. Return JSON with normalized_message and redaction_report.', + 'prompt.classifier' => 'You are a support assistant. Select best article and return JSON. Include tool_call only when the selected article explicitly allows that action and all required parameters are present.', + 'prompt.knowledge_gap' => 'Create a draft knowledge base article suggestion based on the customer question. Use the requested output language passed in the prompt. Return JSON only with keys: title, content.', + 'prompt.support_reply' => 'Give only direct advice in the requested output language. No greeting, no closing, no thank-you text. Start directly with the solution. Give 3-6 numbered action points and end with a verification step.', + 'llm.provider' => env('LLM_PROVIDER', 'ollama'), + 'llm.active_instance_id' => env('LLM_PROVIDER', 'ollama').'_default', + 'llm.provider_instances' => json_encode($this->defaultProviderInstances()), + 'llm.timeout' => (string) env('LLM_TIMEOUT', env('OLLAMA_TIMEOUT', 30)), + 'llm.providers.ollama.base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + 'llm.providers.ollama.chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'), + 'llm.providers.ollama.embedding_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'), + 'llm.providers.lmstudio.base_url' => env('LLM_BASE_URL', 'http://localhost:1234'), + 'llm.providers.lmstudio.chat_model' => env('LLM_CHAT_MODEL', 'local-model'), + 'llm.providers.lmstudio.embedding_model' => env('LLM_EMBEDDING_MODEL', 'text-embedding-nomic-embed-text-v1.5@q6_k'), + 'llm.models.normalization' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')), + 'llm.models.classifier' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')), + 'llm.models.knowledge_gap' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')), + 'llm.models.support_reply' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')), + 'llm.models.embedding' => env('LLM_EMBEDDING_MODEL', env('OLLAMA_EMBED_MODEL', 'nomic-embed-text')), + ]; + } + + public function processSteps(): array + { + return [ + ['id' => 'normalization', 'number' => 1, 'title' => 'Vraag netjes maken', 'description' => 'Haalt typefouten, ruis en privegegevens uit de klantvraag voordat de app ermee verder werkt.', 'prompt_key' => 'prompt.normalization'], + ['id' => 'embedding', 'number' => 2, 'title' => 'Betekenis-code maken', 'description' => 'Zet de opgeschoonde vraag om naar een code die beschrijft waar de vraag over gaat.'], + ['id' => 'retrieval', 'number' => 3, 'title' => 'Kennisbank zoeken', 'description' => 'Zoekt kleine stukjes kennisbanktekst die qua betekenis het meest op de vraag lijken.'], + ['id' => 'classifier', 'number' => 4, 'title' => 'Beste artikel kiezen', 'description' => 'Laat de AI kiezen welk gevonden artikel het beste past en waarom.', 'prompt_key' => 'prompt.classifier'], + ['id' => 'tool_call', 'number' => 5, 'title' => 'Extra informatie ophalen', 'description' => 'Gebruikt alleen toegestane hulpmiddelen, zoals domeininformatie ophalen, als het artikel dit toestaat.'], + ['id' => 'quick_reply', 'number' => 6, 'title' => 'Snelantwoord controleren', 'description' => 'Gebruikt een gekoppeld snelantwoord als dat beschikbaar is, zodat de AI geen nieuw antwoord hoeft te schrijven.'], + ['id' => 'knowledge_gap', 'number' => 7, 'title' => 'Missend artikel herkennen', 'description' => 'Geeft aan dat de kennisbank waarschijnlijk geen goed artikel heeft voor deze vraag.', 'prompt_key' => 'prompt.knowledge_gap'], + ['id' => 'support_reply', 'number' => 8, 'title' => 'Conceptadvies maken', 'description' => 'Schrijft alleen een korte conceptreactie als er geen snelantwoord beschikbaar is.', 'prompt_key' => 'prompt.support_reply'], + ]; + } + + public function providerDefinitions(): array + { + return [ + 'lmstudio' => ['label' => 'LM Studio', 'description' => 'OpenAI-compatible endpoint op je lokale netwerk.'], + 'ollama' => ['label' => 'Ollama', 'description' => 'Lokale Ollama API zonder externe providers.'], + ]; + } + + public function defaultProviderInstances(): array + { + return [ + [ + 'id' => 'lmstudio_default', + 'name' => 'LM Studio', + 'type' => 'lmstudio', + 'base_url' => env('LLM_BASE_URL', 'http://localhost:1234'), + 'chat_model' => env('LLM_CHAT_MODEL', 'local-model'), + 'embedding_model' => env('LLM_EMBEDDING_MODEL', 'text-embedding-nomic-embed-text-v1.5@q6_k'), + ], + [ + 'id' => 'ollama_default', + 'name' => 'Ollama', + 'type' => 'ollama', + 'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), + 'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'), + 'embedding_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'), + ], + ]; + } + + public function modelTasks(): array + { + return [ + ['id' => 'normalization', 'number' => 1, 'title' => 'Vraag netjes maken', 'description' => 'Model dat de ruwe klantvraag herschrijft en privegegevens weghaalt.'], + ['id' => 'embedding', 'number' => 2, 'title' => 'Betekenis-code maken', 'description' => 'Model dat vragen en artikelstukjes omzet naar zoekcodes.'], + ['id' => 'classifier', 'number' => 3, 'title' => 'Beste artikel kiezen', 'description' => 'Model dat kiest welk artikel het beste past en hoe zeker die keuze is.'], + ['id' => 'knowledge_gap', 'number' => 4, 'title' => 'Missend artikel herkennen', 'description' => 'Model dat een voorstel maakt als de kennisbank tekortschiet.'], + ['id' => 'support_reply', 'number' => 5, 'title' => 'Conceptadvies maken', 'description' => 'Model dat het uiteindelijke advies opstelt.'], + ]; + } + + public function all(): array + { + $this->ensureDefaultsPersisted(); + + $defaults = $this->defaults(); + $stored = Setting::query()->pluck('value', 'key')->toArray(); + + return array_merge($defaults, $stored); + } + + public function get(string $key, ?string $default = null): ?string + { + $all = $this->all(); + + return $all[$key] ?? $default; + } + + public function getPrompt(string $key, ?string $default = null): ?string + { + $promptValue = $this->get('prompt.'.$key); + if (is_string($promptValue) && trim($promptValue) !== '') { + return $promptValue; + } + + // Backward compatibility for earlier stored keys. + $legacyKeyMap = [ + 'normalization' => 'normalization_prompt', + 'classifier' => 'classifier_prompt', + 'support_reply' => 'support_reply_prompt', + ]; + + $legacyKey = $legacyKeyMap[$key] ?? null; + if ($legacyKey !== null) { + $legacyValue = $this->get($legacyKey); + if (is_string($legacyValue) && trim($legacyValue) !== '') { + return $legacyValue; + } + } + + return $default; + } + + public function promptSettings(): array + { + $settings = $this->all(); + $prompts = []; + + foreach ($settings as $key => $value) { + if (str_starts_with((string) $key, 'prompt.')) { + $prompts[$key] = (string) $value; + } + } + + ksort($prompts); + + return $prompts; + } + + public function promptValues(): array + { + $values = []; + foreach ($this->processSteps() as $step) { + if (isset($step['prompt_key'])) { + $id = (string) $step['id']; + $values[$id] = (string) $this->getPrompt($id, ''); + } + } + + return $values; + } + + public function providerSettings(): array + { + return [ + 'instances' => $this->providerInstances(), + 'active_instance_id' => $this->activeProviderInstanceId(), + ]; + } + + public function providerInstances(): array + { + $raw = $this->get('llm.provider_instances'); + $decoded = is_string($raw) ? json_decode($raw, true) : null; + + if (! is_array($decoded) || $decoded === []) { + return $this->defaultProviderInstances(); + } + + return array_values(array_filter($decoded, static fn ($item) => is_array($item) && isset($item['id'], $item['type']))); + } + + public function activeProviderInstanceId(): string + { + $active = (string) $this->get('llm.active_instance_id', ''); + $ids = array_column($this->providerInstances(), 'id'); + + if ($active !== '' && in_array($active, $ids, true)) { + return $active; + } + + return (string) ($ids[0] ?? 'ollama_default'); + } + + public function activeProviderInstance(): array + { + $active = $this->activeProviderInstanceId(); + + foreach ($this->providerInstances() as $instance) { + if (($instance['id'] ?? null) === $active) { + return $instance; + } + } + + return $this->defaultProviderInstances()[0]; + } + + public function modelSettings(): array + { + $models = []; + foreach ($this->modelTasks() as $task) { + $id = (string) $task['id']; + $models[$id] = (string) $this->get("llm.models.{$id}", ''); + } + + return $models; + } + + public function setMany(array $pairs): void + { + foreach ($pairs as $key => $value) { + Setting::query()->updateOrCreate(['key' => $key], ['value' => (string) $value]); + } + } + + public function savePromptSettings(array $prompts): void + { + $keys = array_map(static fn ($key) => (string) $key, array_keys($prompts)); + Setting::query() + ->where('key', 'like', 'prompt.%') + ->whereNotIn('key', $keys) + ->delete(); + + foreach ($prompts as $key => $value) { + Setting::query()->updateOrCreate(['key' => (string) $key], ['value' => (string) $value]); + } + } + + public function saveStructuredSettings(array $promptValues, array $providerInstances, string $activeProviderInstanceId, array $modelValues, int $timeout, string $tone): void + { + $activeInstance = collect($providerInstances)->firstWhere('id', $activeProviderInstanceId) ?: ($providerInstances[0] ?? []); + $activeProvider = (string) ($activeInstance['type'] ?? 'ollama'); + + $pairs = [ + 'tone_addressing' => $tone, + 'llm.provider' => $activeProvider, + 'llm.active_instance_id' => $activeProviderInstanceId, + 'llm.provider_instances' => json_encode(array_values($providerInstances), JSON_UNESCAPED_SLASHES), + 'llm.timeout' => (string) $timeout, + ]; + + foreach ($promptValues as $id => $value) { + $pairs['prompt.'.$id] = (string) $value; + } + + foreach ($providerInstances as $instance) { + $provider = (string) ($instance['type'] ?? ''); + if ($provider === '') { + continue; + } + + foreach (['base_url', 'chat_model', 'embedding_model'] as $field) { + $pairs["llm.providers.{$provider}.{$field}"] = (string) ($instance[$field] ?? ''); + } + } + + foreach ($modelValues as $id => $value) { + $pairs["llm.models.{$id}"] = (string) $value; + } + + $this->setMany($pairs); + } + + private function ensureDefaultsPersisted(): void + { + if (! Schema::hasTable('settings')) { + return; + } + + foreach ($this->defaults() as $key => $value) { + Setting::query()->firstOrCreate(['key' => $key], ['value' => (string) $value]); + } + } +} diff --git a/app/Services/ArticleChunkerService.php b/app/Services/ArticleChunkerService.php new file mode 100644 index 0000000..b3b973f --- /dev/null +++ b/app/Services/ArticleChunkerService.php @@ -0,0 +1,39 @@ + */ + public function chunk(string $text, int $targetTokens = 100, int $overlapTokens = 20): array + { + $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); + if ($text === '') { + return []; + } + + // Approximate tokenization by words for deterministic local chunking. + $words = preg_split('/\s+/', $text) ?: []; + $count = count($words); + if ($count === 0) { + return []; + } + + $step = max(1, $targetTokens - $overlapTokens); + $chunks = []; + + for ($start = 0; $start < $count; $start += $step) { + $slice = array_slice($words, $start, $targetTokens); + if ($slice === []) { + continue; + } + $chunks[] = implode(' ', $slice); + + if (($start + $targetTokens) >= $count) { + break; + } + } + + return $chunks; + } +} diff --git a/app/Services/ArticleEmbeddingMaintenanceService.php b/app/Services/ArticleEmbeddingMaintenanceService.php new file mode 100644 index 0000000..1b6ec79 --- /dev/null +++ b/app/Services/ArticleEmbeddingMaintenanceService.php @@ -0,0 +1,56 @@ +embeddingService->context(); + $articles = Article::query()->count(); + $articlesWithChunks = Article::query()->has('chunks')->count(); + $chunks = ArticleChunk::query()->count(); + $chunksWithEmbedding = ArticleChunk::query()->whereNotNull('embedding')->count(); + $currentChunks = ArticleChunk::query() + ->where('embedding_provider_instance_id', $context['provider_instance_id']) + ->where('embedding_model', $context['embedding_model']) + ->count(); + + return [ + 'articles' => $articles, + 'articles_with_chunks' => $articlesWithChunks, + 'articles_without_chunks' => max(0, $articles - $articlesWithChunks), + 'chunks' => $chunks, + 'chunks_with_embedding' => $chunksWithEmbedding, + 'chunks_without_embedding' => max(0, $chunks - $chunksWithEmbedding), + 'current_embedding_chunks' => $currentChunks, + 'stale_or_other_model_chunks' => max(0, $chunks - $currentChunks), + 'active_provider_instance_id' => $context['provider_instance_id'], + 'active_embedding_model' => $context['embedding_model'], + ]; + } + + public function dispatchReindex(bool $force = false): int + { + $query = Article::query()->orderBy('id'); + if (! $force) { + $query->whereDoesntHave('chunks'); + } + + $count = 0; + $query->chunkById(100, function ($articles) use (&$count) { + foreach ($articles as $article) { + GenerateArticleEmbeddingJob::dispatch($article->id); + $count++; + } + }); + + return $count; + } +} diff --git a/app/Services/ArticleIndexingService.php b/app/Services/ArticleIndexingService.php new file mode 100644 index 0000000..23d232a --- /dev/null +++ b/app/Services/ArticleIndexingService.php @@ -0,0 +1,36 @@ +chunkerService->chunk($article->title."\n\n".$article->content, 100, 20); + $context = $this->embeddingService->context(); + + $article->chunks()->delete(); + + foreach ($chunks as $idx => $chunkText) { + $article->chunks()->create([ + 'chunk_index' => $idx, + 'content' => $chunkText, + 'embedding' => $this->embeddingService->embed($chunkText), + 'embedding_provider_instance_id' => $context['provider_instance_id'], + 'embedding_model' => $context['embedding_model'], + 'embedded_at' => now(), + ]); + } + + // Keep article-level embedding for backward compatibility/debugging. + $article->embedding = $this->embeddingService->embed($article->title."\n".$article->content); + $article->saveQuietly(); + } +} diff --git a/app/Services/ClassifierPromptBuilder.php b/app/Services/ClassifierPromptBuilder.php new file mode 100644 index 0000000..93d84c8 --- /dev/null +++ b/app/Services/ClassifierPromptBuilder.php @@ -0,0 +1,38 @@ + $candidates */ + public function build(string $basePrompt, string $ticketMessage, array $candidates, string $language): string + { + $articlesBlock = collect($candidates) + ->map(fn (ArticleCandidateDTO $item, int $idx) => sprintf( + "%d. article_id=%d\nTitle: %s\nSource URL: %s\nAllowed actions: %s\nInternal note for support assistant: %s\nContent: %s", + $idx + 1, + $item->articleId, + $item->title, + $item->sourceUrl ?? '-', + $item->allowedActions === [] ? '[]' : json_encode($item->allowedActions, JSON_UNESCAPED_SLASHES), + $item->note ?: '-', + $item->content + )) + ->implode("\n\n"); + + return $basePrompt."\n\n". + "User language: {$language}. Return explanation in this language.\n\n". + "Return JSON only with this schema:\n". + "{\n". + " \"article_id\": number,\n". + " \"confidence\": number,\n". + " \"explanation\": string,\n". + " \"tool_call\": null | {\"action\": \"domain_inf\", \"parameters\": {\"sld\": string, \"tld\": string}, \"reason\": string}\n". + "}\n\n". + "Only include a tool_call when the selected article lists that action in Allowed actions and both required parameters are present in the user question.\n". + "Never invent sld or tld. If either parameter is missing, set tool_call to null.\n\n". + "User question:\n\"{$ticketMessage}\"\n\nArticles:\n{$articlesBlock}"; + } +} diff --git a/app/Services/EmbeddingService.php b/app/Services/EmbeddingService.php index ba5b0be..0eb6884 100644 --- a/app/Services/EmbeddingService.php +++ b/app/Services/EmbeddingService.php @@ -4,44 +4,59 @@ namespace App\Services; use App\Exceptions\OllamaUnavailableException; use App\Models\EmbeddingCache; -use Illuminate\Support\Facades\Http; -use Throwable; +use App\Services\Llm\LlmClientInterface; class EmbeddingService { + public function __construct( + private readonly LlmClientInterface $llmClient, + private readonly AppSettingsService $settings, + ) {} + public function embed(string $text): array { $hash = hash('sha256', $text); - $cached = EmbeddingCache::query()->where('text_hash', $hash)->first(); + $context = $this->context(); + $cached = EmbeddingCache::query() + ->where('provider_instance_id', $context['provider_instance_id']) + ->where('embedding_model', $context['embedding_model']) + ->where('text_hash', $hash) + ->first(); if ($cached !== null) { return $cached->embedding; } - $baseUrl = rtrim((string) config('services.ollama.base_url'), '/'); - - try { - $response = Http::timeout((int) config('services.ollama.timeout', 30)) - ->post($baseUrl.'/api/embeddings', [ - 'model' => config('services.ollama.embed_model', 'nomic-embed-text'), - 'prompt' => $text, - ]) - ->throw() - ->json(); - } catch (Throwable $e) { - throw new OllamaUnavailableException('Ollama embedding endpoint is unavailable', 0, $e); - } - - $embedding = $response['embedding'] ?? []; - if (!is_array($embedding) || $embedding === []) { - throw new OllamaUnavailableException('Ollama embedding response did not include a valid embedding'); + $embedding = $this->llmClient->embed($text); + if (! is_array($embedding) || $embedding === []) { + throw new OllamaUnavailableException('LLM embedding response did not include a valid embedding'); } EmbeddingCache::query()->updateOrCreate( - ['text_hash' => $hash], + [ + 'provider_instance_id' => $context['provider_instance_id'], + 'embedding_model' => $context['embedding_model'], + 'text_hash' => $hash, + ], ['text' => $text, 'embedding' => $embedding] ); return $embedding; } -} \ No newline at end of file + + public function context(): array + { + $instance = $this->settings->activeProviderInstance(); + $instanceId = (string) ($instance['id'] ?? $this->settings->activeProviderInstanceId()); + $model = trim((string) $this->settings->get('llm.models.embedding', '')); + + if ($model === '') { + $model = (string) ($instance['embedding_model'] ?? ''); + } + + return [ + 'provider_instance_id' => $instanceId, + 'embedding_model' => $model, + ]; + } +} diff --git a/app/Services/HelpdeskImportService.php b/app/Services/HelpdeskImportService.php index 176b38d..a1e2460 100644 --- a/app/Services/HelpdeskImportService.php +++ b/app/Services/HelpdeskImportService.php @@ -38,12 +38,14 @@ class HelpdeskImportService if ($parsed === null) { $skipped++; $progress && $progress($processed, $total, $articleUrl, 'skipped'); + continue; } if ($dryRun) { $imported++; $progress && $progress($processed, $total, $articleUrl, 'dry-run'); + continue; } @@ -52,18 +54,16 @@ class HelpdeskImportService $categoryId = $this->resolveCategoryId($meta['category_external_id'] ?? null, $categoryMap); $subcategoryId = $this->resolveCategoryId($meta['subcategory_external_id'] ?? null, $categoryMap); - $result = Article::withoutEvents(function () use ($title, $content, $articleUrl, $sourceArticleId, $categoryId, $subcategoryId) { - return Article::query()->updateOrCreate( - ['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId], - [ - 'title' => $title, - 'content' => $content, - 'source_url' => $articleUrl, - 'category_id' => $categoryId, - 'subcategory_id' => $subcategoryId, - ] - ); - }); + $result = Article::query()->updateOrCreate( + ['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId], + [ + 'title' => $title, + 'content' => $content, + 'source_url' => $articleUrl, + 'category_id' => $categoryId, + 'subcategory_id' => $subcategoryId, + ] + ); if ($result->wasRecentlyCreated) { $imported++; @@ -92,11 +92,12 @@ class HelpdeskImportService private function extractCategories(string $html): array { - if (!preg_match('/const\s+categories\s*=\s*(\[.*?\]);/s', $html, $matches)) { + if (! preg_match('/const\s+categories\s*=\s*(\[.*?\]);/s', $html, $matches)) { return []; } $decoded = json_decode($matches[1], true); + return is_array($decoded) ? $decoded : []; } @@ -104,12 +105,12 @@ class HelpdeskImportService { $map = []; foreach ($categories as $category) { - if (!isset($category['id'], $category['title'], $category['slug'])) { + if (! isset($category['id'], $category['title'], $category['slug'])) { continue; } $parentId = null; - if (!$dryRun) { + if (! $dryRun) { $model = Category::query()->updateOrCreate( ['external_id' => (int) $category['id']], ['name' => (string) $category['title'], 'slug' => (string) $category['slug'], 'parent_id' => null] @@ -120,11 +121,11 @@ class HelpdeskImportService $map[(int) $category['id']] = $parentId; foreach (($category['children'] ?? []) as $child) { - if (!isset($child['id'], $child['title'], $child['slug'])) { + if (! isset($child['id'], $child['title'], $child['slug'])) { continue; } - if (!$dryRun && $parentId !== null) { + if (! $dryRun && $parentId !== null) { $childModel = Category::query()->updateOrCreate( ['external_id' => (int) $child['id']], ['name' => (string) $child['title'], 'slug' => (string) $child['slug'], 'parent_id' => $parentId] @@ -143,7 +144,7 @@ class HelpdeskImportService { $sections = []; foreach ($categories as $category) { - if (!isset($category['id'], $category['slug'])) { + if (! isset($category['id'], $category['slug'])) { continue; } @@ -154,7 +155,7 @@ class HelpdeskImportService ]; foreach (($category['children'] ?? []) as $child) { - if (!isset($child['id'], $child['slug'])) { + if (! isset($child['id'], $child['slug'])) { continue; } @@ -186,7 +187,7 @@ class HelpdeskImportService preg_match_all('/https:\/\/www\.internettoday\.nl\/helpdesk\/(\d+)-[a-z0-9\-]+/i', $html, $matches); foreach (($matches[0] ?? []) as $match) { $url = strtolower($match); - if (!isset($result[$url])) { + if (! isset($result[$url])) { $result[$url] = [ 'category_external_id' => $source['category_external_id'], 'subcategory_external_id' => $source['subcategory_external_id'], @@ -206,7 +207,7 @@ class HelpdeskImportService return null; } - if (!preg_match('/]*>(.*?)<\/h1>/is', $html, $titleMatch)) { + if (! preg_match('/]*>(.*?)<\/h1>/is', $html, $titleMatch)) { return null; } @@ -215,7 +216,7 @@ class HelpdeskImportService return null; } - if (!preg_match('/\s*()\s*<\/div>/is', $html, $contentMatch)) { + if (! preg_match('/\s*()\s*<\/div>/is', $html, $contentMatch)) { return null; } @@ -228,7 +229,7 @@ class HelpdeskImportService return null; } - if (!preg_match('/\/helpdesk\/(\d+)-/', $url, $idMatch)) { + if (! preg_match('/\/helpdesk\/(\d+)-/', $url, $idMatch)) { return null; } @@ -248,6 +249,7 @@ class HelpdeskImportService { $decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $decoded = preg_replace('/\s+/', ' ', $decoded) ?? $decoded; + return trim($decoded); } } diff --git a/app/Services/KnowledgeGapService.php b/app/Services/KnowledgeGapService.php new file mode 100644 index 0000000..742228c --- /dev/null +++ b/app/Services/KnowledgeGapService.php @@ -0,0 +1,67 @@ +normalized_message ?: $ticket->message; + $language = (string) ($ticket->redaction_report['language'] ?? 'nl'); + $topCandidates = json_encode($result['top_3_candidates'] ?? [], JSON_UNESCAPED_UNICODE); + + $basePrompt = $this->settings->getPrompt('knowledge_gap', 'Create a draft knowledge base article suggestion. Return JSON only with keys: title, content.'); + $prompt = $basePrompt."\n\n". + "Klantvraag:\n{$question}\n\n". + "Originele taal: {$language}. Schrijf titel en inhoud in deze taal.\n\n". + "Huidige kandidaten (mogelijk onvoldoende):\n{$topCandidates}\n\n". + 'Content moet praktisch zijn met duidelijke stappen.'; + + $title = 'Concept: '.Str::limit($question, 80, ''); + $content = "Deze vraag kon nog niet goed worden beantwoord vanuit de huidige kennisbank.\n\n". + "Klantvraag:\n".$question."\n\n". + 'Actie: supportmedewerker vult dit artikel aan met definitieve stappen en context.'; + + try { + $raw = trim($this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'knowledge_gap'])); + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $title = trim((string) ($decoded['title'] ?? $title)); + $content = trim((string) ($decoded['content'] ?? $content)); + } + } catch (\Throwable) { + } + + return [ + 'title' => $title, + 'content' => $content, + ]; + } +} diff --git a/app/Services/Llm/LlmClientInterface.php b/app/Services/Llm/LlmClientInterface.php new file mode 100644 index 0000000..944eb7e --- /dev/null +++ b/app/Services/Llm/LlmClientInterface.php @@ -0,0 +1,10 @@ +timeout(); + $baseUrl = $this->baseUrl(); + + try { + $response = Http::connectTimeout(5) + ->timeout($timeout) + ->withOptions(['read_timeout' => $timeout]) + ->post($baseUrl.'/v1/embeddings', [ + 'model' => $this->model('embedding', (string) config('services.llm.embedding_model')), + 'input' => $text, + ]) + ->throw() + ->json(); + } catch (Throwable $e) { + throw $this->mapException($e, 'embedding'); + } + + $embedding = $response['data'][0]['embedding'] ?? []; + if (! is_array($embedding) || $embedding === []) { + throw new OllamaUnavailableException('lmstudio', 'embedding', 'No embedding in response'); + } + + return $embedding; + } + + public function generate(string $prompt, array $options = []): string + { + $timeout = $this->timeout(); + $baseUrl = $this->baseUrl(); + $expectJson = (bool) ($options['expect_json'] ?? false); + $task = (string) ($options['task'] ?? 'chat'); + + $payload = [ + 'model' => $this->model($task, (string) config('services.llm.chat_model')), + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + 'temperature' => 0.1, + ]; + + if ($expectJson) { + // OpenAI-compatible endpoints vary: some support json_schema/text only. + $payload['response_format'] = ['type' => 'json_schema']; + } + + try { + $response = Http::connectTimeout(5) + ->timeout($timeout) + ->withOptions(['read_timeout' => $timeout]) + ->post($baseUrl.'/v1/chat/completions', $payload) + ->throw() + ->json(); + } catch (RequestException $e) { + $body = (string) ($e->response?->body() ?? ''); + $isResponseFormatError = str_contains($body, 'response_format.type') + || str_contains($body, 'json_schema'); + + if ($expectJson && $isResponseFormatError) { + // Fallback retry without response_format for stricter local servers. + try { + $retryPayload = $payload; + unset($retryPayload['response_format']); + + $response = Http::connectTimeout(5) + ->timeout($timeout) + ->withOptions(['read_timeout' => $timeout]) + ->post($baseUrl.'/v1/chat/completions', $retryPayload) + ->throw() + ->json(); + + return (string) ($response['choices'][0]['message']['content'] ?? ''); + } catch (Throwable $retryException) { + throw $this->mapException($retryException, 'generation'); + } + } + + throw $this->mapException($e, 'generation'); + } catch (Throwable $e) { + throw $this->mapException($e, 'generation'); + } + + return (string) ($response['choices'][0]['message']['content'] ?? ''); + } + + private function baseUrl(): string + { + $instance = $this->settings->activeProviderInstance(); + + return rtrim((string) ($instance['base_url'] ?? $this->settings->get('llm.providers.lmstudio.base_url', (string) config('services.llm.base_url'))), '/'); + } + + private function timeout(): int + { + return (int) $this->settings->get('llm.timeout', (string) config('services.llm.timeout', 30)); + } + + private function model(string $task, string $fallback): string + { + $instance = $this->settings->activeProviderInstance(); + $chatModel = (string) ($instance['chat_model'] ?? $this->settings->get('llm.providers.lmstudio.chat_model', $fallback)); + $embeddingModel = (string) ($instance['embedding_model'] ?? $this->settings->get('llm.providers.lmstudio.embedding_model', $fallback)); + + if ($task === 'chat') { + return $chatModel; + } + + $configured = trim((string) $this->settings->get('llm.models.'.$task, '')); + if ($configured !== '') { + return $configured; + } + + return $task === 'embedding' ? $embeddingModel : $chatModel; + } + + private function mapException(Throwable $e, string $operation): OllamaUnavailableException + { + if ($e instanceof RequestException) { + $body = $e->response?->body(); + $snippet = $body ? mb_substr($body, 0, 280) : null; + + return new OllamaUnavailableException('lmstudio', $operation, $e->getMessage(), $e, $snippet); + } + + return new OllamaUnavailableException('lmstudio', $operation, $e->getMessage(), $e); + } +} diff --git a/app/Services/Llm/OllamaClient.php b/app/Services/Llm/OllamaClient.php new file mode 100644 index 0000000..dfdd6a1 --- /dev/null +++ b/app/Services/Llm/OllamaClient.php @@ -0,0 +1,108 @@ +baseUrl(); + + try { + $response = Http::timeout($this->timeout()) + ->post($baseUrl.'/api/embeddings', [ + 'model' => $this->model('embedding', (string) config('services.llm.embedding_model', 'nomic-embed-text')), + 'prompt' => $text, + ]) + ->throw() + ->json(); + } catch (Throwable $e) { + throw $this->mapException($e, 'embedding'); + } + + $embedding = $response['embedding'] ?? []; + if (! is_array($embedding) || $embedding === []) { + throw new OllamaUnavailableException('ollama', 'embedding', 'No embedding in response'); + } + + return $embedding; + } + + public function generate(string $prompt, array $options = []): string + { + $baseUrl = $this->baseUrl(); + $expectJson = (bool) ($options['expect_json'] ?? false); + $task = (string) ($options['task'] ?? 'chat'); + + $payload = [ + 'model' => $this->model($task, (string) config('services.llm.chat_model', 'llama3')), + 'prompt' => $prompt, + 'stream' => false, + ]; + + if ($expectJson) { + $payload['format'] = 'json'; + } + + try { + $response = Http::timeout($this->timeout()) + ->post($baseUrl.'/api/generate', $payload) + ->throw() + ->json(); + } catch (Throwable $e) { + throw $this->mapException($e, 'generation'); + } + + return (string) ($response['response'] ?? ''); + } + + private function baseUrl(): string + { + $instance = $this->settings->activeProviderInstance(); + + return rtrim((string) ($instance['base_url'] ?? $this->settings->get('llm.providers.ollama.base_url', (string) config('services.llm.base_url'))), '/'); + } + + private function timeout(): int + { + return (int) $this->settings->get('llm.timeout', (string) config('services.llm.timeout', 30)); + } + + private function model(string $task, string $fallback): string + { + $instance = $this->settings->activeProviderInstance(); + $chatModel = (string) ($instance['chat_model'] ?? $this->settings->get('llm.providers.ollama.chat_model', $fallback)); + $embeddingModel = (string) ($instance['embedding_model'] ?? $this->settings->get('llm.providers.ollama.embedding_model', $fallback)); + + if ($task === 'chat') { + return $chatModel; + } + + $configured = trim((string) $this->settings->get('llm.models.'.$task, '')); + if ($configured !== '') { + return $configured; + } + + return $task === 'embedding' ? $embeddingModel : $chatModel; + } + + private function mapException(Throwable $e, string $operation): OllamaUnavailableException + { + if ($e instanceof RequestException) { + $body = $e->response?->body(); + $snippet = $body ? mb_substr($body, 0, 280) : null; + + return new OllamaUnavailableException('ollama', $operation, $e->getMessage(), $e, $snippet); + } + + return new OllamaUnavailableException('ollama', $operation, $e->getMessage(), $e); + } +} diff --git a/app/Services/LlmJsonDecoder.php b/app/Services/LlmJsonDecoder.php new file mode 100644 index 0000000..5084dd1 --- /dev/null +++ b/app/Services/LlmJsonDecoder.php @@ -0,0 +1,36 @@ + $start) { + $candidate = substr($raw, $start, $end - $start + 1); + $decoded = json_decode($candidate, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } +} diff --git a/app/Services/LlmModelCatalogService.php b/app/Services/LlmModelCatalogService.php new file mode 100644 index 0000000..8f8a7d5 --- /dev/null +++ b/app/Services/LlmModelCatalogService.php @@ -0,0 +1,81 @@ + $instance['type'] ?? '', + 'base_url' => $instance['base_url'] ?? '', + ])); + + if ($refresh) { + Cache::forget($cacheKey); + } + + return Cache::remember($cacheKey, now()->addMinutes(5), fn () => $this->fetchModels($instance)); + } + + public function cachedUntil(array $instance): ?string + { + $models = $this->modelsFor($instance); + + return $models === [] ? null : now()->addMinutes(5)->format('H:i:s'); + } + + private function fetchModels(array $instance): array + { + $type = (string) ($instance['type'] ?? ''); + $baseUrl = rtrim((string) ($instance['base_url'] ?? ''), '/'); + + if ($baseUrl === '') { + return []; + } + + return match ($type) { + 'lmstudio' => $this->fetchLmStudioModels($baseUrl), + 'ollama' => $this->fetchOllamaModels($baseUrl), + default => [], + }; + } + + private function fetchLmStudioModels(string $baseUrl): array + { + $response = Http::connectTimeout(2) + ->timeout(5) + ->get($baseUrl.'/v1/models') + ->throw() + ->json(); + + return collect($response['data'] ?? []) + ->pluck('id') + ->filter() + ->map(fn ($model) => (string) $model) + ->unique() + ->sort() + ->values() + ->all(); + } + + private function fetchOllamaModels(string $baseUrl): array + { + $response = Http::connectTimeout(2) + ->timeout(5) + ->get($baseUrl.'/api/tags') + ->throw() + ->json(); + + return collect($response['models'] ?? []) + ->map(fn ($model) => (string) ($model['name'] ?? $model['model'] ?? '')) + ->filter() + ->unique() + ->sort() + ->values() + ->all(); + } +} diff --git a/app/Services/QuickReplyResolver.php b/app/Services/QuickReplyResolver.php new file mode 100644 index 0000000..fa0e244 --- /dev/null +++ b/app/Services/QuickReplyResolver.php @@ -0,0 +1,25 @@ +relationLoaded('quickReplies')) { + $article->load('quickReplies'); + } + + return $article->quickReplies + ->where('is_active', true) + ->sortBy('title') + ->first(); + } +} diff --git a/app/Services/SemanticSearchService.php b/app/Services/SemanticSearchService.php index 4d5e85b..a6161c5 100644 --- a/app/Services/SemanticSearchService.php +++ b/app/Services/SemanticSearchService.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\DTOs\ClassificationResultDTO; use App\Models\AIDecision; use App\Models\Article; use App\Models\Ticket; @@ -18,14 +17,26 @@ class SemanticSearchService public function findBestArticle(Ticket $ticket): array { - $embedding = $ticket->embedding ?? $this->embeddingService->embed($ticket->message); - if ($ticket->embedding === null) { + $queryText = $ticket->normalized_message ?: $ticket->message; + $language = (string) ($ticket->redaction_report['language'] ?? 'nl'); + $embeddingContext = $this->embeddingService->context(); + + $ticketEmbeddingIsCurrent = $ticket->embedding !== null + && $ticket->embedding_provider_instance_id === $embeddingContext['provider_instance_id'] + && $ticket->embedding_model === $embeddingContext['embedding_model']; + + $embedding = $ticketEmbeddingIsCurrent ? $ticket->embedding : $this->embeddingService->embed($queryText); + if (! $ticketEmbeddingIsCurrent) { $ticket->embedding = $embedding; + $ticket->embedding_provider_instance_id = $embeddingContext['provider_instance_id']; + $ticket->embedding_model = $embeddingContext['embedding_model']; + $ticket->embedded_at = now(); $ticket->save(); } - $candidates = $this->articleRepository->findSimilarByEmbedding($embedding, 5); - $classification = $this->classifierService->rank($ticket->message, $candidates); + $rawCandidates = $this->articleRepository->findSimilarByEmbedding($embedding, 12, $embeddingContext); + $candidates = $this->prepareCandidates($queryText, $rawCandidates, 5); + $classification = $this->classifierService->rank($queryText, $candidates, $language); $bestArticle = $classification->articleId ? Article::find($classification->articleId) : null; @@ -42,6 +53,61 @@ class SemanticSearchService 'confidence' => $classification->confidence, 'explanation' => $classification->explanation, 'top_3_candidates' => collect($candidates)->take(3)->map(fn ($c) => $c->toArray())->values()->all(), + 'top_5_candidates' => collect($candidates)->map(fn ($c) => $c->toArray())->values()->all(), + 'retrieval_meta' => [ + 'raw_candidates_count' => count($rawCandidates), + 'deduped_candidates_count' => count($candidates), + ], + 'requested_tool_call' => $classification->toolCall, + 'classifier_raw_response' => $classification->rawResponse, ]; } -} \ No newline at end of file + + private function prepareCandidates(string $queryText, array $candidates, int $limit): array + { + $seen = []; + $unique = []; + + foreach ($candidates as $candidate) { + $dedupeKey = $candidate->sourceArticleId + ? 'source_id:'.$candidate->sourceArticleId + : ($candidate->sourceUrl ? 'source_url:'.$candidate->sourceUrl : 'article:'.$candidate->articleId); + + if (isset($seen[$dedupeKey])) { + continue; + } + + $seen[$dedupeKey] = true; + $unique[] = $candidate; + } + + $query = mb_strtolower($queryText); + $isHowTo = str_contains($query, 'hoe') || str_contains($query, 'instel') || str_contains($query, 'stap'); + + usort($unique, function ($a, $b) use ($isHowTo) { + $scoreA = $this->candidateScore($a->title.' '.$a->content, $a->distance, $isHowTo); + $scoreB = $this->candidateScore($b->title.' '.$b->content, $b->distance, $isHowTo); + + return $scoreB <=> $scoreA; + }); + + return array_values(array_slice($unique, 0, $limit)); + } + + private function candidateScore(string $text, float $distance, bool $isHowTo): float + { + $score = 1.0 - $distance; + $haystack = mb_strtolower($text); + + if ($isHowTo) { + $proceduralKeywords = ['hoe', 'stap', 'instellen', 'aanmaken', 'wijzigen', 'klik', 'menu', 'beheren', 'dns']; + foreach ($proceduralKeywords as $keyword) { + if (str_contains($haystack, $keyword)) { + $score += 0.03; + } + } + } + + return $score; + } +} diff --git a/app/Services/SupportReplyService.php b/app/Services/SupportReplyService.php new file mode 100644 index 0000000..7df726d --- /dev/null +++ b/app/Services/SupportReplyService.php @@ -0,0 +1,108 @@ +settings->getPrompt('support_reply', 'Give only direct advice in the requested output language.'); + + if ($bestArticle === null) { + return "Geen passend kennisbankartikel gevonden.\n\n1. Controleer of je vraag voldoende details bevat (product, domein, foutmelding).\n2. Escaleer naar support voor handmatige opvolging.\n3. Voeg een nieuw kennisbankartikel toe na afhandeling van deze case."; + } + + if ((bool) config('services.llm.ranking_enabled', true)) { + $userInput = $ticket->normalized_message ?: $ticket->message; + $language = (string) ($ticket->redaction_report['language'] ?? 'nl'); + $llmPrompt = $basePrompt."\n\n". + "Customer language: {$language}. Write the advice in this language.\n". + 'Gebruikersvraag (genormaliseerd): '.$userInput."\n". + 'Beste artikel titel: '.$bestArticle->title."\n". + 'Beste artikel content: '.$bestArticle->content."\n". + 'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n". + 'Waarom dit artikel gekozen is: '.$explanation."\n\n". + "Toolresultaat, indien beschikbaar:\n".$this->formatToolCallForPrompt($toolCall)."\n\n". + "Write direct advice only in the customer language.\n". + "Vereisten:\n". + "- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n". + "- Begin direct met de oplossing.\n". + "- Geef 3-6 genummerde actiepunten.\n". + "- Voeg een korte controle-stap toe als laatste punt.\n". + '- Geen markdown, geen codeblokken.'; + + try { + $reply = trim($this->llmClient->generate($llmPrompt, ['task' => 'support_reply'])); + if ($reply !== '') { + return $reply; + } + + $this->logger->log($ticket, 'support_reply', 'warning', 'LLM supportantwoord was leeg; artikel-gebaseerde fallback gebruikt.', [ + 'article_id' => $bestArticle->id, + 'article_title' => $bestArticle->title, + ]); + } catch (\Throwable $e) { + $this->logger->log($ticket, 'support_reply', 'warning', 'LLM supportantwoord faalde; artikel-gebaseerde fallback gebruikt.', [ + 'article_id' => $bestArticle->id, + 'article_title' => $bestArticle->title, + 'error' => $e->getMessage(), + ]); + } + } + + $snippet = Str::limit(trim($bestArticle->content), 420); + $safeExplanation = $this->sanitizeCustomerExplanation($explanation); + + return "1. Gebruik het kennisbankartikel '".$bestArticle->title."' als basis voor de vervolgstappen.\n". + "2. Controleer de situatie aan de hand van de kernpunten uit het artikel.\n". + "3. Voer de herstel- of controleacties uit die in het artikel worden genoemd.\n". + "4. Controleer daarna of het oorspronkelijke probleem niet meer optreedt.\n\n". + 'Relevantie: '.$safeExplanation."\n". + 'Bronsamenvatting: '.$snippet; + } + + private function formatToolCallForPrompt(?array $toolCall): string + { + if ($toolCall === null) { + return 'Geen toolcall uitgevoerd.'; + } + + $safe = [ + 'action' => $toolCall['action'] ?? null, + 'status' => $toolCall['status'] ?? null, + 'parameters' => $toolCall['parameters'] ?? null, + 'response' => $toolCall['response'] ?? null, + 'error' => $toolCall['error'] ?? null, + ]; + + return json_encode($safe, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Toolresultaat kon niet worden geformatteerd.'; + } + + private function sanitizeCustomerExplanation(string $explanation): string + { + $lower = mb_strtolower($explanation); + $hasTechnicalFailure = str_contains($lower, 'llm unavailable') + || str_contains($lower, 'invalid json') + || str_contains($lower, 'fallback') + || str_contains($lower, 'http request') + || str_contains($lower, 'response_format') + || str_contains($lower, 'curl error'); + + if ($hasTechnicalFailure) { + return 'Dit artikel sluit het beste aan op je vraag en bevat de meest relevante stappen om dit in te stellen.'; + } + + return $explanation; + } +} diff --git a/app/Services/TicketIngestionService.php b/app/Services/TicketIngestionService.php new file mode 100644 index 0000000..18ecc7e --- /dev/null +++ b/app/Services/TicketIngestionService.php @@ -0,0 +1,49 @@ +create([ + 'message' => $message, + 'status' => 'queued', + 'api_credentials' => $this->sanitizeCredentials($apiCredentials), + ]); + + $this->logger->log($ticket, 'queued', 'info', 'Ticket in queue geplaatst.'); + ProcessTicketJob::dispatch($ticket->id); + + return [ + 'ticket' => $ticket, + 'result' => null, + ]; + } + + private function sanitizeCredentials(?array $apiCredentials): ?array + { + if ($apiCredentials === null) { + return null; + } + + $apiuser = trim((string) ($apiCredentials['apiuser'] ?? '')); + $apipassword = trim((string) ($apiCredentials['apipassword'] ?? '')); + + if ($apiuser === '' || $apipassword === '') { + return null; + } + + return [ + 'apiuser' => $apiuser, + 'apipassword' => $apipassword, + ]; + } +} diff --git a/app/Services/TicketNormalizationService.php b/app/Services/TicketNormalizationService.php new file mode 100644 index 0000000..60612d6 --- /dev/null +++ b/app/Services/TicketNormalizationService.php @@ -0,0 +1,164 @@ +fallbackNormalize($original); + $fallbackLanguage = $this->detectLanguage($original); + + if (! (bool) config('services.llm.ranking_enabled', true)) { + return [ + 'normalized_message' => $fallback['text'], + 'redaction_report' => [ + 'mode' => 'fallback_regex', + 'pii_types' => $fallback['pii_types'], + 'language' => $fallbackLanguage, + 'reason' => 'llm_normalization_disabled', + ], + ]; + } + + $basePrompt = $this->settings->getPrompt('normalization', 'Rewrite and redact PII. Return JSON.'); + $prompt = $basePrompt."\n\n". + 'Also detect the original language. Return JSON with keys: normalized_message, redaction_report. '. + "redaction_report must include pii_types and language as an ISO 639-1 code such as nl, en, de, fr.\n\n". + "Original question:\n\"\"\"\n{$original}\n\"\"\""; + + try { + $raw = $this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'normalization']); + $decoded = $this->decodeJsonResponse($raw); + + if (is_array($decoded) && ! empty($decoded['normalized_message'])) { + return [ + 'normalized_message' => (string) $decoded['normalized_message'], + 'redaction_report' => [ + 'mode' => 'llm', + 'pii_types' => $decoded['redaction_report']['pii_types'] ?? [], + 'language' => $this->normalizeLanguageCode($decoded['redaction_report']['language'] ?? $fallbackLanguage), + 'notes' => $decoded['redaction_report']['notes'] ?? null, + 'raw' => $decoded, + ], + ]; + } + } catch (\Throwable $e) { + return [ + 'normalized_message' => $fallback['text'], + 'redaction_report' => [ + 'mode' => 'fallback_regex', + 'pii_types' => $fallback['pii_types'], + 'language' => $fallbackLanguage, + 'reason' => 'llm_exception', + 'error' => $e->getMessage(), + ], + ]; + } + + return [ + 'normalized_message' => $fallback['text'], + 'redaction_report' => [ + 'mode' => 'fallback_regex', + 'pii_types' => $fallback['pii_types'], + 'language' => $fallbackLanguage, + 'reason' => 'llm_invalid_json_or_missing_fields', + ], + ]; + } + + private function fallbackNormalize(string $text): array + { + $pii = []; + + // Replace highly-structured values first so looser patterns (phone) do not corrupt them. + $orderedPatterns = [ + 'email' => '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i', + 'iban' => '/\b[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}\b/i', + 'url' => '/https?:\/\/\S+/i', + 'ip' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', + // Require separators to avoid matching long account-like sequences. + 'phone' => '/(? $pattern) { + if (preg_match($pattern, $text) === 1) { + $pii[] = $type; + $text = preg_replace($pattern, '['.strtoupper($type).']', $text) ?? $text; + } + } + + $text = preg_replace('/\s+/', ' ', trim($text)) ?? trim($text); + + return ['text' => $text, 'pii_types' => array_values(array_unique($pii))]; + } + + private function detectLanguage(string $text): string + { + $lower = mb_strtolower($text); + $dutchSignals = [' ik ', ' mijn ', ' een ', ' het ', ' de ', ' hoe ', ' niet ', ' wordt ', ' domeinnaam ', ' website ']; + $englishSignals = [' i ', ' my ', ' the ', ' how ', ' not ', ' website ', ' domain ', ' redirected ']; + + $padded = ' '.$lower.' '; + $nl = 0; + $en = 0; + + foreach ($dutchSignals as $signal) { + $nl += substr_count($padded, $signal); + } + + foreach ($englishSignals as $signal) { + $en += substr_count($padded, $signal); + } + + return $en > $nl ? 'en' : 'nl'; + } + + private function normalizeLanguageCode(mixed $language): string + { + $value = mb_strtolower(trim((string) $language)); + + return match (true) { + str_starts_with($value, 'nl'), str_contains($value, 'dutch'), str_contains($value, 'nederlands') => 'nl', + str_starts_with($value, 'en'), str_contains($value, 'english') => 'en', + str_starts_with($value, 'de'), str_contains($value, 'german'), str_contains($value, 'duits') => 'de', + str_starts_with($value, 'fr'), str_contains($value, 'french'), str_contains($value, 'frans') => 'fr', + default => 'nl', + }; + } + + private function decodeJsonResponse(string $raw): ?array + { + $raw = trim($raw); + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return $decoded; + } + + if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $raw, $matches) === 1) { + $decoded = json_decode(trim($matches[1]), true); + if (is_array($decoded)) { + return $decoded; + } + } + + $start = strpos($raw, '{'); + $end = strrpos($raw, '}'); + if ($start !== false && $end !== false && $end > $start) { + $candidate = substr($raw, $start, $end - $start + 1); + $decoded = json_decode($candidate, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } +} diff --git a/app/Services/TicketProcessingLoggerService.php b/app/Services/TicketProcessingLoggerService.php new file mode 100644 index 0000000..9ba55c8 --- /dev/null +++ b/app/Services/TicketProcessingLoggerService.php @@ -0,0 +1,20 @@ +create([ + 'ticket_id' => $ticket->id, + 'step' => $step, + 'status' => $status, + 'message' => $message, + 'context' => $context === [] ? null : $context, + ]); + } +} diff --git a/app/Services/TicketResultPayloadBuilder.php b/app/Services/TicketResultPayloadBuilder.php new file mode 100644 index 0000000..a5c3ed0 --- /dev/null +++ b/app/Services/TicketResultPayloadBuilder.php @@ -0,0 +1,23 @@ + $result['top_3_candidates'], + 'top_5_candidates' => $result['top_5_candidates'] ?? [], + 'classifier_raw_response' => $result['classifier_raw_response'] ?? null, + 'requested_tool_call' => $result['requested_tool_call'] ?? null, + 'tool_call' => $toolCallRecord?->toArray(), + 'quick_reply' => $quickReply?->only(['id', 'title']), + 'knowledge_gap' => $knowledgeGap, + 'draft_article_suggestion' => $draftSuggestion, + ]; + } +} diff --git a/app/Services/ToolCallRequestValidator.php b/app/Services/ToolCallRequestValidator.php new file mode 100644 index 0000000..3085781 --- /dev/null +++ b/app/Services/ToolCallRequestValidator.php @@ -0,0 +1,39 @@ + $action, + 'parameters' => [ + 'sld' => mb_strtolower($sld), + 'tld' => mb_strtolower($tld), + ], + 'reason' => trim((string) ($toolCall['reason'] ?? '')), + ]; + } +} diff --git a/app/Services/Tools/DomainInfoTool.php b/app/Services/Tools/DomainInfoTool.php new file mode 100644 index 0000000..172a613 --- /dev/null +++ b/app/Services/Tools/DomainInfoTool.php @@ -0,0 +1,47 @@ +validateParameters($parameters); + + return $this->client->request(self::ACTION, [ + 'apiuser' => (string) ($credentials['apiuser'] ?? ''), + 'apipassword' => (string) ($credentials['apipassword'] ?? ''), + 'sld' => $parameters['sld'], + 'tld' => $parameters['tld'], + ]); + } + + public function validateParameters(array $parameters): array + { + $sld = mb_strtolower(trim((string) ($parameters['sld'] ?? ''))); + $tld = mb_strtolower(trim((string) ($parameters['tld'] ?? ''))); + + if ($sld === '' || $tld === '') { + throw new InvalidArgumentException('domain_inf requires both sld and tld parameters.'); + } + + if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $sld) !== 1) { + throw new InvalidArgumentException('domain_inf parameter sld is invalid.'); + } + + if (preg_match('/^[a-z0-9-]{2,63}(?:\.[a-z0-9-]{2,63})*$/', $tld) !== 1) { + throw new InvalidArgumentException('domain_inf parameter tld is invalid.'); + } + + return [ + 'sld' => $sld, + 'tld' => $tld, + ]; + } +} diff --git a/app/Services/Tools/OxxaClient.php b/app/Services/Tools/OxxaClient.php new file mode 100644 index 0000000..a53d323 --- /dev/null +++ b/app/Services/Tools/OxxaClient.php @@ -0,0 +1,70 @@ +get($endpoint, ['command' => $command] + $payload) + ->throw(); + + $xml = simplexml_load_string((string) $response->body(), SimpleXMLElement::class, LIBXML_NOCDATA); + if (! $xml instanceof SimpleXMLElement) { + throw new RuntimeException('Oxxa returned invalid XML.'); + } + + $responseData = $this->xmlToArray($xml); + $statusCode = trim((string) data_get($responseData, 'order.status_code', '')); + $statusDescription = trim((string) data_get($responseData, 'order.status_description', '')); + + if ($statusCode === 'XMLERR 1') { + return [ + 'ok' => false, + 'error' => 'Credentials are incorrect. Please change the credentials in the registrar module.', + 'status_code' => $statusCode, + 'status_description' => $statusDescription, + ]; + } + + if (str_contains($statusCode, 'XMLOK')) { + return [ + 'ok' => true, + 'status_code' => $statusCode, + 'status_description' => $statusDescription, + 'data' => $responseData, + ]; + } + + return [ + 'ok' => false, + 'error' => trim($statusCode.' '.$statusDescription), + 'status_code' => $statusCode, + 'status_description' => $statusDescription, + 'data' => $responseData, + ]; + } + + private function xmlToArray(SimpleXMLElement $xml): array + { + return json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/app/Services/Tools/TicketToolCallService.php b/app/Services/Tools/TicketToolCallService.php new file mode 100644 index 0000000..f511ee7 --- /dev/null +++ b/app/Services/Tools/TicketToolCallService.php @@ -0,0 +1,117 @@ +allowed_actions ?? []; + + if (! in_array($action, $allowedActions, true)) { + return $this->recordSkipped($ticket, $article, $action, $parameters, 'Action is not allowed for the selected article.'); + } + + if ($action !== DomainInfoTool::ACTION) { + return $this->recordSkipped($ticket, $article, $action, $parameters, 'Unsupported tool action.'); + } + + try { + $parameters = $this->domainInfoTool->validateParameters($parameters); + } catch (Throwable $e) { + return $this->recordSkipped($ticket, $article, $action, $parameters, $e->getMessage()); + } + + $credentials = $ticket->api_credentials; + if (! is_array($credentials) || empty($credentials['apiuser']) || empty($credentials['apipassword'])) { + return $this->recordSkipped($ticket, $article, $action, $parameters, 'API credentials are missing for this ticket.'); + } + + $record = TicketToolCall::query()->create([ + 'ticket_id' => $ticket->id, + 'article_id' => $article->id, + 'action' => $action, + 'status' => 'running', + 'parameters' => $parameters, + ]); + + $this->logger->log($ticket, 'tool_call', 'info', 'Toolcall gestart.', [ + 'tool_call_id' => $record->id, + 'action' => $action, + 'parameters' => $parameters, + ]); + + try { + $response = $this->domainInfoTool->execute($parameters, $credentials); + $status = ($response['ok'] ?? false) === true ? 'success' : 'failed'; + + $record->update([ + 'status' => $status, + 'response' => $response, + 'error' => $status === 'failed' ? (string) ($response['error'] ?? 'Tool returned an error.') : null, + 'executed_at' => now(), + ]); + + $this->logger->log($ticket, 'tool_call', $status, 'Toolcall afgerond.', [ + 'tool_call_id' => $record->id, + 'action' => $action, + 'parameters' => $parameters, + 'response' => $response, + ]); + } catch (Throwable $e) { + $record->update([ + 'status' => 'failed', + 'error' => $e->getMessage(), + 'executed_at' => now(), + ]); + + $this->logger->log($ticket, 'tool_call', 'error', 'Toolcall gefaald.', [ + 'tool_call_id' => $record->id, + 'action' => $action, + 'parameters' => $parameters, + 'error' => $e->getMessage(), + ]); + } + + return $record->refresh(); + } + + private function recordSkipped(Ticket $ticket, Article $article, string $action, array $parameters, string $reason): TicketToolCall + { + $record = TicketToolCall::query()->create([ + 'ticket_id' => $ticket->id, + 'article_id' => $article->id, + 'action' => $action !== '' ? $action : 'unknown', + 'status' => 'skipped', + 'parameters' => $parameters, + 'error' => $reason, + 'executed_at' => now(), + ]); + + $this->logger->log($ticket, 'tool_call', 'warning', 'Toolcall overgeslagen.', [ + 'tool_call_id' => $record->id, + 'action' => $record->action, + 'parameters' => $parameters, + 'reason' => $reason, + ]); + + return $record; + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 793d3de..fc94ae6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,5 +1,7 @@ env('DB_CONNECTION', 'sqlite'), 'table' => 'failed_jobs', ], -]; \ No newline at end of file +]; diff --git a/config/services.php b/config/services.php index f30ebf2..4a1e518 100644 --- a/config/services.php +++ b/config/services.php @@ -1,14 +1,20 @@ [ - 'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'), - 'embed_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'), - 'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'), - 'timeout' => (int) env('OLLAMA_TIMEOUT', 30), + 'llm' => [ + 'provider' => env('LLM_PROVIDER', 'ollama'), + 'base_url' => env('LLM_BASE_URL', env('OLLAMA_BASE_URL', 'http://localhost:11434')), + 'embedding_model' => env('LLM_EMBEDDING_MODEL', env('OLLAMA_EMBED_MODEL', 'nomic-embed-text')), + 'chat_model' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')), + 'timeout' => (int) env('LLM_TIMEOUT', env('OLLAMA_TIMEOUT', 30)), + 'ranking_enabled' => filter_var(env('LLM_RANKING_ENABLED', true), FILTER_VALIDATE_BOOL), ], 'embedding' => [ 'dimension' => (int) env('EMBEDDING_DIMENSION', 768), 'queue_embeddings' => filter_var(env('QUEUE_EMBEDDINGS', false), FILTER_VALIDATE_BOOL), ], -]; \ No newline at end of file + 'oxxa' => [ + 'endpoint' => env('OXXA_API_ENDPOINT', ''), + 'timeout' => (int) env('OXXA_TIMEOUT', 60), + ], +]; diff --git a/database/migrations/2026_04_29_000000_enable_pgvector_extension.php b/database/migrations/2026_04_29_000000_enable_pgvector_extension.php index cc0e892..a13ad55 100644 --- a/database/migrations/2026_04_29_000000_enable_pgvector_extension.php +++ b/database/migrations/2026_04_29_000000_enable_pgvector_extension.php @@ -3,7 +3,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { DB::statement('CREATE EXTENSION IF NOT EXISTS vector'); @@ -13,4 +14,4 @@ return new class extends Migration { { DB::statement('DROP EXTENSION IF EXISTS vector'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_000100_create_articles_table.php b/database/migrations/2026_04_29_000100_create_articles_table.php index cf9bfc9..ff9ffd3 100644 --- a/database/migrations/2026_04_29_000100_create_articles_table.php +++ b/database/migrations/2026_04_29_000100_create_articles_table.php @@ -5,7 +5,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('articles', function (Blueprint $table) { @@ -24,4 +25,4 @@ return new class extends Migration { { Schema::dropIfExists('articles'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_000200_create_tickets_table.php b/database/migrations/2026_04_29_000200_create_tickets_table.php index 7acff02..79edabc 100644 --- a/database/migrations/2026_04_29_000200_create_tickets_table.php +++ b/database/migrations/2026_04_29_000200_create_tickets_table.php @@ -5,7 +5,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('tickets', function (Blueprint $table) { @@ -23,4 +24,4 @@ return new class extends Migration { { Schema::dropIfExists('tickets'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_000300_create_embedding_cache_table.php b/database/migrations/2026_04_29_000300_create_embedding_cache_table.php index ae39d8f..c9542c2 100644 --- a/database/migrations/2026_04_29_000300_create_embedding_cache_table.php +++ b/database/migrations/2026_04_29_000300_create_embedding_cache_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('embedding_cache', function (Blueprint $table) { @@ -20,4 +21,4 @@ return new class extends Migration { { Schema::dropIfExists('embedding_cache'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_000400_create_ai_decisions_table.php b/database/migrations/2026_04_29_000400_create_ai_decisions_table.php index b0672a8..4c440f8 100644 --- a/database/migrations/2026_04_29_000400_create_ai_decisions_table.php +++ b/database/migrations/2026_04_29_000400_create_ai_decisions_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('ai_decisions', function (Blueprint $table) { @@ -22,4 +23,4 @@ return new class extends Migration { { Schema::dropIfExists('ai_decisions'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_000500_create_feedback_table.php b/database/migrations/2026_04_29_000500_create_feedback_table.php index 4848731..b38cb78 100644 --- a/database/migrations/2026_04_29_000500_create_feedback_table.php +++ b/database/migrations/2026_04_29_000500_create_feedback_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('feedback', function (Blueprint $table) { @@ -21,4 +22,4 @@ return new class extends Migration { { Schema::dropIfExists('feedback'); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_04_29_001000_add_categories_and_article_metadata.php b/database/migrations/2026_04_29_001000_add_categories_and_article_metadata.php index e938271..84fdde3 100644 --- a/database/migrations/2026_04_29_001000_add_categories_and_article_metadata.php +++ b/database/migrations/2026_04_29_001000_add_categories_and_article_metadata.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('categories', function (Blueprint $table) { diff --git a/database/migrations/2026_04_29_001100_add_ticket_pipeline_status_and_logs.php b/database/migrations/2026_04_29_001100_add_ticket_pipeline_status_and_logs.php new file mode 100644 index 0000000..eb42c54 --- /dev/null +++ b/database/migrations/2026_04_29_001100_add_ticket_pipeline_status_and_logs.php @@ -0,0 +1,44 @@ +string('status')->default('queued')->after('embedding'); + $table->foreignId('best_article_id')->nullable()->after('status')->constrained('articles')->nullOnDelete(); + $table->float('confidence')->nullable()->after('best_article_id'); + $table->text('explanation')->nullable()->after('confidence'); + $table->json('result_payload')->nullable()->after('explanation'); + $table->text('error_message')->nullable()->after('result_payload'); + $table->timestamp('processed_at')->nullable()->after('error_message'); + $table->index('status'); + }); + + Schema::create('ticket_processing_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $table->string('step', 100); + $table->string('status', 30)->default('info'); + $table->text('message')->nullable(); + $table->json('context')->nullable(); + $table->timestamps(); + + $table->index(['ticket_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_processing_logs'); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropConstrainedForeignId('best_article_id'); + $table->dropColumn(['status', 'confidence', 'explanation', 'result_payload', 'error_message', 'processed_at']); + }); + } +}; diff --git a/database/migrations/2026_04_29_001200_add_ticket_normalization_columns.php b/database/migrations/2026_04_29_001200_add_ticket_normalization_columns.php new file mode 100644 index 0000000..9d3b27f --- /dev/null +++ b/database/migrations/2026_04_29_001200_add_ticket_normalization_columns.php @@ -0,0 +1,23 @@ +text('normalized_message')->nullable()->after('message'); + $table->json('redaction_report')->nullable()->after('normalized_message'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['normalized_message', 'redaction_report']); + }); + } +}; diff --git a/database/migrations/2026_04_29_001300_add_support_reply_to_tickets.php b/database/migrations/2026_04_29_001300_add_support_reply_to_tickets.php new file mode 100644 index 0000000..7a6ecf0 --- /dev/null +++ b/database/migrations/2026_04_29_001300_add_support_reply_to_tickets.php @@ -0,0 +1,22 @@ +text('support_reply')->nullable()->after('explanation'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn('support_reply'); + }); + } +}; diff --git a/database/migrations/2026_04_29_001400_create_settings_table.php b/database/migrations/2026_04_29_001400_create_settings_table.php new file mode 100644 index 0000000..f15a3ba --- /dev/null +++ b/database/migrations/2026_04_29_001400_create_settings_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('key')->unique(); + $table->longText('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/database/migrations/2026_04_29_002000_add_knowledge_gap_fields.php b/database/migrations/2026_04_29_002000_add_knowledge_gap_fields.php new file mode 100644 index 0000000..841223c --- /dev/null +++ b/database/migrations/2026_04_29_002000_add_knowledge_gap_fields.php @@ -0,0 +1,35 @@ +string('status')->default('published')->after('content'); + $table->boolean('is_ai_draft')->default(false)->after('status'); + $table->foreignId('source_ticket_id')->nullable()->after('subcategory_id')->constrained('tickets')->nullOnDelete(); + }); + + Schema::table('tickets', function (Blueprint $table) { + $table->boolean('needs_article_draft')->default(false)->after('support_reply'); + $table->foreignId('draft_article_id')->nullable()->after('needs_article_draft')->constrained('articles')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropConstrainedForeignId('draft_article_id'); + $table->dropColumn('needs_article_draft'); + }); + + Schema::table('articles', function (Blueprint $table) { + $table->dropConstrainedForeignId('source_ticket_id'); + $table->dropColumn(['status', 'is_ai_draft']); + }); + } +}; diff --git a/database/migrations/2026_04_29_002100_create_article_chunks_table.php b/database/migrations/2026_04_29_002100_create_article_chunks_table.php new file mode 100644 index 0000000..9521408 --- /dev/null +++ b/database/migrations/2026_04_29_002100_create_article_chunks_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('chunk_index'); + $table->text('content'); + $table->timestamps(); + $table->unique(['article_id', 'chunk_index']); + }); + + $dimension = (int) config('services.embedding.dimension', 768); + DB::statement("ALTER TABLE article_chunks ADD COLUMN embedding vector({$dimension})"); + DB::statement('CREATE INDEX article_chunks_embedding_cosine_idx ON article_chunks USING ivfflat (embedding vector_cosine_ops)'); + DB::statement('CREATE INDEX article_chunks_article_id_idx ON article_chunks(article_id)'); + } + + public function down(): void + { + Schema::dropIfExists('article_chunks'); + } +}; diff --git a/database/migrations/2026_04_29_002200_make_embeddings_model_aware.php b/database/migrations/2026_04_29_002200_make_embeddings_model_aware.php new file mode 100644 index 0000000..9fc219a --- /dev/null +++ b/database/migrations/2026_04_29_002200_make_embeddings_model_aware.php @@ -0,0 +1,52 @@ +string('provider_instance_id')->nullable()->after('id'); + $table->string('embedding_model')->nullable()->after('provider_instance_id'); + $table->dropUnique(['text_hash']); + $table->unique(['provider_instance_id', 'embedding_model', 'text_hash'], 'embedding_cache_model_text_unique'); + }); + + Schema::table('article_chunks', function (Blueprint $table) { + $table->string('embedding_provider_instance_id')->nullable()->after('embedding'); + $table->string('embedding_model')->nullable()->after('embedding_provider_instance_id'); + $table->timestamp('embedded_at')->nullable()->after('embedding_model'); + $table->index(['embedding_provider_instance_id', 'embedding_model'], 'article_chunks_embedding_model_idx'); + }); + + Schema::table('tickets', function (Blueprint $table) { + $table->string('embedding_provider_instance_id')->nullable()->after('embedding'); + $table->string('embedding_model')->nullable()->after('embedding_provider_instance_id'); + $table->timestamp('embedded_at')->nullable()->after('embedding_model'); + }); + + DB::table('embedding_cache')->truncate(); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['embedding_provider_instance_id', 'embedding_model', 'embedded_at']); + }); + + Schema::table('article_chunks', function (Blueprint $table) { + $table->dropIndex('article_chunks_embedding_model_idx'); + $table->dropColumn(['embedding_provider_instance_id', 'embedding_model', 'embedded_at']); + }); + + Schema::table('embedding_cache', function (Blueprint $table) { + $table->dropUnique('embedding_cache_model_text_unique'); + $table->unique('text_hash'); + $table->dropColumn(['provider_instance_id', 'embedding_model']); + }); + } +}; diff --git a/database/migrations/2026_04_29_002300_add_article_actions_and_ticket_tool_calls.php b/database/migrations/2026_04_29_002300_add_article_actions_and_ticket_tool_calls.php new file mode 100644 index 0000000..043d92d --- /dev/null +++ b/database/migrations/2026_04_29_002300_add_article_actions_and_ticket_tool_calls.php @@ -0,0 +1,49 @@ +text('note')->nullable()->after('content'); + $table->json('allowed_actions')->nullable()->after('note'); + }); + + Schema::table('tickets', function (Blueprint $table) { + $table->text('api_credentials')->nullable()->after('result_payload'); + }); + + Schema::create('ticket_tool_calls', function (Blueprint $table) { + $table->id(); + $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $table->foreignId('article_id')->nullable()->constrained('articles')->nullOnDelete(); + $table->string('action', 100); + $table->string('status', 30)->default('pending'); + $table->json('parameters')->nullable(); + $table->json('response')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('executed_at')->nullable(); + $table->timestamps(); + + $table->index(['ticket_id', 'created_at']); + $table->index(['action', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ticket_tool_calls'); + + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn('api_credentials'); + }); + + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn(['note', 'allowed_actions']); + }); + } +}; diff --git a/database/migrations/2026_04_30_000100_create_quick_replies_tables.php b/database/migrations/2026_04_30_000100_create_quick_replies_tables.php new file mode 100644 index 0000000..ab45f18 --- /dev/null +++ b/database/migrations/2026_04_30_000100_create_quick_replies_tables.php @@ -0,0 +1,36 @@ +id(); + $table->string('title'); + $table->text('content'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index('is_active'); + }); + + Schema::create('article_quick_reply', function (Blueprint $table) { + $table->id(); + $table->foreignId('article_id')->constrained()->cascadeOnDelete(); + $table->foreignId('quick_reply_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['article_id', 'quick_reply_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_quick_reply'); + Schema::dropIfExists('quick_replies'); + } +}; diff --git a/database/seeders/ArticleSeeder.php b/database/seeders/ArticleSeeder.php index 12874e8..60b38cc 100644 --- a/database/seeders/ArticleSeeder.php +++ b/database/seeders/ArticleSeeder.php @@ -21,4 +21,4 @@ class ArticleSeeder extends Seeder Article::query()->create($article); } } -} \ No newline at end of file +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 18f6038..5ccb373 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,4 +12,4 @@ class DatabaseSeeder extends Seeder ArticleSeeder::class, ]); } -} \ No newline at end of file +} diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 60895b6..eddf670 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -13,9 +13,11 @@ server { fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass app:9000; fastcgi_index index.php; + fastcgi_read_timeout 300; + fastcgi_send_timeout 300; } location ~ /\.ht { deny all; } -} \ No newline at end of file +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..56f2794 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,17 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon + +parameters: + + paths: + - app/ + + # Level 10 is the highest level + level: 5 + +# ignoreErrors: +# - '#PHPDoc tag @var#' +# +# excludePaths: +# - ./*/*/FileToBeExcluded.php diff --git a/resources/views/admin/process.blade.php b/resources/views/admin/process.blade.php new file mode 100644 index 0000000..2f57fb2 --- /dev/null +++ b/resources/views/admin/process.blade.php @@ -0,0 +1,196 @@ + +
+
+
+
+

Hoe werkt de Ticket Assistant?

+

+ Zie het systeem als een supportmedewerker met een hele grote map handleidingen. Eerst maakt hij + de vraag netjes leesbaar, daarna zoekt hij de beste stukjes uitleg, en daarna schrijft hij een + kort advies dat een supportmedewerker kan controleren. +

+
+
+ Draait lokaal met je eigen kennisbank en je eigen AI-model +
+
+
+ +
+
+

Wat gebeurt er met een ticket?

+
+
+
1. De vraag komt binnen
+

+ Een klantvraag komt binnen via de API of via het handmatige testveld in het admin scherm. + Het ticket komt eerst in de wachtrij, zodat de zware stappen op de achtergrond kunnen lopen. +

+
+
+
2. De vraag wordt netjes gemaakt
+

+ De AI haalt ruis, typefouten en privegegevens weg. Denk aan: naam, adres, IBAN of andere + gegevens die niet nodig zijn om het probleem op te lossen. +

+
+
+
3. De betekenis wordt omgezet naar een zoekcode
+

+ De app maakt van de nette vraag een soort getallen-code. Die code beschrijft niet de exacte + woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen vinden + waarin "e-mail storing" staat. +

+
+
+
4. De kennisbank wordt doorzocht
+

+ Artikelen zijn opgeknipt in kleine tekstkaartjes. De app zoekt de kaartjes die het meest + lijken op de klantvraag en groepeert die daarna terug naar artikelen. +

+
+
+
5. De AI kiest het beste artikel
+

+ De AI krijgt de beste kandidaten te zien en kiest welk artikel het beste past. Daarbij geeft + hij ook aan hoe zeker hij is en waarom hij dat artikel kiest. +

+
+
+
6. Eventueel wordt extra informatie opgehaald
+

+ Sommige artikelen mogen extra hulpmiddelen gebruiken. Voor nu is dat alleen domeininformatie + ophalen. Dat gebeurt alleen als het artikel dit toestaat en de vraag genoeg gegevens bevat. +

+
+
+
7. Er komt een advies of een melding
+

+ Als het artikel een snelantwoord heeft, gebruikt de app dat direct. Anders maakt de AI een + korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord maar + meldt hij dat de kennisbank iets mist. +

+
+
+
+ +
+

Hoe werkt de kennisbank?

+
+
+
Artikelen zijn de handleidingen
+

+ Elk artikel is een uitleg die support kan gebruiken. Artikelen kunnen uit de externe + helpdesk komen of handmatig worden aangemaakt. +

+
+
+
Artikelen worden in kleine kaartjes geknipt
+

+ Een lang artikel kan over meerdere dingen gaan. Daarom knippen we het in kleine stukken. + Zo kan de app precies het stukje vinden dat bij de vraag hoort. +

+
+
+
Elk kaartje krijgt een betekenis-code
+

+ Die code helpt zoeken op betekenis. Het gaat dus niet alleen om dezelfde woorden, maar om + dezelfde bedoeling. +

+
+
+
Bij een nieuw AI-model opnieuw indexeren
+

+ Als je het model wijzigt dat deze betekenis-codes maakt, moeten de kaartjes opnieuw worden + berekend. Dat kan via Settings bij Embeddings. +

+
+
+
+
+ +
+
+

Wat zie je op een ticket?

+

+ Je ziet de originele vraag, de opgeschoonde vraag, de gekozen artikelen, eventuele hulpmiddelen die + gebruikt zijn, fouten, en alle stappen in volgorde. De nieuwste stap staat bovenaan. +

+
+
+

Wanneer komt er geen antwoord?

+

+ Als de app geen passend artikel vindt, maakt hij bewust geen reactie naar de klant. Dan zie je een + melding dat de kennisbank tekortschiet, plus een suggestie voor wat er mist. +

+
+
+

Waarvoor zijn artikelnotities?

+

+ Een artikelnotitie is een interne tip voor de AI. Bijvoorbeeld: "Gebruik dit artikel alleen voor + domeinen" of "Vraag eerst om klantnummer als dit ontbreekt". De notitie is geen klanttekst. +

+
+
+ +
+
+

Snelantwoorden

+

+ Sommige artikelen hebben uiteindelijk hetzelfde antwoord nodig. Dan kun je een snelantwoord maken en + aan meerdere artikelen koppelen. Als zo'n artikel wordt gekozen, gebruikt de app het snelantwoord en + hoeft de AI geen nieuwe klantreactie te schrijven. +

+
+ +
+

Hulpmiddelen en allowed actions

+

+ Sommige antwoorden worden beter als de app iets kan nakijken. Daarom kan een artikel aangeven welke + hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie controleert + daarna zelf of het echt mag. +

+
+
Nu beschikbaar: domain_inf
+

+ Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf` toestaat, + als de vraag een domeinnaam bevat, en als er API-gegevens op het ticket zijn opgeslagen. +

+
+
+ +
+

Woordenlijst

+
+
Embedding: een betekenis-code van tekst.
+
Chunk: een klein stukje van een artikel.
+
Confidence: hoe zeker de AI is van zijn keuze.
+
Knowledge gap: de kennisbank heeft waarschijnlijk nog geen goed artikel voor deze vraag.
+
Toolcall: een gecontroleerde actie waarmee de app extra informatie kan ophalen.
+
+
+
+ +
+

Voorbeeld in gewone taal

+
+
+
Klant vraagt
+

"Mijn mail doet het niet op mijn domein."

+
+
+
App zoekt
+

De app zoekt kaartjes over mail, domeinen en instellingen.

+
+
+
AI kiest
+

De AI kiest het artikel dat het meest bruikbaar lijkt.

+
+
+
Support controleert
+

Support ziet het advies, de reden en de gebruikte stappen.

+
+
+
+
+
diff --git a/resources/views/admin/quick-replies.blade.php b/resources/views/admin/quick-replies.blade.php new file mode 100644 index 0000000..70bdb40 --- /dev/null +++ b/resources/views/admin/quick-replies.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/admin/settings.blade.php b/resources/views/admin/settings.blade.php new file mode 100644 index 0000000..4399538 --- /dev/null +++ b/resources/views/admin/settings.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/admin/ticket-show.blade.php b/resources/views/admin/ticket-show.blade.php new file mode 100644 index 0000000..2a20fdb --- /dev/null +++ b/resources/views/admin/ticket-show.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/components/admin/timeline-card.blade.php b/resources/views/components/admin/timeline-card.blade.php new file mode 100644 index 0000000..b4bf057 --- /dev/null +++ b/resources/views/components/admin/timeline-card.blade.php @@ -0,0 +1,27 @@ +@props([ + 'number' => null, + 'title', + 'description' => null, + 'badge' => null, +]) + +
+
+ {{ $number }} +
+
+
+
+
{{ $title }}
+ @if($description) +

{{ $description }}

+ @endif +
+ @if($badge) + {{ $badge }} + @endif +
+ + {{ $slot }} +
+
diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 61a0046..b854dd0 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -15,7 +15,10 @@ diff --git a/resources/views/livewire/admin/article-manager.blade.php b/resources/views/livewire/admin/article-manager.blade.php index 8e33185..9696d96 100644 --- a/resources/views/livewire/admin/article-manager.blade.php +++ b/resources/views/livewire/admin/article-manager.blade.php @@ -9,6 +9,16 @@ @error('title')

{{ $message }}

@enderror @error('content')

{{ $message }}

@enderror + + @error('note')

{{ $message }}

@enderror + + @error('allowedActions.*')

{{ $message }}

@enderror @@ -18,8 +28,84 @@
@foreach($articles as $article)
-
#{{ $article->id }} {{ $article->title }}
+
+
+
#{{ $article->id }} {{ $article->title }}
+ @if(($article->status ?? 'published') === 'draft') + Concept (AI) + @endif +
+
+ @if(($article->status ?? 'published') === 'draft') + + @endif + +
+
+ @if($article->note) +
+ LLM note: {{ \Illuminate\Support\Str::limit($article->note, 180) }} +
+ @endif + @if(($article->allowed_actions ?? []) !== []) +
+ @foreach($article->allowed_actions as $action) + {{ $action }} + @endforeach +
+ @endif
{{ \Illuminate\Support\Str::limit($article->content, 140) }}
+
+ + + @error("articleNotes.{$article->id}")

{{ $message }}

@enderror + + @error("articleAllowedActions.{$article->id}.*")

{{ $message }}

@enderror +
+ + @if($quickReplyOptions->isEmpty()) +

Nog geen actieve snelantwoorden beschikbaar.

+ @else +
+ @foreach($quickReplyOptions as $quickReply) + + @endforeach +
+ @endif + @error("articleQuickReplies.{$article->id}.*")

{{ $message }}

@enderror +
+ +
@endforeach
diff --git a/resources/views/livewire/admin/quick-reply-manager.blade.php b/resources/views/livewire/admin/quick-reply-manager.blade.php new file mode 100644 index 0000000..f116fed --- /dev/null +++ b/resources/views/livewire/admin/quick-reply-manager.blade.php @@ -0,0 +1,77 @@ +
+
+

Nieuw snelantwoord

+

+ Gebruik dit voor vaste antwoorden die bij meerdere kennisbankartikelen passen. Als een gekozen artikel een + actief snelantwoord heeft, wordt er geen AI-antwoord meer gegenereerd. +

+ + @if (session('success')) +
{{ session('success') }}
+ @endif + +
+ + @error('title')

{{ $message }}

@enderror + + + @error('content')

{{ $message }}

@enderror + + + + +
+
+ +
+

Snelantwoorden

+
+ @foreach($quickReplies as $quickReply) +
+
+
+
#{{ $quickReply->id }} {{ $quickReply->title }}
+
+ Gekoppeld aan {{ $quickReply->articles_count }} artikel(en) +
+
+ +
+ +
+ + @error("editRows.{$quickReply->id}.title")

{{ $message }}

@enderror + + + @error("editRows.{$quickReply->id}.content")

{{ $message }}

@enderror + + + + +
+
+ @endforeach +
+ +
{{ $quickReplies->links() }}
+
+
diff --git a/resources/views/livewire/admin/settings-page.blade.php b/resources/views/livewire/admin/settings-page.blade.php new file mode 100644 index 0000000..7020d9f --- /dev/null +++ b/resources/views/livewire/admin/settings-page.blade.php @@ -0,0 +1,300 @@ +
+
+
+
+

AI Settings

+

Beheer prompts, providers en modellen per stap in de pipeline.

+
+ +
+ + @if (session('saved')) +
{{ session('saved') }}
+ @endif + +
+ @foreach(['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label) + + @endforeach +
+
+ +
+ @if($activeTab === 'process') +
+
+

Proces & prompts

+

Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt, kun je die hier aanpassen.

+
+ +
+ + +
+ +
+ @foreach($processSteps as $step) + + @if(isset($step['prompt_key'])) + + + @error('promptValues.'.$step['id'])

{{ $message }}

@enderror + @endif +
+ @endforeach +
+
+ @endif + + @if($activeTab === 'providers') +
+
+
+

LLM provider instances

+

Voeg meerdere Ollama- of LM Studio-instances toe en kies welke instance actief is.

+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ @foreach($providerInstances as $index => $instance) + @php($type = $instance['type'] ?? 'lmstudio') + @php($definition = $providerDefinitions[$type] ?? ['label' => $type, 'description' => '']) +
+
+
+
{{ $instance['name'] ?? 'Provider' }}
+

{{ $definition['description'] }}

+
+
+ @if($activeProviderInstanceId === ($instance['id'] ?? null)) + Actief + @else + + @endif + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ @endforeach +
+
+ @endif + + @if($activeTab === 'models') +
+
+
+

Modellen per stap

+

Kies per stap een model van de actieve instance. De lijst wordt 5 minuten gecachet.

+
+ +
+ + @if($modelLoadError) +
+ Modellen konden niet live worden opgehaald: {{ $modelLoadError }} +
+ @elseif($availableModels === []) +
+ Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam invullen. +
+ @else +
+ {{ count($availableModels) }} modellen gevonden op de actieve instance. +
+ @endif + +
+ Actieve instance: + @foreach($providerInstances as $instance) + @if(($instance['id'] ?? null) === $activeProviderInstanceId) + {{ $instance['name'] }} ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }}) + @endif + @endforeach +
+ +
+ @foreach($modelTasks as $task) + + @if($availableModels !== []) + + @else + + @endif + @error('modelValues.'.$task['id'])

{{ $message }}

@enderror +
+ @endforeach +
+
+ @endif + + @if($activeTab === 'embeddings') +
+
+
+

Chunk embeddings

+

Hergenereer embeddings voor kennisbankchunks. Dit draait via de queue en gebruikt de actieve embedding-provider en het embeddingmodel.

+
+ +
+ +
+
+
Artikelen
+
{{ $embeddingStats['articles'] ?? 0 }}
+

{{ $embeddingStats['articles_without_chunks'] ?? 0 }} zonder chunks

+
+
+
Chunks
+
{{ $embeddingStats['chunks'] ?? 0 }}
+

{{ $embeddingStats['chunks_without_embedding'] ?? 0 }} zonder embedding

+
+
+
Chunks met embedding
+
{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}
+

{{ $embeddingStats['articles_with_chunks'] ?? 0 }} artikelen geindexeerd

+
+
+ +
+
Actieve embedding context
+
+
+ Provider instance: + {{ $embeddingStats['active_provider_instance_id'] ?? '-' }} +
+
+ Embedding model: + {{ $embeddingStats['active_embedding_model'] ?? '-' }} +
+
+

+ {{ $embeddingStats['current_embedding_chunks'] ?? 0 }} chunks passen bij het actieve embeddingmodel. + {{ $embeddingStats['stale_or_other_model_chunks'] ?? 0 }} chunks zijn leeg, oud of voor een ander model. +

+
+ +
+
+
+

Alleen ontbrekende chunks genereren

+

Plaats alleen artikelen zonder chunks opnieuw in de queue.

+
+ +
+ +
+
+

Alles opnieuw genereren

+

Plaats alle artikelen opnieuw in de queue. Bestaande chunks worden per artikel vervangen tijdens verwerking.

+
+ +
+
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/ticket-monitor.blade.php b/resources/views/livewire/admin/ticket-monitor.blade.php index d795112..d3b4aeb 100644 --- a/resources/views/livewire/admin/ticket-monitor.blade.php +++ b/resources/views/livewire/admin/ticket-monitor.blade.php @@ -1,30 +1,65 @@ -
-
-

Tickets + AI Decisions

-
- - -
+
+
+

Ticket simulatie (handmatig inschieten)

+ + @if($submitError) +
{{ $submitError }}
+ @endif + + @if($lastResult) +
+ Ticket #{{ $lastResult['ticket_id'] }} aangemaakt met status '{{ $lastResult['status'] }}'. + Bekijk voortgang +
+ @endif + +
+ + @error('newTicketMessage')

{{ $message }}

@enderror +
+
+ + @error('apiUser')

{{ $message }}

@enderror +
+
+ + @error('apiPassword')

{{ $message }}

@enderror +
+
+

Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt voor toegestane toolcalls.

+ +
-
- @foreach($tickets as $ticket) -
-
Ticket #{{ $ticket->id }}
-
{{ $ticket->message }}
- @php($decision = $ticket->decisions->first()) - @if($decision) -
Article: #{{ $decision->article_id ?? 'N/A' }} | Confidence: {{ number_format($decision->confidence, 2) }}
-
{{ $decision->explanation }}
- @else -
Nog geen AI beslissing.
- @endif +
+
+

Tickets + status

+
+ +
- @endforeach +
+ +
+ @foreach($tickets as $ticket) +
+
+
Ticket #{{ $ticket->id }}
+ {{ $ticket->status }} +
+
{{ $ticket->message }}
+ @if($ticket->bestArticle) +
Article: #{{ $ticket->bestArticle->id }} | Confidence: {{ number_format((float) $ticket->confidence, 2) }}
+
{{ $ticket->explanation }}
+ @endif + Bekijk detail/progress +
+ @endforeach +
+
{{ $tickets->links() }}
-
{{ $tickets->links() }}
diff --git a/resources/views/livewire/admin/ticket-show.blade.php b/resources/views/livewire/admin/ticket-show.blade.php new file mode 100644 index 0000000..90925d3 --- /dev/null +++ b/resources/views/livewire/admin/ticket-show.blade.php @@ -0,0 +1,147 @@ +
+
+
+

Ticket #{{ $ticket->id }}

+
+ + Status: {{ $ticket->status }} +
+
+ + @if (session('success')) +
{{ session('success') }}
+ @endif + + @if($ticket->needs_article_draft) +
+
Kennisbank-gat gedetecteerd
+

+ Er is geen geschikt artikel in de kennisbank gevonden voor deze vraag. +

+ @php($suggestion = $ticket->result_payload['draft_article_suggestion'] ?? null) + @if(is_array($suggestion)) +
+

Voorgestelde titel

+

{{ $suggestion['title'] ?? '-' }}

+
+
+

Voorgestelde inhoud

+
{{ $suggestion['content'] ?? '-' }}
+
+ @endif +
+ @endif + + @if($ticket->support_reply && !$ticket->needs_article_draft) +
+
Concept reactie voor klant
+ @if(is_array($ticket->result_payload['quick_reply'] ?? null)) +
+ Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }} {{ $ticket->result_payload['quick_reply']['title'] ?? '' }} +
+ @endif +
{{ $ticket->support_reply }}
+
+ @endif + +

Origineel: {{ $ticket->message }}

+ @if($ticket->normalized_message) +

Genormaliseerd: {{ $ticket->normalized_message }}

+ @endif + @if($ticket->redaction_report) +
{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}
+ @endif + @if(is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser'])) +
+ API credentials aanwezig voor deze ticket-run. Waarden worden niet getoond. +
+ @endif + @if($ticket->error_message) +
{{ $ticket->error_message }}
+ @endif + @if($ticket->bestArticle) +
+ Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }} + @if($ticket->confidence !== null) + (confidence {{ number_format($ticket->confidence, 2) }}) + @endif +
+ @endif +
+ +
+

Toolcalls

+ @if($ticket->toolCalls->isEmpty()) +

Geen toolcalls uitgevoerd of voorgesteld.

+ @else +
+ @foreach($ticket->toolCalls as $toolCall) +
+
+
+ {{ $toolCall->action }} ({{ $toolCall->status }}) + @if($toolCall->article) + via artikel #{{ $toolCall->article->id }} + @endif +
+ {{ $toolCall->executed_at ?? $toolCall->created_at }} +
+ @if($toolCall->parameters) +
+
Parameters
+
{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}
+
+ @endif + @if($toolCall->response) +
+
Response
+
{{ json_encode($toolCall->response, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}
+
+ @endif + @if($toolCall->error) +
{{ $toolCall->error }}
+ @endif +
+ @endforeach +
+ @endif +
+ +
+

Verwerkingsstappen

+ @php($orderedLogs = $ticket->logs->sortBy([['created_at', 'desc'], ['id', 'desc']])) + @php($latestLog = $orderedLogs->first()) + @if($ticket->status === 'processing' && $latestLog) +
+ Huidige stap: {{ $latestLog->step }} + + + Bezig + +
+ @endif +
+ @foreach($orderedLogs as $log) +
+
+ {{ $log->step }} ({{ $log->status }}) + {{ $log->created_at }} +
+ @if($log->message) +
{{ $log->message }}
+ @endif + @if($log->context) +
{{ json_encode($log->context, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}
+ @endif +
+ @endforeach +
+
+
diff --git a/routes/api.php b/routes/api.php index a6c2ecf..938da1a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,4 +6,4 @@ use Illuminate\Support\Facades\Route; Route::get('/articles', [ArticleController::class, 'index']); Route::post('/articles', [ArticleController::class, 'store']); -Route::post('/tickets', [TicketController::class, 'store']); \ No newline at end of file +Route::post('/tickets', [TicketController::class, 'store']); diff --git a/routes/web.php b/routes/web.php index 04f35b8..d1027d4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ group(function () { Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard'); Route::view('/articles', 'admin.articles')->name('admin.articles'); + Route::view('/quick-replies', 'admin.quick-replies')->name('admin.quick-replies'); Route::view('/tickets', 'admin.tickets')->name('admin.tickets'); + Route::get('/tickets/{ticket}', [AdminTicketController::class, 'show'])->name('admin.tickets.show'); + Route::view('/process', 'admin.process')->name('admin.process'); + Route::view('/settings', 'admin.settings')->name('admin.settings'); }); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8364a84..8973927 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -14,6 +14,6 @@ class ExampleTest extends TestCase { $response = $this->get('/'); - $response->assertStatus(200); + $response->assertRedirect(route('admin.dashboard')); } } diff --git a/tests/Unit/AIClassifierServiceTest.php b/tests/Unit/AIClassifierServiceTest.php new file mode 100644 index 0000000..5a427cc --- /dev/null +++ b/tests/Unit/AIClassifierServiceTest.php @@ -0,0 +1,84 @@ +prompt = $prompt; + + return json_encode([ + 'article_id' => 42, + 'confidence' => 0.91, + 'explanation' => 'Past bij domeininformatie.', + 'tool_call' => [ + 'action' => 'domain_inf', + 'parameters' => ['sld' => 'Example', 'tld' => 'NL'], + 'reason' => 'Domeinstatus is nodig.', + ], + ]); + } + }; + + $settings = new class extends AppSettingsService + { + public function getPrompt(string $key, ?string $default = null): ?string + { + return 'Select best article.'; + } + + public function get(string $key, ?string $default = null): ?string + { + return $default; + } + }; + + $service = new AIClassifierService( + $client, + $settings, + new ClassifierPromptBuilder, + new LlmJsonDecoder, + new ToolCallRequestValidator + ); + $result = $service->rank('Hoe staat example.nl ingesteld?', [ + new ArticleCandidateDTO( + articleId: 42, + title: 'Domein controleren', + content: 'Controleer domeininformatie.', + distance: 0.12, + note: 'Gebruik domain_inf wanneer een volledig domein genoemd wordt.', + allowedActions: ['domain_inf'], + ), + ]); + + $this->assertSame(42, $result->articleId); + $this->assertSame([ + 'action' => 'domain_inf', + 'parameters' => ['sld' => 'example', 'tld' => 'nl'], + 'reason' => 'Domeinstatus is nodig.', + ], $result->toolCall); + $this->assertStringContainsString('Allowed actions: ["domain_inf"]', $client->prompt); + $this->assertStringContainsString('Internal note for support assistant', $client->prompt); + } +} diff --git a/tests/Unit/DomainInfoToolTest.php b/tests/Unit/DomainInfoToolTest.php new file mode 100644 index 0000000..6953920 --- /dev/null +++ b/tests/Unit/DomainInfoToolTest.php @@ -0,0 +1,33 @@ +validateParameters([ + 'sld' => 'Example-Domain', + 'tld' => 'NL', + ]); + + $this->assertSame(['sld' => 'example-domain', 'tld' => 'nl'], $parameters); + } + + public function test_it_rejects_missing_domain_parameters(): void + { + $tool = new DomainInfoTool(new OxxaClient); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('domain_inf requires both sld and tld parameters.'); + + $tool->validateParameters(['sld' => 'example']); + } +} diff --git a/tests/Unit/OxxaClientTest.php b/tests/Unit/OxxaClientTest.php new file mode 100644 index 0000000..9611f78 --- /dev/null +++ b/tests/Unit/OxxaClientTest.php @@ -0,0 +1,43 @@ +set('services.oxxa.endpoint', 'https://api.example.test/'); + config()->set('services.oxxa.timeout', 5); + + Http::fake([ + 'api.example.test/*' => Http::response( + 'XMLOK 0OKexample.nl', + 200, + ['Content-Type' => 'application/xml'] + ), + ]); + + $result = (new OxxaClient)->request('domain_inf', [ + 'apiuser' => 'demo', + 'apipassword' => 'secret', + 'sld' => 'example', + 'tld' => 'nl', + ]); + + $this->assertTrue($result['ok']); + $this->assertSame('XMLOK 0', $result['status_code']); + + Http::assertSent(function ($request) { + $url = (string) $request->url(); + + return str_contains($url, 'command=domain_inf') + && str_contains($url, 'apiuser=demo') + && str_contains($url, 'apipassword=MD5'.md5('secret')) + && ! str_contains($url, 'apipassword=secret'); + }); + } +}