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,81 @@
<?php
namespace App\Http\Controllers\Api;
use App\Exceptions\OllamaUnavailableException;
use App\Http\Controllers\Controller;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Services\EmbeddingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NearestArticleController extends Controller
{
public function __invoke(
Request $request,
EmbeddingService $embeddingService,
ArticleRepositoryInterface $articleRepository
): JsonResponse {
$validated = $request->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,
],
]);
}
}

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -7,5 +7,5 @@ use App\DTOs\ArticleCandidateDTO;
interface ArticleRepositoryInterface
{
/** @return array<ArticleCandidateDTO> */
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array;
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = [], array $filters = []): array;
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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.';

View File

@@ -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: <url>' 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;

View File

@@ -1,25 +0,0 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
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]
</IfModule>

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>

View File

@@ -1,9 +1,11 @@
<?php
use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\NearestArticleController;
use App\Http\Controllers\Api\TicketController;
use Illuminate\Support\Facades\Route;
Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/nearest', NearestArticleController::class);
Route::post('/articles', [ArticleController::class, 'store']);
Route::post('/tickets', [TicketController::class, 'store']);

View File

@@ -10,6 +10,7 @@ Route::get('/', function () {
Route::prefix('admin')->group(function () {
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
Route::view('/articles', 'admin.articles')->name('admin.articles');
Route::view('/knowledge-search-demo', 'admin.knowledge-search-demo')->name('admin.knowledge-search-demo');
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');

View File

@@ -10,7 +10,7 @@ class FakeArticleRepository implements ArticleRepositoryInterface
/** @var array<int, ArticleCandidateDTO> */
public array $candidates = [];
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = [], array $filters = []): array
{
return array_slice($this->candidates, 0, $limit);
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Livewire\Admin\ArticleManager;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\TestCase;
class ArticleManagerTest extends TestCase
{
use RefreshDatabase;
public function test_it_can_search_articles(): void
{
Article::withoutEvents(function (): void {
Article::query()->create([
'title' => 'DNS instellen',
'content' => 'Managed DNS handleiding',
]);
Article::query()->create([
'title' => 'E-mail wachtwoord wijzigen',
'content' => 'Mailbox instellingen',
]);
});
Livewire::test(ArticleManager::class)
->set('search', 'Managed DNS')
->assertSee('DNS instellen')
->assertDontSee('E-mail wachtwoord wijzigen');
}
public function test_it_can_edit_article_title_and_content(): void
{
Queue::fake();
config(['services.embedding.queue_embeddings' => true]);
$article = Article::withoutEvents(fn () => Article::query()->create([
'title' => 'Oude titel',
'content' => 'Oude inhoud',
]));
Livewire::test(ArticleManager::class)
->call('startEdit', $article->id)
->assertSet('editingArticleId', $article->id)
->set('editTitle', 'Nieuwe titel')
->set('editContent', 'Nieuwe inhoud voor het artikel')
->call('saveEdit')
->assertSet('editingArticleId', null);
$article->refresh();
$this->assertSame('Nieuwe titel', $article->title);
$this->assertSame('Nieuwe inhoud voor het artikel', $article->content);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\DTOs\ArticleCandidateDTO;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Services\EmbeddingService;
use Mockery;
use Tests\Fakes\FakeArticleRepository;
use Tests\TestCase;
class NearestArticleApiTest extends TestCase
{
public function test_it_returns_nearest_published_articles(): void
{
$embeddingService = Mockery::mock(EmbeddingService::class);
$embeddingService
->shouldReceive('embed')
->once()
->with('Hoe stel ik DNS in?')
->andReturn([0.1, 0.2, 0.3]);
$embeddingService
->shouldReceive('context')
->once()
->andReturn([
'provider_instance_id' => 'instance-1',
'embedding_model' => 'embed-model',
]);
$repository = new FakeArticleRepository;
$repository->candidates = [
new ArticleCandidateDTO(
articleId: 10,
title: 'DNS instellen',
content: 'Open het DNS beheer en voeg de juiste records toe.',
distance: 0.12,
sourceUrl: 'https://example.test/articles/dns'
),
];
$this->app->instance(EmbeddingService::class, $embeddingService);
$this->app->instance(ArticleRepositoryInterface::class, $repository);
$response = $this->getJson('/api/articles/nearest?query=Hoe%20stel%20ik%20DNS%20in%3F&limit=5');
$response
->assertOk()
->assertJsonPath('data.0.article_id', 10)
->assertJsonPath('data.0.title', 'DNS instellen')
->assertJsonPath('data.0.similarity', 0.88)
->assertJsonPath('data.0.content', null)
->assertJsonPath('meta.published_only', true)
->assertJsonPath('meta.embedding_model', 'embed-model');
}
public function test_it_validates_the_search_query(): void
{
$response = $this->getJson('/api/articles/nearest?query=x');
$response->assertStatus(422);
}
}

View File

@@ -19,6 +19,7 @@ class TicketShowPageTest extends TestCase
'message' => 'vraag',
'status' => 'completed',
'best_article_id' => $article->id,
'confidence' => 0.9,
'support_reply' => 'Gebruik deze stappen',
'result_payload' => [
'quick_reply' => ['id' => 1, 'title' => 'DNS Quick'],
@@ -39,5 +40,23 @@ class TicketShowPageTest extends TestCase
$response->assertOk();
$response->assertSee('Snelantwoord gebruikt', false);
$response->assertSee('Toolcalls', false);
$response->assertSee('haalt drempel', false);
}
public function test_ticket_show_marks_confidence_below_threshold(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$ticket = Ticket::query()->create([
'message' => 'vraag',
'status' => 'completed',
'best_article_id' => $article->id,
'confidence' => 0.25,
]);
$response = $this->get("/admin/tickets/{$ticket->id}");
$response->assertOk();
$response->assertSee('Confidence 0.25', false);
$response->assertSee('onder drempel 0.45', false);
}
}

View File

@@ -5,9 +5,7 @@ namespace Tests\Unit;
use App\Models\Article;
use App\Models\Ticket;
use App\Services\AppSettingsService;
use App\Services\Llm\LlmClientInterface;
use App\Services\SupportReplyService;
use App\Services\TicketProcessingLoggerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fakes\FakeLlmClient;
use Tests\Fakes\FakeTicketProcessingLoggerService;
@@ -35,6 +33,7 @@ class SupportReplyServiceTest extends TestCase
$this->assertSame('1. Doe X', $reply);
$this->assertStringContainsString('Gebruikersvraag (genormaliseerd): vraag', $llm->generatedPrompts[0]['prompt']);
$this->assertStringContainsString('spreek de klant consequent informeel aan met je/jij/jouw', $llm->generatedPrompts[0]['prompt']);
}
public function test_it_falls_back_when_llm_returns_empty(): void
@@ -56,9 +55,41 @@ class SupportReplyServiceTest extends TestCase
$this->assertStringContainsString('Gebruik het kennisbankartikel', $reply);
}
private function fakeSettings(): AppSettingsService
public function test_it_includes_formal_addressing_instruction_when_configured(): void
{
return new class extends AppSettingsService {
$llm = new FakeLlmClient;
$llm->responses = ['1. Doe X'];
$service = new SupportReplyService(
$this->fakeSettings('u'),
$llm,
new FakeTicketProcessingLoggerService
);
$ticket = Ticket::query()->create(['message' => 'vraag', 'normalized_message' => 'vraag']);
$article = Article::query()->create(['title' => 'DNS', 'content' => 'steps']);
$service->build($ticket, $article, 'relevant');
$this->assertStringContainsString('spreek de klant consequent formeel aan met u/uw', $llm->generatedPrompts[0]['prompt']);
$this->assertStringContainsString('Gebruik geen je/jij/jouw', $llm->generatedPrompts[0]['prompt']);
}
private function fakeSettings(string $tone = 'je'): AppSettingsService
{
return new class($tone) extends AppSettingsService
{
public function __construct(private readonly string $tone) {}
public function get(string $key, ?string $default = null): ?string
{
if ($key === 'tone_addressing') {
return $this->tone;
}
return $default;
}
public function getPrompt(string $key, ?string $default = null): ?string
{
return 'Prompt';