diff --git a/app/Http/Controllers/Api/NearestArticleController.php b/app/Http/Controllers/Api/NearestArticleController.php new file mode 100644 index 0000000..22b7bee --- /dev/null +++ b/app/Http/Controllers/Api/NearestArticleController.php @@ -0,0 +1,81 @@ +validate([ + 'query' => ['required', 'string', 'min:2', 'max:1000'], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:20'], + 'min_similarity' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'include_content' => ['sometimes', 'boolean'], + ]); + + $query = trim($validated['query']); + $limit = (int) ($validated['limit'] ?? 5); + $minSimilarity = (float) ($validated['min_similarity'] ?? 0); + $includeContent = $request->boolean('include_content', false); + + try { + $embedding = $embeddingService->embed($query); + } catch (OllamaUnavailableException $exception) { + return response()->json([ + 'message' => 'Embedding provider is unavailable.', + 'error' => $exception->getMessage(), + ], 503); + } + + $embeddingContext = $embeddingService->context(); + $candidates = $articleRepository->findSimilarByEmbedding( + embedding: $embedding, + limit: $limit, + embeddingContext: $embeddingContext, + filters: ['published_only' => true] + ); + + $results = collect($candidates) + ->map(function ($candidate) use ($includeContent) { + $similarity = max(0, min(1, 1 - $candidate->distance)); + $content = trim($candidate->content); + + return [ + 'article_id' => $candidate->articleId, + 'title' => $candidate->title, + 'similarity' => round($similarity, 4), + 'distance' => round($candidate->distance, 4), + 'snippet' => str($content)->limit(220)->toString(), + 'content' => $includeContent ? $content : null, + 'source_url' => $candidate->sourceUrl, + 'source_article_id' => $candidate->sourceArticleId, + 'note' => $candidate->note, + 'allowed_actions' => $candidate->allowedActions, + ]; + }) + ->filter(fn (array $result) => $result['similarity'] >= $minSimilarity) + ->values(); + + return response()->json([ + 'data' => $results, + 'meta' => [ + 'query' => $query, + 'limit' => $limit, + 'min_similarity' => $minSimilarity, + 'published_only' => true, + 'embedding_provider_instance_id' => $embeddingContext['provider_instance_id'] ?? null, + 'embedding_model' => $embeddingContext['embedding_model'] ?? null, + ], + ]); + } +} diff --git a/app/Livewire/Admin/ArticleManager.php b/app/Livewire/Admin/ArticleManager.php index 09f3593..9675c8a 100644 --- a/app/Livewire/Admin/ArticleManager.php +++ b/app/Livewire/Admin/ArticleManager.php @@ -19,6 +19,14 @@ class ArticleManager extends Component public array $allowedActions = []; + public string $search = ''; + + public ?int $editingArticleId = null; + + public string $editTitle = ''; + + public string $editContent = ''; + public array $articleNotes = []; public array $articleAllowedActions = []; @@ -55,6 +63,51 @@ class ArticleManager extends Component $this->resetPage(); } + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function startEdit(int $articleId, AdminArticleService $service): void + { + $article = $service->findById($articleId); + if ($article === null) { + session()->flash('success', "Artikel #{$articleId} bestaat niet meer."); + + return; + } + + $this->editingArticleId = $article->id; + $this->editTitle = $article->title; + $this->editContent = $article->content; + } + + public function cancelEdit(): void + { + $this->reset(['editingArticleId', 'editTitle', 'editContent']); + $this->resetValidation(['editTitle', 'editContent']); + } + + public function saveEdit(AdminArticleService $service): void + { + if ($this->editingArticleId === null) { + return; + } + + $this->validate([ + 'editTitle' => ['required', 'string', 'max:255'], + 'editContent' => ['required', 'string', 'max:12000'], + ]); + + $updated = $service->updateContent($this->editingArticleId, $this->editTitle, $this->editContent); + + session()->flash('success', $updated + ? "Artikel #{$this->editingArticleId} is bijgewerkt en wordt opnieuw geindexeerd." + : "Artikel #{$this->editingArticleId} bestaat niet meer."); + + $this->cancelEdit(); + } + public function saveMetadata(int $articleId, AdminArticleService $service): void { $this->validate([ @@ -90,8 +143,8 @@ class ArticleManager extends Component public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService) { - $articles = $service->paginate(10); - $this->hydrateArticleMetadataState($articles->items()); + $articles = $service->paginate(10, $this->search); + $this->fillArticleMetadataState($articles->items()); return view('livewire.admin.article-manager', [ 'articles' => $articles, @@ -99,7 +152,7 @@ class ArticleManager extends Component ]); } - private function hydrateArticleMetadataState(array $articles): void + private function fillArticleMetadataState(array $articles): void { foreach ($articles as $article) { if (! array_key_exists($article->id, $this->articleNotes)) { diff --git a/app/Repositories/ArticleRepository.php b/app/Repositories/ArticleRepository.php index afde4ea..1dc70f4 100644 --- a/app/Repositories/ArticleRepository.php +++ b/app/Repositories/ArticleRepository.php @@ -9,13 +9,20 @@ use App\Repositories\Contracts\ArticleRepositoryInterface; class ArticleRepository implements ArticleRepositoryInterface { - public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array + public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = [], array $filters = []): array { $vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']'; $chunkDistances = ArticleChunk::query() ->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector]) ->whereNotNull('embedding') + ->when((bool) ($filters['published_only'] ?? false), function ($query) { + $query->whereHas('article', function ($articleQuery) { + $articleQuery + ->where('status', 'published') + ->where('is_ai_draft', false); + }); + }) ->when($embeddingContext !== [], function ($query) use ($embeddingContext) { $query ->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null) diff --git a/app/Repositories/Contracts/ArticleRepositoryInterface.php b/app/Repositories/Contracts/ArticleRepositoryInterface.php index 9e30d34..9f28cb1 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 $embeddingContext = []): array; + public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = [], array $filters = []): array; } diff --git a/app/Services/AdminArticleService.php b/app/Services/AdminArticleService.php index 511953e..19bba24 100644 --- a/app/Services/AdminArticleService.php +++ b/app/Services/AdminArticleService.php @@ -9,10 +9,29 @@ use Illuminate\Support\Facades\DB; class AdminArticleService { - public function paginate(int $perPage = 10): LengthAwarePaginator + public function paginate(int $perPage = 10, ?string $search = null): LengthAwarePaginator { + $search = $search !== null ? trim($search) : ''; + + $numericSearch = ltrim($search, '#'); + return Article::query() ->with('quickReplies') + ->when($search !== '', function ($query) use ($numericSearch, $search): void { + $like = "%{$search}%"; + + $query->where(function ($query) use ($numericSearch, $like): void { + $query + ->where('title', 'ilike', $like) + ->orWhere('content', 'ilike', $like) + ->orWhere('source_url', 'ilike', $like) + ->orWhere('source_article_id', 'ilike', $like); + + if (ctype_digit($numericSearch)) { + $query->orWhere('id', (int) $numericSearch); + } + }); + }) ->latest() ->paginate($perPage); } @@ -34,6 +53,25 @@ class AdminArticleService return (bool) Article::query()->whereKey($articleId)->delete(); } + public function findById(int $articleId): ?Article + { + return Article::query()->find($articleId); + } + + public function updateContent(int $articleId, string $title, string $content): bool + { + $article = Article::query()->find($articleId); + if ($article === null) { + return false; + } + + $article->title = trim($title); + $article->content = trim($content); + $article->save(); + + return true; + } + public function updateMetadata(int $articleId, ?string $note, array $allowedActions, array $quickReplyIds = []): bool { $article = Article::query()->find($articleId); diff --git a/app/Services/AppSettingsService.php b/app/Services/AppSettingsService.php index 7a0bf47..4ef82fb 100644 --- a/app/Services/AppSettingsService.php +++ b/app/Services/AppSettingsService.php @@ -130,6 +130,20 @@ class AppSettingsService return $default; } + public function toneAddressing(): string + { + return $this->get('tone_addressing', 'je') === 'u' ? 'u' : 'je'; + } + + public function toneInstruction(): string + { + if ($this->toneAddressing() === 'u') { + return 'Als de klanttaal Nederlands is: spreek de klant consequent formeel aan met u/uw. Gebruik geen je/jij/jouw.'; + } + + return 'Als de klanttaal Nederlands is: spreek de klant consequent informeel aan met je/jij/jouw. Gebruik geen u/uw.'; + } + public function promptSettings(): array { $settings = $this->all(); diff --git a/app/Services/KnowledgeGapService.php b/app/Services/KnowledgeGapService.php index 742228c..d7cf6c7 100644 --- a/app/Services/KnowledgeGapService.php +++ b/app/Services/KnowledgeGapService.php @@ -8,6 +8,8 @@ use Illuminate\Support\Str; class KnowledgeGapService { + public const CONFIDENCE_THRESHOLD = 0.45; + public function __construct( private readonly LlmClientInterface $llmClient, private readonly AppSettingsService $settings, @@ -16,7 +18,7 @@ class KnowledgeGapService public function shouldCreateDraft(Ticket $ticket, array $result): bool { $confidence = (float) ($result['confidence'] ?? 0); - if ($confidence < 0.45) { + if ($confidence < self::CONFIDENCE_THRESHOLD) { return true; } @@ -41,6 +43,7 @@ class KnowledgeGapService $prompt = $basePrompt."\n\n". "Klantvraag:\n{$question}\n\n". "Originele taal: {$language}. Schrijf titel en inhoud in deze taal.\n\n". + 'Aanspreekvorm: '.$this->settings->toneInstruction()."\n\n". "Huidige kandidaten (mogelijk onvoldoende):\n{$topCandidates}\n\n". 'Content moet praktisch zijn met duidelijke stappen.'; diff --git a/app/Services/SupportReplyService.php b/app/Services/SupportReplyService.php index 7df726d..b6cd8b6 100644 --- a/app/Services/SupportReplyService.php +++ b/app/Services/SupportReplyService.php @@ -18,18 +18,22 @@ class SupportReplyService public function build(Ticket $ticket, ?Article $bestArticle, string $explanation, ?array $toolCall = null): string { $basePrompt = $this->settings->getPrompt('support_reply', 'Give only direct advice in the requested output language.'); + $toneInstruction = $this->settings->toneInstruction(); 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."; + return "Geen passend kennisbankartikel gevonden.\n\n1. Controleer of de klantvraag 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'); + $articleUrl = $bestArticle->source_url ?: 'niet beschikbaar'; $llmPrompt = $basePrompt."\n\n". "Customer language: {$language}. Write the advice in this language.\n". + "Aanspreekvorm: {$toneInstruction}\n". 'Gebruikersvraag (genormaliseerd): '.$userInput."\n". 'Beste artikel titel: '.$bestArticle->title."\n". + 'Beste artikel URL: '.$articleUrl."\n". 'Beste artikel content: '.$bestArticle->content."\n". 'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n". 'Waarom dit artikel gekozen is: '.$explanation."\n\n". @@ -39,7 +43,9 @@ class SupportReplyService "- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n". "- Begin direct met de oplossing.\n". "- Geef 3-6 genummerde actiepunten.\n". + "- Volg de aanspreekvorm exact.\n". "- Voeg een korte controle-stap toe als laatste punt.\n". + "- Eindig altijd met precies 1 bronregel: 'Bron: ' of 'Bron: niet beschikbaar'.\n". '- Geen markdown, geen codeblokken.'; try { @@ -69,7 +75,8 @@ class SupportReplyService "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; + 'Bronsamenvatting: '.$snippet."\n". + 'Bron: '.($bestArticle->source_url ?: 'niet beschikbaar'); } private function formatToolCallForPrompt(?array $toolCall): string @@ -100,7 +107,7 @@ class SupportReplyService || 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 'Dit artikel sluit het beste aan op de klantvraag en bevat de meest relevante stappen om dit in te stellen.'; } return $explanation; diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index b574a59..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,25 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Handle Authorization Header - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Handle X-XSRF-Token Header - RewriteCond %{HTTP:x-xsrf-token} . - RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Send Requests To Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - diff --git a/resources/views/admin/knowledge-search-demo.blade.php b/resources/views/admin/knowledge-search-demo.blade.php new file mode 100644 index 0000000..f799531 --- /dev/null +++ b/resources/views/admin/knowledge-search-demo.blade.php @@ -0,0 +1,247 @@ + +
+
+
+
+

Website kennisbank search

+

+ Demo voor het ophalen van de dichtstbijzijnde gepubliceerde artikelen via vector search. +

+
+ + GET /api/articles/nearest + +
+ +
+ + + + + + + +
+ + +
+ +
+
+
+

Resultaten

+ Nog niet gezocht +
+
+
+ + +
+
+ + +
diff --git a/resources/views/components/admin/confidence-badge.blade.php b/resources/views/components/admin/confidence-badge.blade.php new file mode 100644 index 0000000..603efaa --- /dev/null +++ b/resources/views/components/admin/confidence-badge.blade.php @@ -0,0 +1,25 @@ +@props([ + 'confidence', + 'threshold' => \App\Services\KnowledgeGapService::CONFIDENCE_THRESHOLD, +]) + +@php + $score = (float) $confidence; + $passes = $score >= (float) $threshold; +@endphp + + $passes, + 'bg-amber-100 text-amber-900' => ! $passes, +])> + $passes, + 'bg-amber-600' => ! $passes, + ])> + Confidence {{ number_format($score, 2) }} + + {{ $passes ? 'haalt drempel' : 'onder drempel' }} {{ number_format((float) $threshold, 2) }} + + diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 65b3d07..bb40861 100644 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -17,6 +17,7 @@