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>