feat: Enhance Support Reply Service with tone instructions and article details

- Added tone instruction retrieval to SupportReplyService.
- Improved user feedback when no relevant article is found.
- Included article URL and tone instruction in LLM prompt.
- Updated response format to include source information.
- Enhanced article management UI with search functionality and editing capabilities.
- Introduced a new API endpoint for nearest articles based on vector search.
- Added confidence badge component to display article confidence levels.
- Implemented tests for article searching, editing, and nearest article API.
- Removed obsolete .htaccess file.
This commit is contained in:
your name
2026-05-13 22:25:45 +02:00
parent c94d3f85e8
commit 9244899f9b
22 changed files with 813 additions and 123 deletions

View File

@@ -0,0 +1,247 @@
<x-layouts.admin title="Knowledge Base Search Demo">
<div class="space-y-6">
<section class="bg-white border border-slate-200 rounded-lg p-6">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-xl font-semibold">Website kennisbank search</h2>
<p class="text-sm text-slate-600 mt-1">
Demo voor het ophalen van de dichtstbijzijnde gepubliceerde artikelen via vector search.
</p>
</div>
<span class="inline-flex w-fit items-center rounded bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">
GET /api/articles/nearest
</span>
</div>
<form id="nearest-articles-form" class="mt-6 grid gap-4 lg:grid-cols-[1fr_140px_170px_auto]">
<label class="block">
<span class="text-sm font-medium text-slate-700">Zoekvraag</span>
<input
id="query"
name="query"
type="search"
value="Hoe stel ik DNS in?"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-900 focus:outline-none focus:ring-1 focus:ring-slate-900"
required
minlength="2"
maxlength="1000"
>
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Aantal</span>
<input
id="limit"
name="limit"
type="number"
min="1"
max="20"
value="5"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-900 focus:outline-none focus:ring-1 focus:ring-slate-900"
>
</label>
<label class="block">
<span class="text-sm font-medium text-slate-700">Min. similarity</span>
<input
id="min_similarity"
name="min_similarity"
type="number"
min="0"
max="1"
step="0.01"
value="0"
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-900 focus:outline-none focus:ring-1 focus:ring-slate-900"
>
</label>
<button
type="submit"
class="mt-6 inline-flex h-10 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white hover:bg-slate-800"
>
Zoek
</button>
</form>
<div id="status" class="mt-4 hidden rounded-md border px-3 py-2 text-sm"></div>
</section>
<section class="grid gap-6 lg:grid-cols-[1fr_420px]">
<div class="bg-white border border-slate-200 rounded-lg p-6">
<div class="flex items-center justify-between gap-3">
<h3 class="font-semibold">Resultaten</h3>
<span id="result-count" class="text-xs text-slate-500">Nog niet gezocht</span>
</div>
<div id="results" class="mt-4 space-y-3"></div>
</div>
<aside class="bg-white border border-slate-200 rounded-lg p-6">
<h3 class="font-semibold">Call documentatie</h3>
<div class="mt-4 space-y-4 text-sm">
<div>
<p class="font-medium text-slate-700">Endpoint</p>
<pre class="mt-1 overflow-auto rounded bg-slate-950 p-3 text-xs text-slate-100">GET /api/articles/nearest</pre>
</div>
<div>
<p class="font-medium text-slate-700">Query parameters</p>
<dl class="mt-2 divide-y divide-slate-100 border-y border-slate-100">
<div class="grid grid-cols-[130px_1fr] gap-3 py-2">
<dt class="font-mono text-xs text-slate-700">query</dt>
<dd class="text-slate-600">Verplicht. De zoekvraag die wordt ge-embed.</dd>
</div>
<div class="grid grid-cols-[130px_1fr] gap-3 py-2">
<dt class="font-mono text-xs text-slate-700">limit</dt>
<dd class="text-slate-600">Optioneel. 1 t/m 20, standaard 5.</dd>
</div>
<div class="grid grid-cols-[130px_1fr] gap-3 py-2">
<dt class="font-mono text-xs text-slate-700">min_similarity</dt>
<dd class="text-slate-600">Optioneel. 0 t/m 1, standaard 0.</dd>
</div>
<div class="grid grid-cols-[130px_1fr] gap-3 py-2">
<dt class="font-mono text-xs text-slate-700">include_content</dt>
<dd class="text-slate-600">Optioneel. Boolean, standaard false.</dd>
</div>
</dl>
</div>
<div>
<p class="font-medium text-slate-700">Voorbeeld</p>
<pre id="request-example" class="mt-1 overflow-auto rounded bg-slate-950 p-3 text-xs text-slate-100">GET /api/articles/nearest?query=Hoe%20stel%20ik%20DNS%20in%3F&amp;limit=5&amp;min_similarity=0</pre>
</div>
<div>
<p class="font-medium text-slate-700">Response shape</p>
<pre class="mt-1 overflow-auto rounded bg-slate-950 p-3 text-xs text-slate-100">{
"data": [
{
"article_id": 12,
"title": "DNS instellen",
"similarity": 0.8421,
"distance": 0.1579,
"snippet": "Korte preview van het artikel...",
"content": null,
"source_url": "https://...",
"source_article_id": 12345,
"note": null,
"allowed_actions": []
}
],
"meta": {
"query": "Hoe stel ik DNS in?",
"limit": 5,
"min_similarity": 0,
"published_only": true,
"embedding_provider_instance_id": "default",
"embedding_model": "nomic-embed-text"
}
}</pre>
</div>
</div>
</aside>
</section>
</div>
<script>
const form = document.getElementById('nearest-articles-form');
const resultsEl = document.getElementById('results');
const statusEl = document.getElementById('status');
const resultCountEl = document.getElementById('result-count');
const requestExampleEl = document.getElementById('request-example');
function setStatus(message, type = 'info') {
statusEl.classList.remove('hidden', 'border-red-200', 'bg-red-50', 'text-red-700', 'border-slate-200', 'bg-slate-50', 'text-slate-700');
statusEl.classList.add(type === 'error' ? 'border-red-200' : 'border-slate-200');
statusEl.classList.add(type === 'error' ? 'bg-red-50' : 'bg-slate-50');
statusEl.classList.add(type === 'error' ? 'text-red-700' : 'text-slate-700');
statusEl.textContent = message;
}
function clearStatus() {
statusEl.classList.add('hidden');
statusEl.textContent = '';
}
function buildUrl() {
const params = new URLSearchParams({
query: document.getElementById('query').value,
limit: document.getElementById('limit').value || '5',
min_similarity: document.getElementById('min_similarity').value || '0',
});
return `/api/articles/nearest?${params.toString()}`;
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function renderResults(results) {
resultCountEl.textContent = `${results.length} resultaat${results.length === 1 ? '' : 'en'}`;
if (results.length === 0) {
resultsEl.innerHTML = '<div class="rounded-md border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">Geen artikelen gevonden voor deze zoekvraag of threshold.</div>';
return;
}
resultsEl.innerHTML = results.map((result) => {
const url = result.source_url
? `<a href="${escapeHtml(result.source_url)}" target="_blank" rel="noreferrer" class="text-sm text-slate-900 underline">Bron openen</a>`
: '';
return `
<article class="rounded-md border border-slate-200 p-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h4 class="font-medium">${escapeHtml(result.title)}</h4>
<p class="mt-1 text-sm text-slate-600">${escapeHtml(result.snippet)}</p>
</div>
<span class="w-fit rounded bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">
${(result.similarity * 100).toFixed(1)}%
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span>Article #${result.article_id}</span>
<span>Distance ${result.distance}</span>
${url}
</div>
</article>
`;
}).join('');
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
clearStatus();
resultsEl.innerHTML = '';
resultCountEl.textContent = 'Zoeken...';
const url = buildUrl();
requestExampleEl.textContent = `GET ${url}`;
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || 'De request is mislukt.');
}
renderResults(payload.data || []);
setStatus('Request afgerond via vector search.');
} catch (error) {
resultCountEl.textContent = 'Mislukt';
setStatus(error.message, 'error');
}
});
</script>
</x-layouts.admin>

