Add unit and feature tests for ticket processing and article management

- Implement Fake repositories and services for testing purposes.
- Create tests for Article API including creation, validation, and listing.
- Develop ProcessTicketJobFlowTest to validate ticket processing logic.
- Add QuickReplyAdminTest for creating and updating quick replies.
- Implement TicketAndArticleModelTest to ensure proper cascading deletes and credential encryption.
- Create TicketIngestionTest for ticket creation and job dispatching.
- Add TicketShowPageTest to verify rendering of quick replies and tool calls.
- Implement unit tests for ClassifierPromptBuilder, EmbeddingService, LlmJsonDecoder, QuickReplyResolver, SupportReplyService, TicketResultPayloadBuilder, TicketToolCallService, and ToolCallRequestValidator.
This commit is contained in:
SitiWeb
2026-04-30 02:10:15 +02:00
parent 39bdba2dfb
commit c94d3f85e8
36 changed files with 7445 additions and 467 deletions

View File

@@ -70,14 +70,14 @@ class QuickReplyManager extends Component
public function render(AdminQuickReplyService $service)
{
$quickReplies = $service->paginate(10);
$this->hydrateEditRows($quickReplies->items());
$this->fillEditRows($quickReplies->items());
return view('livewire.admin.quick-reply-manager', [
'quickReplies' => $quickReplies,
]);
}
private function hydrateEditRows(array $quickReplies): void
private function fillEditRows(array $quickReplies): void
{
foreach ($quickReplies as $quickReply) {
if (array_key_exists($quickReply->id, $this->editRows)) {

View File

@@ -12,7 +12,8 @@
"phpunit/phpunit": "^11.0",
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.29"
"laravel/pint": "^1.29",
"mockery/mockery": "^1.6"
},
"autoload": {
"psr-4": {

136
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d577f9da472918b362d4f0001beff9e5",
"content-hash": "fe3062513ea83b2c74c4269b1de8e7f9",
"packages": [
{
"name": "brick/math",
@@ -6029,6 +6029,57 @@
},
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "hamcrest/hamcrest-php",
"version": "v2.1.1",
"source": {
"type": "git",
"url": "https://github.com/hamcrest/hamcrest-php.git",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"replace": {
"cordoval/hamcrest-php": "*",
"davedevelopment/hamcrest-php": "*",
"kodova/hamcrest-php": "*"
},
"require-dev": {
"phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
"phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": {
"classmap": [
"hamcrest"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "This is the PHP port of Hamcrest Matchers",
"keywords": [
"test"
],
"support": {
"issues": "https://github.com/hamcrest/hamcrest-php/issues",
"source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "iamcal/sql-parser",
"version": "v0.7",
@@ -6228,6 +6279,89 @@
},
"time": "2026-04-20T15:26:14+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.12",
"source": {
"type": "git",
"url": "https://github.com/mockery/mockery.git",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
"shasum": ""
},
"require": {
"hamcrest/hamcrest-php": "^2.0.1",
"lib-pcre": ">=7.0",
"php": ">=7.3"
},
"conflict": {
"phpunit/phpunit": "<8.0"
},
"require-dev": {
"phpunit/phpunit": "^8.5 || ^9.6.17",
"symplify/easy-coding-standard": "^12.1.14"
},
"type": "library",
"autoload": {
"files": [
"library/helpers.php",
"library/Mockery.php"
],
"psr-4": {
"Mockery\\": "library/Mockery"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Pádraic Brady",
"email": "padraic.brady@gmail.com",
"homepage": "https://github.com/padraic",
"role": "Author"
},
{
"name": "Dave Marshall",
"email": "dave.marshall@atstsolutions.co.uk",
"homepage": "https://davedevelopment.co.uk",
"role": "Developer"
},
{
"name": "Nathanael Esayeas",
"email": "nathanael.esayeas@protonmail.com",
"homepage": "https://github.com/ghostwriter",
"role": "Lead Developer"
}
],
"description": "Mockery is a simple yet flexible PHP mock object framework",
"homepage": "https://github.com/mockery/mockery",
"keywords": [
"BDD",
"TDD",
"library",
"mock",
"mock objects",
"mockery",
"stub",
"test",
"test double",
"testing"
],
"support": {
"docs": "https://docs.mockery.io/",
"issues": "https://github.com/mockery/mockery/issues",
"rss": "https://github.com/mockery/mockery/releases.atom",
"security": "https://github.com/mockery/mockery/security/advisories",
"source": "https://github.com/mockery/mockery"
},
"time": "2024-05-16T03:13:13+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",

3552
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,12 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "vite",
"format:blade": "blade-formatter --write \"resources/**/*.blade.php\""
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"blade-formatter": "^1.44.4",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.0.0",
"tailwindcss": "^4.0.0",

View File

@@ -23,8 +23,12 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_HOST" value="postgres"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_DATABASE" value="ticket_assistant_test"/>
<env name="DB_USERNAME" value="postgres"/>
<env name="DB_PASSWORD" value="postgres"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>

View File

@@ -38,7 +38,8 @@
<div class="font-medium">3. De betekenis wordt omgezet naar een zoekcode</div>
<p class="text-sm text-slate-600">
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
woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen
vinden
waarin "e-mail storing" staat.
</p>
</div>
@@ -67,7 +68,8 @@
<div class="font-medium">7. Er komt een advies of een melding</div>
<p class="text-sm text-slate-600">
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
korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord
maar
meldt hij dat de kennisbank iets mist.
</p>
</div>
@@ -147,13 +149,15 @@
<h3 class="font-semibold">Hulpmiddelen en allowed actions</h3>
<p class="mt-2 text-sm text-slate-600">
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
hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie
controleert
daarna zelf of het echt mag.
</p>
<div class="mt-4 rounded border p-3">
<div class="font-medium">Nu beschikbaar: domain_inf</div>
<p class="mt-1 text-sm text-slate-600">
Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf` toestaat,
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.
</p>
</div>
@@ -164,9 +168,12 @@
<div class="mt-4 space-y-3 text-sm text-slate-600">
<div><span class="font-medium text-slate-900">Embedding:</span> een betekenis-code van tekst.</div>
<div><span class="font-medium text-slate-900">Chunk:</span> een klein stukje van een artikel.</div>
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.</div>
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft waarschijnlijk nog geen goed artikel voor deze vraag.</div>
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de app extra informatie kan ophalen.</div>
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.
</div>
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft
waarschijnlijk nog geen goed artikel voor deze vraag.</div>
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de
app extra informatie kan ophalen.</div>
</div>
</div>
</section>
@@ -180,7 +187,8 @@
</div>
<div class="rounded border p-3">
<div class="font-medium">App zoekt</div>
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.</p>
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.
</p>
</div>
<div class="rounded border p-3">
<div class="font-medium">AI kiest</div>
@@ -188,7 +196,8 @@
</div>
<div class="rounded border p-3">
<div class="font-medium">Support controleert</div>
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.</p>
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.
</p>
</div>
</div>
</section>

View File

@@ -13,11 +13,11 @@
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">{{ $title }}</div>
@if($description)
@if ($description)
<p class="mt-1 text-sm text-slate-600">{{ $description }}</p>
@endif
</div>
@if($badge)
@if ($badge)
<span class="shrink-0 rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{{ $badge }}</span>
@endif
</div>

View File

@@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,26 +8,28 @@
<script src="https://cdn.tailwindcss.com"></script>
@livewireStyles
</head>
<body class="bg-slate-100 text-slate-900">
<div class="min-h-screen">
<header class="bg-slate-900 text-white">
<div class="mx-auto max-w-7xl px-6 py-4 flex items-center justify-between">
<h1 class="text-lg font-semibold">Ticket Assistant Admin</h1>
<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.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>
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
{{ $slot }}
</main>
</div>
@livewireScripts
<body class="bg-slate-100 text-slate-900">
<div class="min-h-screen">
<header class="bg-slate-900 text-white">
<div class="mx-auto max-w-7xl px-6 py-4 flex items-center justify-between">
<h1 class="text-lg font-semibold">Ticket Assistant Admin</h1>
<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.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>
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-6">
{{ $slot }}
</main>
</div>
@livewireScripts
</body>
</html>

View File

@@ -6,19 +6,30 @@
@endif
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('title')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-40" placeholder="Content"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24" placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
@error('note') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('content')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24"
placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
@error('note')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="allowedActions" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
<input wire:model="allowedActions" type="checkbox" value="domain_inf"
class="mt-1 rounded border-slate-300">
<span>
<span class="font-medium">domain_inf toestaan</span>
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld aanwezig zijn.</span>
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld
aanwezig zijn.</span>
</span>
</label>
@error('allowedActions.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('allowedActions.*')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
</form>
</div>
@@ -26,83 +37,84 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Artikelen</h2>
<div class="space-y-3">
@foreach($articles as $article)
@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>
@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"
>
@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 }})"
<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"
>
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
</div>
@if($article->note)
@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) }}
<span class="font-semibold">LLM note:</span>
{{ \Illuminate\Support\Str::limit($article->note, 180) }}
</div>
@endif
@if(($article->allowed_actions ?? []) !== [])
@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>
@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="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
<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">
<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
@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())
<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)
@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"
>
<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
@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"
>
<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>

View File

@@ -1,9 +1,14 @@
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-4">
<div class="bg-white rounded-xl p-4 shadow">Articles<br><span class="text-2xl font-bold">{{ $stats['articles_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Tickets<br><span class="text-2xl font-bold">{{ $stats['tickets_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">AI Decisions<br><span class="text-2xl font-bold">{{ $stats['decisions_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Feedback Accuracy<br><span class="text-2xl font-bold">{{ isset($stats['feedback_accuracy']) ? $stats['feedback_accuracy'].'%' : 'N/A' }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Articles<br><span
class="text-2xl font-bold">{{ $stats['articles_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Tickets<br><span
class="text-2xl font-bold">{{ $stats['tickets_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">AI Decisions<br><span
class="text-2xl font-bold">{{ $stats['decisions_count'] ?? 0 }}</span></div>
<div class="bg-white rounded-xl p-4 shadow">Feedback Accuracy<br><span
class="text-2xl font-bold">{{ isset($stats['feedback_accuracy']) ? $stats['feedback_accuracy'] . '%' : 'N/A' }}</span>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
@@ -11,7 +16,8 @@
<h2 class="font-semibold mb-3">Recent Tickets</h2>
<ul class="space-y-2 text-sm">
@forelse($recentTickets as $ticket)
<li class="border-b pb-2">#{{ $ticket->id }} - {{ \Illuminate\Support\Str::limit($ticket->message, 100) }}</li>
<li class="border-b pb-2">#{{ $ticket->id }} -
{{ \Illuminate\Support\Str::limit($ticket->message, 100) }}</li>
@empty
<li>Geen tickets.</li>
@endforelse

View File

@@ -12,10 +12,14 @@
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('title')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-36" placeholder="Snelantwoord tekst"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error('content')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="isActive" type="checkbox" class="rounded border-slate-300">
@@ -29,7 +33,7 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Snelantwoorden</h2>
<div class="space-y-3">
@foreach($quickReplies as $quickReply)
@foreach ($quickReplies as $quickReply)
<div class="border rounded p-3">
<div class="flex items-start justify-between gap-3">
<div>
@@ -38,33 +42,33 @@
Gekoppeld aan {{ $quickReply->articles_count }} artikel(en)
</div>
</div>
<button
type="button"
wire:click="deleteQuickReply({{ $quickReply->id }})"
<button type="button" wire:click="deleteQuickReply({{ $quickReply->id }})"
wire:confirm="Weet je zeker dat je dit snelantwoord wilt verwijderen?"
class="text-sm text-red-600 hover:underline"
>
class="text-sm text-red-600 hover:underline">
Verwijderen
</button>
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text" class="w-full border rounded p-2 text-sm">
@error("editRows.{$quickReply->id}.title") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text"
class="w-full border rounded p-2 text-sm">
@error("editRows.{$quickReply->id}.title")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<textarea wire:model="editRows.{{ $quickReply->id }}.content" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
@error("editRows.{$quickReply->id}.content") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
@error("editRows.{$quickReply->id}.content")
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox" class="rounded border-slate-300">
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox"
class="rounded border-slate-300">
<span>Actief</span>
</label>
<button
type="button"
wire:click="updateQuickReply({{ $quickReply->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
<button type="button" wire:click="updateQuickReply({{ $quickReply->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Wijzigingen opslaan
</button>
</div>

View File

@@ -15,12 +15,9 @@
@endif
<div class="mt-4 flex flex-wrap gap-2 text-sm">
@foreach(['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
<button
type="button"
wire:click="setTab('{{ $tab }}')"
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}"
>
@foreach (['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
<button type="button" wire:click="setTab('{{ $tab }}')"
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}">
{{ $label }}
</button>
@endforeach
@@ -28,11 +25,12 @@
</div>
<form id="ai-settings-form" wire:submit="save" class="space-y-4">
@if($activeTab === 'process')
@if ($activeTab === 'process')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div>
<h3 class="font-semibold">Proces & prompts</h3>
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt, kun je die hier aanpassen.</p>
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt,
kun je die hier aanpassen.</p>
</div>
<div>
@@ -44,20 +42,15 @@
</div>
<div class="space-y-3">
@foreach($processSteps as $step)
<x-admin.timeline-card
:number="$step['number']"
:title="$step['title']"
:description="$step['description']"
:badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'"
>
@if(isset($step['prompt_key']))
<label class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
<textarea
wire:model="promptValues.{{ $step['id'] }}"
class="w-full border rounded p-2 min-h-28 text-sm"
></textarea>
@error('promptValues.'.$step['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
@foreach ($processSteps as $step)
<x-admin.timeline-card :number="$step['number']" :title="$step['title']" :description="$step['description']" :badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'">
@if (isset($step['prompt_key']))
<label
class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
<textarea wire:model="promptValues.{{ $step['id'] }}" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
@error('promptValues.' . $step['id'])
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
@endif
</x-admin.timeline-card>
@endforeach
@@ -65,14 +58,16 @@
</section>
@endif
@if($activeTab === 'providers')
@if ($activeTab === 'providers')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">LLM provider instances</h3>
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke instance actief is.</p>
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke
instance actief is.</p>
</div>
<button type="button" wire:click="addProviderInstance" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="addProviderInstance"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Instance toevoegen
</button>
</div>
@@ -80,20 +75,24 @@
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Actieve instance</label>
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels" class="w-full border rounded p-2">
@foreach($providerInstances as $instance)
<option value="{{ $instance['id'] }}">{{ $instance['name'] }} ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})</option>
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels"
class="w-full border rounded p-2">
@foreach ($providerInstances as $instance)
<option value="{{ $instance['id'] }}">{{ $instance['name'] }}
({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm mb-1">Timeout seconden</label>
<input wire:model="llm_timeout" type="number" min="5" max="600" class="w-full border rounded p-2">
<input wire:model="llm_timeout" type="number" min="5" max="600"
class="w-full border rounded p-2">
</div>
</div>
<div class="space-y-4">
@foreach($providerInstances as $index => $instance)
@foreach ($providerInstances as $index => $instance)
@php($type = $instance['type'] ?? 'lmstudio')
@php($definition = $providerDefinitions[$type] ?? ['label' => $type, 'description' => ''])
<div class="border rounded p-4 space-y-3">
@@ -103,18 +102,18 @@
<p class="text-sm text-slate-600">{{ $definition['description'] }}</p>
</div>
<div class="flex items-center gap-2">
@if($activeProviderInstanceId === ($instance['id'] ?? null))
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
@if ($activeProviderInstanceId === ($instance['id'] ?? null))
<span
class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
@else
<button type="button" wire:click="setActiveProviderInstance('{{ $instance['id'] }}')" class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
<button type="button"
wire:click="setActiveProviderInstance('{{ $instance['id'] }}')"
class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
Actief maken
</button>
@endif
<button
type="button"
wire:click="removeProviderInstance('{{ $instance['id'] }}')"
class="text-xs text-red-600 hover:underline"
>
<button type="button" wire:click="removeProviderInstance('{{ $instance['id'] }}')"
class="text-xs text-red-600 hover:underline">
Verwijderen
</button>
</div>
@@ -123,13 +122,16 @@
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Naam</label>
<input wire:model="providerInstances.{{ $index }}.name" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.name" type="text"
class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Type</label>
<select wire:model="providerInstances.{{ $index }}.type" class="w-full border rounded p-2">
@foreach($providerDefinitions as $provider => $providerDefinition)
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}</option>
<select wire:model="providerInstances.{{ $index }}.type"
class="w-full border rounded p-2">
@foreach ($providerDefinitions as $provider => $providerDefinition)
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}
</option>
@endforeach
</select>
</div>
@@ -137,16 +139,19 @@
<div>
<label class="block text-sm mb-1">Base URL</label>
<input wire:model="providerInstances.{{ $index }}.base_url" type="url" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.base_url" type="url"
class="w-full border rounded p-2">
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Standaard chat model</label>
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text"
class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Standaard embedding model</label>
<input wire:model="providerInstances.{{ $index }}.embedding_model" type="text" class="w-full border rounded p-2">
<input wire:model="providerInstances.{{ $index }}.embedding_model"
type="text" class="w-full border rounded p-2">
</div>
</div>
</div>
@@ -155,25 +160,28 @@
</section>
@endif
@if($activeTab === 'models')
@if ($activeTab === 'models')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Modellen per stap</h3>
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst wordt 5 minuten gecachet.</p>
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst
wordt 5 minuten gecachet.</p>
</div>
<button type="button" wire:click="refreshModels" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="refreshModels"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Modellen verversen
</button>
</div>
@if($modelLoadError)
@if ($modelLoadError)
<div class="rounded bg-amber-100 p-2 text-sm text-amber-800">
Modellen konden niet live worden opgehaald: {{ $modelLoadError }}
</div>
@elseif($availableModels === [])
<div class="rounded bg-slate-100 p-2 text-sm text-slate-700">
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam invullen.
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam
invullen.
</div>
@else
<div class="rounded bg-green-100 p-2 text-sm text-green-800">
@@ -183,51 +191,48 @@
<div class="rounded border p-3 text-sm text-slate-600">
Actieve instance:
@foreach($providerInstances as $instance)
@if(($instance['id'] ?? null) === $activeProviderInstanceId)
<strong>{{ $instance['name'] }}</strong> ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
@foreach ($providerInstances as $instance)
@if (($instance['id'] ?? null) === $activeProviderInstanceId)
<strong>{{ $instance['name'] }}</strong>
({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
@endif
@endforeach
</div>
<div class="space-y-3">
@foreach($modelTasks as $task)
<x-admin.timeline-card
:number="$task['number']"
:title="$task['title']"
:description="$task['description']"
badge="Model"
>
@if($availableModels !== [])
<select wire:model="modelValues.{{ $task['id'] }}" class="w-full border rounded p-2 text-sm">
@foreach ($modelTasks as $task)
<x-admin.timeline-card :number="$task['number']" :title="$task['title']" :description="$task['description']" badge="Model">
@if ($availableModels !== [])
<select wire:model="modelValues.{{ $task['id'] }}"
class="w-full border rounded p-2 text-sm">
<option value="">Kies model</option>
@foreach($availableModels as $model)
@foreach ($availableModels as $model)
<option value="{{ $model }}">{{ $model }}</option>
@endforeach
</select>
@else
<input
wire:model="modelValues.{{ $task['id'] }}"
type="text"
class="w-full border rounded p-2 text-sm"
placeholder="Modelnaam"
>
<input wire:model="modelValues.{{ $task['id'] }}" type="text"
class="w-full border rounded p-2 text-sm" placeholder="Modelnaam">
@endif
@error('modelValues.'.$task['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
@error('modelValues.' . $task['id'])
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</x-admin.timeline-card>
@endforeach
</div>
</section>
@endif
@if($activeTab === 'embeddings')
@if ($activeTab === 'embeddings')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Chunk embeddings</h3>
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via
de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
</div>
<button type="button" wire:click="refreshEmbeddingStats" class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
<button type="button" wire:click="refreshEmbeddingStats"
class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
Status verversen
</button>
</div>
@@ -236,17 +241,21 @@
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Artikelen</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['articles'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }} zonder chunks</p>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }}
zonder chunks</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }} zonder embedding</p>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }}
zonder embedding</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks met embedding</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }} artikelen geindexeerd</p>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}
</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }}
artikelen geindexeerd</p>
</div>
</div>
@@ -263,8 +272,10 @@
</div>
</div>
<p class="mt-2 text-slate-600">
{{ $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.
{{ $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.
</p>
</div>
@@ -272,9 +283,11 @@
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alleen ontbrekende chunks genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de queue.</p>
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de
queue.</p>
</div>
<button type="button" wire:click="reindexMissingEmbeddings" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
<button type="button" wire:click="reindexMissingEmbeddings"
class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Ontbrekende chunks genereren
</button>
</div>
@@ -282,14 +295,12 @@
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alles opnieuw genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande chunks worden per artikel vervangen tijdens verwerking.</p>
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande
chunks worden per artikel vervangen tijdens verwerking.</p>
</div>
<button
type="button"
wire:click="reindexAllEmbeddings"
<button type="button" wire:click="reindexAllEmbeddings"
wire:confirm="Weet je zeker dat je alle artikelchunks opnieuw wilt genereren?"
class="rounded bg-red-700 px-3 py-2 text-sm text-white"
>
class="rounded bg-red-700 px-3 py-2 text-sm text-white">
Alle chunks opnieuw genereren
</button>
</div>

View File

@@ -2,31 +2,42 @@
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Ticket simulatie (handmatig inschieten)</h2>
@if($submitError)
@if ($submitError)
<div class="mb-3 rounded bg-red-100 text-red-700 px-3 py-2 text-sm">{{ $submitError }}</div>
@endif
@if($lastResult)
@if ($lastResult)
<div class="mb-3 rounded bg-green-100 text-green-800 px-3 py-2 text-sm">
Ticket #{{ $lastResult['ticket_id'] }} aangemaakt met status '{{ $lastResult['status'] }}'.
<a class="underline" href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
<a class="underline"
href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
</div>
@endif
<form wire:submit="submitTicket" class="space-y-3">
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28" placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
@error('newTicketMessage') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28"
placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
@error('newTicketMessage')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
<div class="grid md:grid-cols-2 gap-3">
<div>
<input wire:model="apiUser" type="text" class="w-full border rounded p-2" placeholder="API user (optioneel)">
@error('apiUser') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="apiUser" type="text" class="w-full border rounded p-2"
placeholder="API user (optioneel)">
@error('apiUser')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
<div>
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2" placeholder="API password/key (optioneel)">
@error('apiPassword') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2"
placeholder="API password/key (optioneel)">
@error('apiPassword')
<p class="text-red-600 text-sm">{{ $message }}</p>
@enderror
</div>
</div>
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt voor toegestane toolcalls.</p>
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt
voor toegestane toolcalls.</p>
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Ticket inschieten</button>
</form>
</div>
@@ -45,18 +56,20 @@
</div>
<div class="space-y-3">
@foreach($tickets as $ticket)
@foreach ($tickets as $ticket)
<div class="border rounded p-3">
<div class="flex items-center justify-between">
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
<span class="text-xs px-2 py-1 rounded bg-slate-100">{{ $ticket->status }}</span>
</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>
@if ($ticket->bestArticle)
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence:
{{ number_format((float) $ticket->confidence, 2) }}</div>
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
@endif
<a class="text-sm underline" href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
<a class="text-sm underline"
href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
</div>
@endforeach
</div>

View File

@@ -3,12 +3,9 @@
<div class="flex items-center justify-between">
<h2 class="font-semibold">Ticket #{{ $ticket->id }}</h2>
<div class="flex items-center gap-3">
<button
type="button"
wire:click="reprocess"
<button type="button" wire:click="reprocess"
wire:confirm="Weet je zeker dat je ticket #{{ $ticket->id }} opnieuw wilt verwerken?"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
class="text-sm px-3 py-1 rounded bg-slate-900 text-white">
Herverwerk ticket
</button>
<span class="text-sm px-2 py-1 rounded bg-slate-100">Status: {{ $ticket->status }}</span>
@@ -19,14 +16,14 @@
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('success') }}</div>
@endif
@if($ticket->needs_article_draft)
@if ($ticket->needs_article_draft)
<div class="mt-3 rounded bg-amber-50 border border-amber-300 p-3">
<div class="text-sm font-semibold text-amber-900 mb-1">Kennisbank-gat gedetecteerd</div>
<p class="text-sm text-amber-900">
Er is geen geschikt artikel in de kennisbank gevonden voor deze vraag.
</p>
@php($suggestion = $ticket->result_payload['draft_article_suggestion'] ?? null)
@if(is_array($suggestion))
@if (is_array($suggestion))
<div class="mt-3">
<p class="text-sm font-semibold text-amber-900">Voorgestelde titel</p>
<p class="text-sm text-amber-900">{{ $suggestion['title'] ?? '-' }}</p>
@@ -39,12 +36,13 @@
</div>
@endif
@if($ticket->support_reply && !$ticket->needs_article_draft)
@if ($ticket->support_reply && !$ticket->needs_article_draft)
<div class="mt-3 rounded bg-blue-50 border border-blue-200 p-3">
<div class="text-sm font-semibold text-blue-900 mb-1">Concept reactie voor klant</div>
@if(is_array($ticket->result_payload['quick_reply'] ?? null))
@if (is_array($ticket->result_payload['quick_reply'] ?? null))
<div class="mb-2 inline-flex rounded bg-blue-100 px-2 py-1 text-xs text-blue-900">
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }} {{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }}
{{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
</div>
@endif
<pre class="text-sm whitespace-pre-wrap text-blue-900">{{ $ticket->support_reply }}</pre>
@@ -52,24 +50,25 @@
@endif
<p class="mt-3 text-sm text-slate-800"><strong>Origineel:</strong> {{ $ticket->message }}</p>
@if($ticket->normalized_message)
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}</p>
@if ($ticket->normalized_message)
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}
</p>
@endif
@if($ticket->redaction_report)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@if ($ticket->redaction_report)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
@endif
@if(is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
@if (is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
<div class="mt-3 text-sm rounded bg-slate-100 text-slate-700 p-2">
API credentials aanwezig voor deze ticket-run. Waarden worden niet getoond.
</div>
@endif
@if($ticket->error_message)
@if ($ticket->error_message)
<div class="mt-3 text-sm rounded bg-red-100 text-red-700 p-2">{{ $ticket->error_message }}</div>
@endif
@if($ticket->bestArticle)
@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 }}
@if($ticket->confidence !== null)
@if ($ticket->confidence !== null)
(confidence {{ number_format($ticket->confidence, 2) }})
@endif
</div>
@@ -78,34 +77,35 @@
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="font-semibold mb-3">Toolcalls</h3>
@if($ticket->toolCalls->isEmpty())
@if ($ticket->toolCalls->isEmpty())
<p class="text-sm text-slate-600">Geen toolcalls uitgevoerd of voorgesteld.</p>
@else
<div class="space-y-2">
@foreach($ticket->toolCalls as $toolCall)
@foreach ($ticket->toolCalls as $toolCall)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<div class="font-medium">
{{ $toolCall->action }} ({{ $toolCall->status }})
@if($toolCall->article)
@if ($toolCall->article)
<span class="text-slate-500">via artikel #{{ $toolCall->article->id }}</span>
@endif
</div>
<span class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
<span
class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
</div>
@if($toolCall->parameters)
@if ($toolCall->parameters)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Parameters</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@endif
@if($toolCall->response)
@if ($toolCall->response)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Response</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}</pre>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
@if($toolCall->error)
@if ($toolCall->error)
<div class="mt-2 rounded bg-amber-50 text-amber-900 p-2">{{ $toolCall->error }}</div>
@endif
</div>
@@ -118,8 +118,9 @@
<h3 class="font-semibold mb-3">Verwerkingsstappen</h3>
@php($orderedLogs = $ticket->logs->sortBy([['created_at', 'desc'], ['id', 'desc']]))
@php($latestLog = $orderedLogs->first())
@if($ticket->status === 'processing' && $latestLog)
<div class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
@if ($ticket->status === 'processing' && $latestLog)
<div
class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
<span>Huidige stap: <strong>{{ $latestLog->step }}</strong></span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-blue-600 animate-pulse"></span>
@@ -128,17 +129,17 @@
</div>
@endif
<div class="space-y-2">
@foreach($orderedLogs as $log)
@foreach ($orderedLogs as $log)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium">{{ $log->step }} ({{ $log->status }})</span>
<span class="text-xs text-slate-500">{{ $log->created_at }}</span>
</div>
@if($log->message)
@if ($log->message)
<div class="text-slate-700 mt-1">{{ $log->message }}</div>
@endif
@if($log->context)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@if ($log->context)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
@endif
</div>
@endforeach

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
<?php
namespace Tests\Fakes;
use App\DTOs\ArticleCandidateDTO;
use App\Repositories\Contracts\ArticleRepositoryInterface;
class FakeArticleRepository implements ArticleRepositoryInterface
{
/** @var array<int, ArticleCandidateDTO> */
public array $candidates = [];
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array
{
return array_slice($this->candidates, 0, $limit);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Tests\Fakes;
use App\Services\Tools\DomainInfoTool;
use App\Services\Tools\OxxaClient;
class FakeDomainInfoTool extends DomainInfoTool
{
/** @var array<int, array{parameters:array,credentials:array}> */
public array $calls = [];
public array $response = ['ok' => true, 'data' => ['domain' => 'example.nl']];
public function __construct()
{
parent::__construct(new OxxaClient);
}
public function execute(array $parameters, array $credentials): array
{
$this->calls[] = [
'parameters' => $parameters,
'credentials' => $credentials,
];
return $this->response;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Tests\Fakes;
use App\Services\Llm\LlmClientInterface;
class FakeLlmClient implements LlmClientInterface
{
/** @var array<string, array<int, float>> */
public array $embeddings = [];
/** @var array<int, string> */
public array $responses = [];
/** @var array<int, array{prompt:string, options:array}> */
public array $generatedPrompts = [];
public function embed(string $text): array
{
return $this->embeddings[$text] ?? [0.1, 0.2, 0.3];
}
public function generate(string $prompt, array $options = []): string
{
$this->generatedPrompts[] = [
'prompt' => $prompt,
'options' => $options,
];
return array_shift($this->responses) ?? '{}';
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Tests\Fakes;
use App\Models\Ticket;
use App\Services\TicketProcessingLoggerService;
class FakeTicketProcessingLoggerService extends TicketProcessingLoggerService
{
/** @var array<int, array{step:string,status:string,message:string|null,context:array}> */
public array $logs = [];
public function log(Ticket $ticket, string $step, string $status = 'info', ?string $message = null, array $context = []): void
{
$this->logs[] = [
'step' => $step,
'status' => $status,
'message' => $message,
'context' => $context,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ArticleApiTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_article_with_note_and_allowed_actions(): void
{
$response = $this->postJson('/api/articles', [
'title' => 'DNS instellen',
'content' => 'Stap 1...',
'note' => 'Alleen voor DNS vragen',
'allowed_actions' => ['domain_inf'],
]);
$response->assertStatus(201);
$article = Article::query()->first();
$this->assertNotNull($article);
$this->assertSame('Alleen voor DNS vragen', $article->note);
$this->assertSame(['domain_inf'], $article->allowed_actions);
}
public function test_it_rejects_invalid_allowed_action(): void
{
$response = $this->postJson('/api/articles', [
'title' => 'DNS instellen',
'content' => 'Stap 1...',
'allowed_actions' => ['invalid_action'],
]);
$response->assertStatus(422);
}
public function test_it_lists_articles(): void
{
Article::query()->create(['title' => 'A', 'content' => 'B']);
$response = $this->getJson('/api/articles');
$response->assertOk();
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Tests\Feature;
use App\Jobs\ProcessTicketJob;
use App\Models\Article;
use App\Models\QuickReply;
use App\Models\Ticket;
use App\Services\EmbeddingService;
use App\Services\KnowledgeGapService;
use App\Services\QuickReplyResolver;
use App\Services\SemanticSearchService;
use App\Services\SupportReplyService;
use App\Services\TicketNormalizationService;
use App\Services\TicketProcessingLoggerService;
use App\Services\TicketResultPayloadBuilder;
use App\Services\Tools\TicketToolCallService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProcessTicketJobFlowTest extends TestCase
{
use RefreshDatabase;
public function test_it_uses_quick_reply_and_skips_tool_call(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen']);
$quickReply = QuickReply::query()->create(['title' => 'DNS Quick', 'content' => 'Gebruik DNS quick antwoord', 'is_active' => true]);
$article->quickReplies()->sync([$quickReply->id]);
$ticket = Ticket::query()->create(['message' => 'DNS vraag', 'status' => 'queued']);
$embedding = $this->createMock(EmbeddingService::class);
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
$semantic = $this->createMock(SemanticSearchService::class);
$semantic->method('findBestArticle')->willReturn([
'best_article' => $article,
'confidence' => 0.9,
'explanation' => 'match',
'top_3_candidates' => [],
'top_5_candidates' => [],
'retrieval_meta' => [],
'requested_tool_call' => ['action' => 'domain_inf', 'parameters' => ['sld' => 'example', 'tld' => 'nl']],
'classifier_raw_response' => ['mode' => 'llm'],
]);
$normalizer = $this->createMock(TicketNormalizationService::class);
$normalizer->method('normalize')->willReturn([
'normalized_message' => 'dns vraag',
'redaction_report' => ['language' => 'nl'],
]);
$logger = app(TicketProcessingLoggerService::class);
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
$knowledgeGap->method('shouldCreateDraft')->willReturn(false);
$toolCallService = $this->createMock(TicketToolCallService::class);
$toolCallService->expects($this->never())->method('executeRequestedTool');
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
$quickReplyResolver->method('resolveForArticle')->willReturn($quickReply);
$supportReply = $this->createMock(SupportReplyService::class);
$supportReply->expects($this->never())->method('build');
$job = new ProcessTicketJob($ticket->id);
$job->handle(
$embedding,
$semantic,
$normalizer,
$logger,
$knowledgeGap,
$toolCallService,
$quickReplyResolver,
$supportReply,
new TicketResultPayloadBuilder
);
$ticket->refresh();
$this->assertSame('completed', $ticket->status);
$this->assertSame('Gebruik DNS quick antwoord', $ticket->support_reply);
$this->assertSame($quickReply->id, $ticket->result_payload['quick_reply']['id']);
}
public function test_it_executes_tool_call_when_no_quick_reply_exists(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen', 'allowed_actions' => ['domain_inf']]);
$ticket = Ticket::query()->create([
'message' => 'DNS vraag',
'status' => 'queued',
'api_credentials' => ['apiuser' => 'u', 'apipassword' => 'p'],
]);
$embedding = $this->createMock(EmbeddingService::class);
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
$semantic = $this->createMock(SemanticSearchService::class);
$semantic->method('findBestArticle')->willReturn([
'best_article' => $article,
'confidence' => 0.9,
'explanation' => 'match',
'top_3_candidates' => [],
'top_5_candidates' => [],
'retrieval_meta' => [],
'requested_tool_call' => ['action' => 'domain_inf', 'parameters' => ['sld' => 'example', 'tld' => 'nl']],
'classifier_raw_response' => ['mode' => 'llm'],
]);
$normalizer = $this->createMock(TicketNormalizationService::class);
$normalizer->method('normalize')->willReturn([
'normalized_message' => 'dns vraag',
'redaction_report' => ['language' => 'nl'],
]);
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
$knowledgeGap->method('shouldCreateDraft')->willReturn(false);
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
$quickReplyResolver->method('resolveForArticle')->willReturn(null);
$toolRecord = new \App\Models\TicketToolCall([
'action' => 'domain_inf',
'status' => 'success',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
]);
$toolCallService = $this->createMock(TicketToolCallService::class);
$toolCallService->method('executeRequestedTool')->willReturn($toolRecord);
$supportReply = $this->createMock(SupportReplyService::class);
$supportReply->method('build')->willReturn('1. Doe stap 1');
$job = new ProcessTicketJob($ticket->id);
$job->handle(
$embedding,
$semantic,
$normalizer,
app(TicketProcessingLoggerService::class),
$knowledgeGap,
$toolCallService,
$quickReplyResolver,
$supportReply,
new TicketResultPayloadBuilder
);
$ticket->refresh();
$this->assertSame('completed', $ticket->status);
$this->assertSame('1. Doe stap 1', $ticket->support_reply);
$this->assertSame('domain_inf', $ticket->result_payload['requested_tool_call']['action']);
}
public function test_it_marks_knowledge_gap_and_skips_customer_reply(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'stappen']);
$ticket = Ticket::query()->create(['message' => 'DNS vraag', 'status' => 'queued']);
$embedding = $this->createMock(EmbeddingService::class);
$embedding->method('embed')->willReturn(array_fill(0, 768, 0.1));
$embedding->method('context')->willReturn(['provider_instance_id' => 'p1', 'embedding_model' => 'm1']);
$semantic = $this->createMock(SemanticSearchService::class);
$semantic->method('findBestArticle')->willReturn([
'best_article' => $article,
'confidence' => 0.2,
'explanation' => 'low confidence',
'top_3_candidates' => [],
'top_5_candidates' => [],
'retrieval_meta' => [],
'requested_tool_call' => null,
'classifier_raw_response' => ['mode' => 'llm'],
]);
$normalizer = $this->createMock(TicketNormalizationService::class);
$normalizer->method('normalize')->willReturn([
'normalized_message' => 'dns vraag',
'redaction_report' => ['language' => 'nl'],
]);
$knowledgeGap = $this->createMock(KnowledgeGapService::class);
$knowledgeGap->method('shouldCreateDraft')->willReturn(true);
$knowledgeGap->method('suggestArticleDraft')->willReturn([
'title' => 'Nieuwe DNS handleiding',
'content' => 'Nog aan te vullen',
]);
$toolCallService = $this->createMock(TicketToolCallService::class);
$toolCallService->expects($this->never())->method('executeRequestedTool');
$quickReplyResolver = $this->createMock(QuickReplyResolver::class);
$quickReplyResolver->expects($this->never())->method('resolveForArticle');
$supportReply = $this->createMock(SupportReplyService::class);
$supportReply->expects($this->never())->method('build');
$job = new ProcessTicketJob($ticket->id);
$job->handle(
$embedding,
$semantic,
$normalizer,
app(TicketProcessingLoggerService::class),
$knowledgeGap,
$toolCallService,
$quickReplyResolver,
$supportReply,
new TicketResultPayloadBuilder
);
$ticket->refresh();
$this->assertTrue($ticket->needs_article_draft);
$this->assertNull($ticket->support_reply);
$this->assertSame('Nieuwe DNS handleiding', $ticket->result_payload['draft_article_suggestion']['title']);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Tests\Feature;
use App\Livewire\Admin\QuickReplyManager;
use App\Models\QuickReply;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class QuickReplyAdminTest extends TestCase
{
use RefreshDatabase;
public function test_it_can_create_and_update_quick_reply(): void
{
Livewire::test(QuickReplyManager::class)
->set('title', 'DNS Basis')
->set('content', 'Gebruik deze stappen')
->set('isActive', true)
->call('save');
$reply = QuickReply::query()->first();
$this->assertNotNull($reply);
Livewire::test(QuickReplyManager::class)
->set("editRows.{$reply->id}.title", 'DNS Geupdated')
->set("editRows.{$reply->id}.content", 'Nieuwe content')
->set("editRows.{$reply->id}.is_active", false)
->call('updateQuickReply', $reply->id);
$reply->refresh();
$this->assertSame('DNS Geupdated', $reply->title);
$this->assertFalse($reply->is_active);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\QuickReply;
use App\Models\Ticket;
use App\Models\TicketToolCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TicketAndArticleModelTest extends TestCase
{
use RefreshDatabase;
public function test_article_quick_reply_pivot_and_cascade_delete(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$reply = QuickReply::query()->create(['title' => 'Quick', 'content' => 'y', 'is_active' => true]);
$article->quickReplies()->sync([$reply->id]);
$this->assertCount(1, $article->quickReplies);
$article->delete();
$pivotCount = DB::table('article_quick_reply')->count();
$this->assertSame(0, $pivotCount);
}
public function test_ticket_credentials_are_stored_encrypted_and_decrypted_via_cast(): void
{
$ticket = Ticket::query()->create([
'message' => 'vraag',
'api_credentials' => ['apiuser' => 'demo', 'apipassword' => 'secret'],
]);
$this->assertSame('demo', $ticket->api_credentials['apiuser']);
$raw = DB::table('tickets')->where('id', $ticket->id)->value('api_credentials');
$this->assertIsString($raw);
$this->assertStringNotContainsString('secret', $raw);
}
public function test_ticket_tool_call_casts_arrays(): void
{
$ticket = Ticket::query()->create(['message' => 'vraag']);
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$toolCall = TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => 'domain_inf',
'status' => 'success',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
'response' => ['ok' => true],
]);
$toolCall->refresh();
$this->assertSame('example', $toolCall->parameters['sld']);
$this->assertTrue($toolCall->response['ok']);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature;
use App\Jobs\ProcessTicketJob;
use App\Models\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class TicketIngestionTest extends TestCase
{
use RefreshDatabase;
public function test_it_ingests_ticket_and_dispatches_processing_job(): void
{
Queue::fake();
$response = $this->postJson('/api/tickets', [
'message' => 'Mijn mail werkt niet',
'api_credentials' => [
'apiuser' => 'demo',
'apipassword' => 'secret',
],
]);
$response->assertStatus(202)
->assertJsonPath('status', 'queued');
$ticket = Ticket::query()->latest('id')->first();
$this->assertNotNull($ticket);
$this->assertSame('queued', $ticket->status);
$this->assertSame('demo', $ticket->api_credentials['apiuser']);
$raw = DB::table('tickets')->where('id', $ticket->id)->value('api_credentials');
$this->assertIsString($raw);
$this->assertStringNotContainsString('secret', $raw);
Queue::assertPushed(ProcessTicketJob::class);
}
public function test_it_validates_required_message(): void
{
$response = $this->postJson('/api/tickets', []);
$response->assertStatus(422);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Ticket;
use App\Models\TicketToolCall;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketShowPageTest extends TestCase
{
use RefreshDatabase;
public function test_ticket_show_renders_quick_reply_and_tool_call_sections(): void
{
$article = Article::query()->create(['title' => 'DNS', 'content' => 'x']);
$ticket = Ticket::query()->create([
'message' => 'vraag',
'status' => 'completed',
'best_article_id' => $article->id,
'support_reply' => 'Gebruik deze stappen',
'result_payload' => [
'quick_reply' => ['id' => 1, 'title' => 'DNS Quick'],
'draft_article_suggestion' => null,
],
]);
TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => 'domain_inf',
'status' => 'success',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
'response' => ['ok' => true],
]);
$response = $this->get("/admin/tickets/{$ticket->id}");
$response->assertOk();
$response->assertSee('Snelantwoord gebruikt', false);
$response->assertSee('Toolcalls', false);
}
}

View File

@@ -6,5 +6,4 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View File

@@ -41,22 +41,9 @@ class AIClassifierServiceTest extends TestCase
}
};
$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,
$this->fakeSettings(),
new ClassifierPromptBuilder,
new LlmJsonDecoder,
new ToolCallRequestValidator
@@ -81,4 +68,122 @@ class AIClassifierServiceTest extends TestCase
$this->assertStringContainsString('Allowed actions: ["domain_inf"]', $client->prompt);
$this->assertStringContainsString('Internal note for support assistant', $client->prompt);
}
public function test_it_falls_back_when_ranking_disabled(): void
{
config()->set('services.llm.ranking_enabled', false);
$client = new class implements LlmClientInterface
{
public function embed(string $text): array
{
return [];
}
public function generate(string $prompt, array $options = []): string
{
return '{}';
}
};
$service = new AIClassifierService(
$client,
$this->fakeSettings(),
new ClassifierPromptBuilder,
new LlmJsonDecoder,
new ToolCallRequestValidator
);
$result = $service->rank('vraag', [$this->candidate(15)]);
$this->assertSame(15, $result->articleId);
$this->assertSame(0.2, $result->confidence);
}
public function test_it_falls_back_when_llm_json_is_invalid(): void
{
config()->set('services.llm.ranking_enabled', true);
$client = new class implements LlmClientInterface
{
public function embed(string $text): array
{
return [];
}
public function generate(string $prompt, array $options = []): string
{
return 'not-json';
}
};
$service = new AIClassifierService(
$client,
$this->fakeSettings(),
new ClassifierPromptBuilder,
new LlmJsonDecoder,
new ToolCallRequestValidator
);
$result = $service->rank('vraag', [$this->candidate(21)]);
$this->assertSame(21, $result->articleId);
$this->assertStringContainsString('invalid JSON', $result->explanation);
}
public function test_it_falls_back_when_article_id_not_in_candidates(): void
{
$client = new class implements LlmClientInterface
{
public function embed(string $text): array
{
return [];
}
public function generate(string $prompt, array $options = []): string
{
return json_encode([
'article_id' => 999,
'confidence' => 0.9,
'explanation' => 'x',
]);
}
};
$service = new AIClassifierService(
$client,
$this->fakeSettings(),
new ClassifierPromptBuilder,
new LlmJsonDecoder,
new ToolCallRequestValidator
);
$result = $service->rank('vraag', [$this->candidate(33)]);
$this->assertSame(33, $result->articleId);
$this->assertStringContainsString('schema invalid', $result->explanation);
}
private function fakeSettings(): AppSettingsService
{
return 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;
}
};
}
private function candidate(int $id): ArticleCandidateDTO
{
return new ArticleCandidateDTO(
articleId: $id,
title: 'A',
content: 'B',
distance: 0.2
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests\Unit;
use App\DTOs\ArticleCandidateDTO;
use App\Services\ClassifierPromptBuilder;
use PHPUnit\Framework\TestCase;
class ClassifierPromptBuilderTest extends TestCase
{
public function test_it_builds_prompt_with_articles_notes_and_actions(): void
{
$builder = new ClassifierPromptBuilder;
$prompt = $builder->build(
'Base prompt',
'Hoe stel ik DNS in?',
[
new ArticleCandidateDTO(
articleId: 10,
title: 'DNS',
content: 'Stappen voor DNS.',
distance: 0.12,
sourceUrl: 'https://example.test/article',
note: 'Alleen gebruiken voor DNS.',
allowedActions: ['domain_inf']
),
],
'nl'
);
$this->assertStringContainsString('Base prompt', $prompt);
$this->assertStringContainsString('User language: nl', $prompt);
$this->assertStringContainsString('Allowed actions: ["domain_inf"]', $prompt);
$this->assertStringContainsString('Internal note for support assistant', $prompt);
$this->assertStringContainsString('"tool_call"', $prompt);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Tests\Unit;
use App\Exceptions\OllamaUnavailableException;
use App\Models\EmbeddingCache;
use App\Services\AppSettingsService;
use App\Services\EmbeddingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fakes\FakeLlmClient;
use Tests\TestCase;
class EmbeddingServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_cached_embedding_without_new_llm_call(): void
{
$settings = $this->fakeSettings();
$llm = new FakeLlmClient;
$service = new EmbeddingService($llm, $settings);
EmbeddingCache::query()->create([
'provider_instance_id' => 'instance-1',
'embedding_model' => 'embed-model',
'text_hash' => hash('sha256', 'abc'),
'text' => 'abc',
'embedding' => [0.9, 0.8],
]);
$embedding = $service->embed('abc');
$this->assertSame([0.9, 0.8], $embedding);
$this->assertCount(0, $llm->generatedPrompts);
}
public function test_it_throws_when_embedding_is_empty(): void
{
$settings = $this->fakeSettings();
$llm = new FakeLlmClient;
$llm->embeddings['abc'] = [];
$service = new EmbeddingService($llm, $settings);
$this->expectException(OllamaUnavailableException::class);
$service->embed('abc');
}
private function fakeSettings(): AppSettingsService
{
return new class extends AppSettingsService {
public function activeProviderInstance(): array
{
return ['id' => 'instance-1', 'embedding_model' => 'embed-model'];
}
public function activeProviderInstanceId(): string
{
return 'instance-1';
}
public function get(string $key, ?string $default = null): ?string
{
if ($key === 'llm.models.embedding') {
return 'embed-model';
}
return $default;
}
};
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\Services\LlmJsonDecoder;
use PHPUnit\Framework\TestCase;
class LlmJsonDecoderTest extends TestCase
{
public function test_it_decodes_plain_json(): void
{
$decoder = new LlmJsonDecoder;
$decoded = $decoder->decode('{"a":1}');
$this->assertSame(['a' => 1], $decoded);
}
public function test_it_decodes_fenced_json(): void
{
$decoder = new LlmJsonDecoder;
$decoded = $decoder->decode("```json\n{\"a\":2}\n```");
$this->assertSame(['a' => 2], $decoded);
}
public function test_it_extracts_json_from_mixed_text(): void
{
$decoder = new LlmJsonDecoder;
$decoded = $decoder->decode("noise before {\"a\":3} noise after");
$this->assertSame(['a' => 3], $decoded);
}
public function test_it_returns_null_on_invalid_json(): void
{
$decoder = new LlmJsonDecoder;
$this->assertNull($decoder->decode('no json here'));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\Models\Article;
use App\Models\QuickReply;
use App\Services\QuickReplyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class QuickReplyResolverTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_null_without_article(): void
{
$resolver = new QuickReplyResolver;
$this->assertNull($resolver->resolveForArticle(null));
}
public function test_it_returns_first_active_quick_reply(): void
{
$article = Article::query()->create([
'title' => 'DNS',
'content' => 'content',
]);
$inactive = QuickReply::query()->create(['title' => 'B Reply', 'content' => 'B', 'is_active' => false]);
$activeB = QuickReply::query()->create(['title' => 'Z Reply', 'content' => 'Z', 'is_active' => true]);
$activeA = QuickReply::query()->create(['title' => 'A Reply', 'content' => 'A', 'is_active' => true]);
$article->quickReplies()->sync([$inactive->id, $activeB->id, $activeA->id]);
$resolver = new QuickReplyResolver;
$resolved = $resolver->resolveForArticle($article);
$this->assertInstanceOf(QuickReply::class, $resolved);
$this->assertSame($activeA->id, $resolved->id);
}
}

View File

@@ -0,0 +1,68 @@
<?php
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;
use Tests\TestCase;
class SupportReplyServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_llm_response_when_available(): void
{
$llm = new FakeLlmClient;
$llm->responses = ['1. Doe X'];
$service = new SupportReplyService(
$this->fakeSettings(),
$llm,
new FakeTicketProcessingLoggerService
);
$ticket = Ticket::query()->create(['message' => 'vraag', 'normalized_message' => 'vraag']);
$article = Article::query()->create(['title' => 'DNS', 'content' => 'steps']);
$reply = $service->build($ticket, $article, 'relevant');
$this->assertSame('1. Doe X', $reply);
$this->assertStringContainsString('Gebruikersvraag (genormaliseerd): vraag', $llm->generatedPrompts[0]['prompt']);
}
public function test_it_falls_back_when_llm_returns_empty(): void
{
$llm = new FakeLlmClient;
$llm->responses = [''];
$service = new SupportReplyService(
$this->fakeSettings(),
$llm,
new FakeTicketProcessingLoggerService
);
$ticket = Ticket::query()->create(['message' => 'vraag']);
$article = Article::query()->create(['title' => 'DNS', 'content' => 'steps']);
$reply = $service->build($ticket, $article, 'relevant');
$this->assertStringContainsString('Gebruik het kennisbankartikel', $reply);
}
private function fakeSettings(): AppSettingsService
{
return new class extends AppSettingsService {
public function getPrompt(string $key, ?string $default = null): ?string
{
return 'Prompt';
}
};
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Tests\Unit;
use App\Models\QuickReply;
use App\Models\TicketToolCall;
use App\Services\TicketResultPayloadBuilder;
use PHPUnit\Framework\TestCase;
class TicketResultPayloadBuilderTest extends TestCase
{
public function test_it_builds_payload_with_optional_fields(): void
{
$builder = new TicketResultPayloadBuilder;
$quickReply = new QuickReply(['id' => 7, 'title' => 'Quick']);
$quickReply->id = 7;
$toolCall = new TicketToolCall(['action' => 'domain_inf', 'status' => 'success']);
$payload = $builder->build(
[
'top_3_candidates' => [['article_id' => 1]],
'top_5_candidates' => [['article_id' => 1], ['article_id' => 2]],
'classifier_raw_response' => ['mode' => 'llm'],
'requested_tool_call' => ['action' => 'domain_inf'],
],
$toolCall,
$quickReply,
false,
null
);
$this->assertSame(7, $payload['quick_reply']['id']);
$this->assertSame('domain_inf', $payload['requested_tool_call']['action']);
$this->assertFalse($payload['knowledge_gap']);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Tests\Unit;
use App\Models\Article;
use App\Models\Ticket;
use App\Services\Tools\TicketToolCallService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Fakes\FakeDomainInfoTool;
use Tests\Fakes\FakeTicketProcessingLoggerService;
use Tests\TestCase;
class TicketToolCallServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_skips_when_action_not_allowed(): void
{
$service = new TicketToolCallService(new FakeDomainInfoTool, new FakeTicketProcessingLoggerService);
$article = Article::query()->create(['title' => 'A', 'content' => 'B', 'allowed_actions' => []]);
$ticket = Ticket::query()->create(['message' => 'vraag']);
$record = $service->executeRequestedTool($ticket, $article, [
'action' => 'domain_inf',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
]);
$this->assertNotNull($record);
$this->assertSame('skipped', $record->status);
}
public function test_it_executes_domain_info_when_allowed_and_credentials_present(): void
{
$fakeTool = new FakeDomainInfoTool;
$service = new TicketToolCallService($fakeTool, new FakeTicketProcessingLoggerService);
$article = Article::query()->create(['title' => 'A', 'content' => 'B', 'allowed_actions' => ['domain_inf']]);
$ticket = Ticket::query()->create([
'message' => 'vraag',
'api_credentials' => ['apiuser' => 'u', 'apipassword' => 'p'],
]);
$record = $service->executeRequestedTool($ticket, $article, [
'action' => 'domain_inf',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
]);
$this->assertNotNull($record);
$this->assertSame('success', $record->status);
$this->assertCount(1, $fakeTool->calls);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use App\Services\ToolCallRequestValidator;
use PHPUnit\Framework\TestCase;
class ToolCallRequestValidatorTest extends TestCase
{
public function test_it_validates_domain_inf_tool_call(): void
{
$validator = new ToolCallRequestValidator;
$validated = $validator->validate([
'action' => 'domain_inf',
'parameters' => ['sld' => 'Example', 'tld' => 'NL'],
'reason' => 'Needed',
]);
$this->assertSame([
'action' => 'domain_inf',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
'reason' => 'Needed',
], $validated);
}
public function test_it_rejects_missing_parameters(): void
{
$validator = new ToolCallRequestValidator;
$this->assertNull($validator->validate([
'action' => 'domain_inf',
'parameters' => ['sld' => 'example'],
]));
}
public function test_it_rejects_unknown_action(): void
{
$validator = new ToolCallRequestValidator;
$this->assertNull($validator->validate([
'action' => 'dns_update',
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
]));
}
}