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:
81
app/Http/Controllers/Api/NearestArticleController.php
Normal file
81
app/Http/Controllers/Api/NearestArticleController.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ class ArticleManager extends Component
|
|||||||
|
|
||||||
public array $allowedActions = [];
|
public array $allowedActions = [];
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
|
public ?int $editingArticleId = null;
|
||||||
|
|
||||||
|
public string $editTitle = '';
|
||||||
|
|
||||||
|
public string $editContent = '';
|
||||||
|
|
||||||
public array $articleNotes = [];
|
public array $articleNotes = [];
|
||||||
|
|
||||||
public array $articleAllowedActions = [];
|
public array $articleAllowedActions = [];
|
||||||
@@ -55,6 +63,51 @@ class ArticleManager extends Component
|
|||||||
$this->resetPage();
|
$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
|
public function saveMetadata(int $articleId, AdminArticleService $service): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
@@ -90,8 +143,8 @@ class ArticleManager extends Component
|
|||||||
|
|
||||||
public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService)
|
public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService)
|
||||||
{
|
{
|
||||||
$articles = $service->paginate(10);
|
$articles = $service->paginate(10, $this->search);
|
||||||
$this->hydrateArticleMetadataState($articles->items());
|
$this->fillArticleMetadataState($articles->items());
|
||||||
|
|
||||||
return view('livewire.admin.article-manager', [
|
return view('livewire.admin.article-manager', [
|
||||||
'articles' => $articles,
|
'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) {
|
foreach ($articles as $article) {
|
||||||
if (! array_key_exists($article->id, $this->articleNotes)) {
|
if (! array_key_exists($article->id, $this->articleNotes)) {
|
||||||
|
|||||||
@@ -9,13 +9,20 @@ use App\Repositories\Contracts\ArticleRepositoryInterface;
|
|||||||
|
|
||||||
class ArticleRepository implements 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)).']';
|
$vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']';
|
||||||
|
|
||||||
$chunkDistances = ArticleChunk::query()
|
$chunkDistances = ArticleChunk::query()
|
||||||
->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector])
|
->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector])
|
||||||
->whereNotNull('embedding')
|
->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) {
|
->when($embeddingContext !== [], function ($query) use ($embeddingContext) {
|
||||||
$query
|
$query
|
||||||
->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null)
|
->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null)
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ use App\DTOs\ArticleCandidateDTO;
|
|||||||
interface ArticleRepositoryInterface
|
interface ArticleRepositoryInterface
|
||||||
{
|
{
|
||||||
/** @return array<ArticleCandidateDTO> */
|
/** @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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,29 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
class AdminArticleService
|
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()
|
return Article::query()
|
||||||
->with('quickReplies')
|
->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()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
}
|
}
|
||||||
@@ -34,6 +53,25 @@ class AdminArticleService
|
|||||||
return (bool) Article::query()->whereKey($articleId)->delete();
|
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
|
public function updateMetadata(int $articleId, ?string $note, array $allowedActions, array $quickReplyIds = []): bool
|
||||||
{
|
{
|
||||||
$article = Article::query()->find($articleId);
|
$article = Article::query()->find($articleId);
|
||||||
|
|||||||
@@ -130,6 +130,20 @@ class AppSettingsService
|
|||||||
return $default;
|
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
|
public function promptSettings(): array
|
||||||
{
|
{
|
||||||
$settings = $this->all();
|
$settings = $this->all();
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class KnowledgeGapService
|
class KnowledgeGapService
|
||||||
{
|
{
|
||||||
|
public const CONFIDENCE_THRESHOLD = 0.45;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LlmClientInterface $llmClient,
|
private readonly LlmClientInterface $llmClient,
|
||||||
private readonly AppSettingsService $settings,
|
private readonly AppSettingsService $settings,
|
||||||
@@ -16,7 +18,7 @@ class KnowledgeGapService
|
|||||||
public function shouldCreateDraft(Ticket $ticket, array $result): bool
|
public function shouldCreateDraft(Ticket $ticket, array $result): bool
|
||||||
{
|
{
|
||||||
$confidence = (float) ($result['confidence'] ?? 0);
|
$confidence = (float) ($result['confidence'] ?? 0);
|
||||||
if ($confidence < 0.45) {
|
if ($confidence < self::CONFIDENCE_THRESHOLD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ class KnowledgeGapService
|
|||||||
$prompt = $basePrompt."\n\n".
|
$prompt = $basePrompt."\n\n".
|
||||||
"Klantvraag:\n{$question}\n\n".
|
"Klantvraag:\n{$question}\n\n".
|
||||||
"Originele taal: {$language}. Schrijf titel en inhoud in deze taal.\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".
|
"Huidige kandidaten (mogelijk onvoldoende):\n{$topCandidates}\n\n".
|
||||||
'Content moet praktisch zijn met duidelijke stappen.';
|
'Content moet praktisch zijn met duidelijke stappen.';
|
||||||
|
|
||||||
|
|||||||
@@ -18,18 +18,22 @@ class SupportReplyService
|
|||||||
public function build(Ticket $ticket, ?Article $bestArticle, string $explanation, ?array $toolCall = null): string
|
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.');
|
$basePrompt = $this->settings->getPrompt('support_reply', 'Give only direct advice in the requested output language.');
|
||||||
|
$toneInstruction = $this->settings->toneInstruction();
|
||||||
|
|
||||||
if ($bestArticle === null) {
|
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)) {
|
if ((bool) config('services.llm.ranking_enabled', true)) {
|
||||||
$userInput = $ticket->normalized_message ?: $ticket->message;
|
$userInput = $ticket->normalized_message ?: $ticket->message;
|
||||||
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
|
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
|
||||||
|
$articleUrl = $bestArticle->source_url ?: 'niet beschikbaar';
|
||||||
$llmPrompt = $basePrompt."\n\n".
|
$llmPrompt = $basePrompt."\n\n".
|
||||||
"Customer language: {$language}. Write the advice in this language.\n".
|
"Customer language: {$language}. Write the advice in this language.\n".
|
||||||
|
"Aanspreekvorm: {$toneInstruction}\n".
|
||||||
'Gebruikersvraag (genormaliseerd): '.$userInput."\n".
|
'Gebruikersvraag (genormaliseerd): '.$userInput."\n".
|
||||||
'Beste artikel titel: '.$bestArticle->title."\n".
|
'Beste artikel titel: '.$bestArticle->title."\n".
|
||||||
|
'Beste artikel URL: '.$articleUrl."\n".
|
||||||
'Beste artikel content: '.$bestArticle->content."\n".
|
'Beste artikel content: '.$bestArticle->content."\n".
|
||||||
'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n".
|
'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n".
|
||||||
'Waarom dit artikel gekozen is: '.$explanation."\n\n".
|
'Waarom dit artikel gekozen is: '.$explanation."\n\n".
|
||||||
@@ -39,7 +43,9 @@ class SupportReplyService
|
|||||||
"- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n".
|
"- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n".
|
||||||
"- Begin direct met de oplossing.\n".
|
"- Begin direct met de oplossing.\n".
|
||||||
"- Geef 3-6 genummerde actiepunten.\n".
|
"- Geef 3-6 genummerde actiepunten.\n".
|
||||||
|
"- Volg de aanspreekvorm exact.\n".
|
||||||
"- Voeg een korte controle-stap toe als laatste punt.\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.';
|
'- Geen markdown, geen codeblokken.';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +75,8 @@ class SupportReplyService
|
|||||||
"3. Voer de herstel- of controleacties uit die in het artikel worden genoemd.\n".
|
"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".
|
"4. Controleer daarna of het oorspronkelijke probleem niet meer optreedt.\n\n".
|
||||||
'Relevantie: '.$safeExplanation."\n".
|
'Relevantie: '.$safeExplanation."\n".
|
||||||
'Bronsamenvatting: '.$snippet;
|
'Bronsamenvatting: '.$snippet."\n".
|
||||||
|
'Bron: '.($bestArticle->source_url ?: 'niet beschikbaar');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatToolCallForPrompt(?array $toolCall): string
|
private function formatToolCallForPrompt(?array $toolCall): string
|
||||||
@@ -100,7 +107,7 @@ class SupportReplyService
|
|||||||
|| str_contains($lower, 'curl error');
|
|| str_contains($lower, 'curl error');
|
||||||
|
|
||||||
if ($hasTechnicalFailure) {
|
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;
|
return $explanation;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
247
resources/views/admin/knowledge-search-demo.blade.php
Normal file
247
resources/views/admin/knowledge-search-demo.blade.php
Normal 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&limit=5&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>
|
||||||
25
resources/views/components/admin/confidence-badge.blade.php
Normal file
25
resources/views/components/admin/confidence-badge.blade.php
Normal 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>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<nav class="flex gap-3 text-sm">
|
<nav class="flex gap-3 text-sm">
|
||||||
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
|
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
|
||||||
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</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.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
|
||||||
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</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.process') }}" class="hover:underline">Proces</a>
|
||||||
|
|||||||
@@ -35,10 +35,51 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl p-4 shadow">
|
<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">
|
<div class="space-y-3">
|
||||||
@foreach ($articles as $article)
|
@forelse ($articles as $article)
|
||||||
<div class="border rounded p-3">
|
<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>
|
||||||
|
<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 class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
|
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
|
||||||
@@ -55,6 +96,10 @@
|
|||||||
Valideren & publiceren
|
Valideren & publiceren
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@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 }})"
|
<button type="button" wire:click="deleteArticle({{ $article->id }})"
|
||||||
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
|
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">
|
||||||
@@ -102,8 +147,9 @@
|
|||||||
<div class="grid gap-1 sm:grid-cols-2">
|
<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">
|
<label class="flex items-center gap-2 text-sm">
|
||||||
<input wire:model="articleQuickReplies.{{ $article->id }}" type="checkbox"
|
<input wire:model="articleQuickReplies.{{ $article->id }}"
|
||||||
value="{{ $quickReply->id }}" class="rounded border-slate-300">
|
type="checkbox" value="{{ $quickReply->id }}"
|
||||||
|
class="rounded border-slate-300">
|
||||||
<span>{{ $quickReply->title }}</span>
|
<span>{{ $quickReply->title }}</span>
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -118,8 +164,13 @@
|
|||||||
Metadata opslaan
|
Metadata opslaan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@empty
|
||||||
|
<div class="border rounded p-4 text-sm text-slate-500">
|
||||||
|
Geen artikelen gevonden.
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">{{ $articles->links() }}</div>
|
<div class="mt-4">{{ $articles->links() }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,8 +64,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
|
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
|
||||||
@if ($ticket->bestArticle)
|
@if ($ticket->bestArticle)
|
||||||
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence:
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{{ number_format((float) $ticket->confidence, 2) }}</div>
|
<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>
|
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
|
||||||
@endif
|
@endif
|
||||||
<a class="text-sm underline"
|
<a class="text-sm underline"
|
||||||
|
|||||||
@@ -67,11 +67,21 @@
|
|||||||
@endif
|
@endif
|
||||||
@if ($ticket->bestArticle)
|
@if ($ticket->bestArticle)
|
||||||
<div class="mt-3 text-sm rounded bg-green-100 text-green-800 p-2">
|
<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)
|
@if ($ticket->confidence !== null)
|
||||||
(confidence {{ number_format($ticket->confidence, 2) }})
|
<x-admin.confidence-badge :confidence="$ticket->confidence" />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Api\ArticleController;
|
use App\Http\Controllers\Api\ArticleController;
|
||||||
|
use App\Http\Controllers\Api\NearestArticleController;
|
||||||
use App\Http\Controllers\Api\TicketController;
|
use App\Http\Controllers\Api\TicketController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/articles', [ArticleController::class, 'index']);
|
Route::get('/articles', [ArticleController::class, 'index']);
|
||||||
|
Route::get('/articles/nearest', NearestArticleController::class);
|
||||||
Route::post('/articles', [ArticleController::class, 'store']);
|
Route::post('/articles', [ArticleController::class, 'store']);
|
||||||
Route::post('/tickets', [TicketController::class, 'store']);
|
Route::post('/tickets', [TicketController::class, 'store']);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Route::get('/', function () {
|
|||||||
Route::prefix('admin')->group(function () {
|
Route::prefix('admin')->group(function () {
|
||||||
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
|
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
|
||||||
Route::view('/articles', 'admin.articles')->name('admin.articles');
|
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('/quick-replies', 'admin.quick-replies')->name('admin.quick-replies');
|
||||||
Route::view('/tickets', 'admin.tickets')->name('admin.tickets');
|
Route::view('/tickets', 'admin.tickets')->name('admin.tickets');
|
||||||
Route::get('/tickets/{ticket}', [AdminTicketController::class, 'show'])->name('admin.tickets.show');
|
Route::get('/tickets/{ticket}', [AdminTicketController::class, 'show'])->name('admin.tickets.show');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class FakeArticleRepository implements ArticleRepositoryInterface
|
|||||||
/** @var array<int, ArticleCandidateDTO> */
|
/** @var array<int, ArticleCandidateDTO> */
|
||||||
public array $candidates = [];
|
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);
|
return array_slice($this->candidates, 0, $limit);
|
||||||
}
|
}
|
||||||
|
|||||||
59
tests/Feature/ArticleManagerTest.php
Normal file
59
tests/Feature/ArticleManagerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/Feature/NearestArticleApiTest.php
Normal file
62
tests/Feature/NearestArticleApiTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class TicketShowPageTest extends TestCase
|
|||||||
'message' => 'vraag',
|
'message' => 'vraag',
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'best_article_id' => $article->id,
|
'best_article_id' => $article->id,
|
||||||
|
'confidence' => 0.9,
|
||||||
'support_reply' => 'Gebruik deze stappen',
|
'support_reply' => 'Gebruik deze stappen',
|
||||||
'result_payload' => [
|
'result_payload' => [
|
||||||
'quick_reply' => ['id' => 1, 'title' => 'DNS Quick'],
|
'quick_reply' => ['id' => 1, 'title' => 'DNS Quick'],
|
||||||
@@ -39,5 +40,23 @@ class TicketShowPageTest extends TestCase
|
|||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertSee('Snelantwoord gebruikt', false);
|
$response->assertSee('Snelantwoord gebruikt', false);
|
||||||
$response->assertSee('Toolcalls', 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ namespace Tests\Unit;
|
|||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Services\AppSettingsService;
|
use App\Services\AppSettingsService;
|
||||||
use App\Services\Llm\LlmClientInterface;
|
|
||||||
use App\Services\SupportReplyService;
|
use App\Services\SupportReplyService;
|
||||||
use App\Services\TicketProcessingLoggerService;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\Fakes\FakeLlmClient;
|
use Tests\Fakes\FakeLlmClient;
|
||||||
use Tests\Fakes\FakeTicketProcessingLoggerService;
|
use Tests\Fakes\FakeTicketProcessingLoggerService;
|
||||||
@@ -35,6 +33,7 @@ class SupportReplyServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame('1. Doe X', $reply);
|
$this->assertSame('1. Doe X', $reply);
|
||||||
$this->assertStringContainsString('Gebruikersvraag (genormaliseerd): vraag', $llm->generatedPrompts[0]['prompt']);
|
$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
|
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);
|
$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
|
public function getPrompt(string $key, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
return 'Prompt';
|
return 'Prompt';
|
||||||
|
|||||||
Reference in New Issue
Block a user