View File

@@ -0,0 +1,25 @@
@props([
'confidence',
'threshold' => \App\Services\KnowledgeGapService::CONFIDENCE_THRESHOLD,
])
@php
$score = (float) $confidence;
$passes = $score >= (float) $threshold;
@endphp
<span @class([
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium',
'bg-green-100 text-green-800' => $passes,
'bg-amber-100 text-amber-900' => ! $passes,
])>
<span @class([
'h-2 w-2 rounded-full',
'bg-green-600' => $passes,
'bg-amber-600' => ! $passes,
])></span>
Confidence {{ number_format($score, 2) }}
<span class="text-[11px] opacity-80">
{{ $passes ? 'haalt drempel' : 'onder drempel' }} {{ number_format((float) $threshold, 2) }}
</span>
</span>

View File

@@ -17,6 +17,7 @@
<nav class="flex gap-3 text-sm">
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
<a href="{{ route('admin.knowledge-search-demo') }}" class="hover:underline">KB demo</a>
<a href="{{ route('admin.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
<a href="{{ route('admin.process') }}" class="hover:underline">Proces</a>

View File

@@ -35,91 +35,142 @@
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Artikelen</h2>
<div class="flex flex-col gap-3 mb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 class="font-semibold">Artikelen</h2>
<p class="text-sm text-slate-500">Zoek op titel, inhoud, bron of artikelnummer.</p>
</div>
<div class="w-full lg:w-96">
<label for="article-search" class="block text-xs font-semibold text-slate-500 mb-1">Zoeken</label>
<input id="article-search" wire:model.live.debounce.300ms="search" type="search"
class="w-full border rounded p-2" placeholder="Bijvoorbeeld: DNS, e-mail, #42">
</div>
</div>
<div class="space-y-3">
@foreach ($articles as $article)
<div class="border rounded p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
@if (($article->status ?? 'published') === 'draft')
<span
class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept
(AI)</span>
@endif
</div>
<div class="flex items-center gap-3">
@if (($article->status ?? 'published') === 'draft')
<button type="button" wire:click="approveDraft({{ $article->id }})"
class="text-sm text-green-700 hover:underline">
Valideren & publiceren
</button>
@endif
<button type="button" wire:click="deleteArticle({{ $article->id }})"
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
</div>
@if ($article->note)
<div class="mt-2 text-xs rounded bg-slate-50 p-2 text-slate-600">
<span class="font-semibold">LLM note:</span>
{{ \Illuminate\Support\Str::limit($article->note, 180) }}
</div>
@endif
@if (($article->allowed_actions ?? []) !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach ($article->allowed_actions as $action)
<span
class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
@endforeach
</div>
@endif
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<label class="block text-xs font-semibold text-slate-500">LLM note</label>
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm"
placeholder="Interne aanwijzingen voor dit artikel"></textarea>
@error("articleNotes.{$article->id}")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox"
value="domain_inf" class="mt-1 rounded border-slate-300">
<span>domain_inf toestaan</span>
</label>
@error("articleAllowedActions.{$article->id}.*")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde
snelantwoorden</label>
@if ($quickReplyOptions->isEmpty())
<p class="text-xs text-slate-500">Nog geen actieve snelantwoorden beschikbaar.</p>
@else
<div class="grid gap-1 sm:grid-cols-2">
@foreach ($quickReplyOptions as $quickReply)
<label class="flex items-center gap-2 text-sm">
<input wire:model="articleQuickReplies.{{ $article->id }}" type="checkbox"
value="{{ $quickReply->id }}" class="rounded border-slate-300">
<span>{{ $quickReply->title }}</span>
</label>
@endforeach
@forelse ($articles as $article)
<div class="border rounded p-3" wire:key="article-{{ $article->id }}">
@if ($editingArticleId === $article->id)
<form wire:submit="saveEdit" class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">Artikel #{{ $article->id }} bewerken</div>
<p class="text-sm text-slate-500">Na opslaan wordt het artikel opnieuw geindexeerd.</p>
</div>
@endif
@error("articleQuickReplies.{$article->id}.*")
<button type="button" wire:click="cancelEdit"
class="text-sm text-slate-600 hover:underline">
Annuleren
</button>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Titel</label>
<input wire:model="editTitle" type="text" class="w-full border rounded p-2">
@error('editTitle')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Inhoud</label>
<textarea wire:model="editContent" class="w-full border rounded p-2 min-h-64"></textarea>
@error('editContent')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">
Wijzigingen opslaan
</button>
</form>
@else
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
@if (($article->status ?? 'published') === 'draft')
<span
class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept
(AI)</span>
@endif
</div>
<div class="flex items-center gap-3">
@if (($article->status ?? 'published') === 'draft')
<button type="button" wire:click="approveDraft({{ $article->id }})"
class="text-sm text-green-700 hover:underline">
Valideren & publiceren
</button>
@endif
<button type="button" wire:click="startEdit({{ $article->id }})"
class="text-sm text-slate-700 hover:underline">
Bewerken
</button>
<button type="button" wire:click="deleteArticle({{ $article->id }})"
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
</div>
@if ($article->note)
<div class="mt-2 text-xs rounded bg-slate-50 p-2 text-slate-600">
<span class="font-semibold">LLM note:</span>
{{ \Illuminate\Support\Str::limit($article->note, 180) }}
</div>
@endif
@if (($article->allowed_actions ?? []) !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach ($article->allowed_actions as $action)
<span
class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
@endforeach
</div>
@endif
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<label class="block text-xs font-semibold text-slate-500">LLM note</label>
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm"
placeholder="Interne aanwijzingen voor dit artikel"></textarea>
@error("articleNotes.{$article->id}")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox"
value="domain_inf" class="mt-1 rounded border-slate-300">
<span>domain_inf toestaan</span>
</label>
@error("articleAllowedActions.{$article->id}.*")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde
snelantwoorden</label>
@if ($quickReplyOptions->isEmpty())
<p class="text-xs text-slate-500">Nog geen actieve snelantwoorden beschikbaar.</p>
@else
<div class="grid gap-1 sm:grid-cols-2">
@foreach ($quickReplyOptions as $quickReply)
<label class="flex items-center gap-2 text-sm">
<input wire:model="articleQuickReplies.{{ $article->id }}"
type="checkbox" value="{{ $quickReply->id }}"
class="rounded border-slate-300">
<span>{{ $quickReply->title }}</span>
</label>
@endforeach
</div>
@endif
@error("articleQuickReplies.{$article->id}.*")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<button type="button" wire:click="saveMetadata({{ $article->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Metadata opslaan
</button>
</div>
<button type="button" wire:click="saveMetadata({{ $article->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Metadata opslaan
</button>
</div>
@endif
</div>
@endforeach
@empty
<div class="border rounded p-4 text-sm text-slate-500">
Geen artikelen gevonden.
</div>
@endforelse
</div>
<div class="mt-4">{{ $articles->links() }}</div>
</div>

View File

@@ -64,8 +64,12 @@
</div>
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
@if ($ticket->bestArticle)
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence:
{{ number_format((float) $ticket->confidence, 2) }}</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span>Article: #{{ $ticket->bestArticle->id }}</span>
@if ($ticket->confidence !== null)
<x-admin.confidence-badge :confidence="$ticket->confidence" />
@endif
</div>
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
@endif
<a class="text-sm underline"

View File

@@ -67,10 +67,20 @@
@endif
@if ($ticket->bestArticle)
<div class="mt-3 text-sm rounded bg-green-100 text-green-800 p-2">
Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }}
<div class="flex flex-wrap items-center gap-2">
<span>Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }}</span>
@if ($ticket->confidence !== null)
(confidence {{ number_format($ticket->confidence, 2) }})
<x-admin.confidence-badge :confidence="$ticket->confidence" />
@endif
</div>
<div class="mt-1">
Bron:
@if ($ticket->bestArticle->source_url)
<a href="{{ $ticket->bestArticle->source_url }}" target="_blank" class="underline">{{ $ticket->bestArticle->source_url }}</a>
@else
niet beschikbaar
@endif
</div>
</div>
@endif
</div>