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

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

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Api;
use App\Exceptions\OllamaUnavailableException;
use App\Http\Controllers\Controller;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Services\EmbeddingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NearestArticleController extends Controller
{
public function __invoke(
Request $request,
EmbeddingService $embeddingService,
ArticleRepositoryInterface $articleRepository
): JsonResponse {
$validated = $request->validate([
'query' => ['required', 'string', 'min:2', 'max:1000'],
'limit' => ['sometimes', 'integer', 'min:1', 'max:20'],
'min_similarity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'include_content' => ['sometimes', 'boolean'],
]);
$query = trim($validated['query']);
$limit = (int) ($validated['limit'] ?? 5);
$minSimilarity = (float) ($validated['min_similarity'] ?? 0);
$includeContent = $request->boolean('include_content', false);
try {
$embedding = $embeddingService->embed($query);
} catch (OllamaUnavailableException $exception) {
return response()->json([
'message' => 'Embedding provider is unavailable.',
'error' => $exception->getMessage(),
], 503);
}
$embeddingContext = $embeddingService->context();
$candidates = $articleRepository->findSimilarByEmbedding(
embedding: $embedding,
limit: $limit,
embeddingContext: $embeddingContext,
filters: ['published_only' => true]
);
$results = collect($candidates)
->map(function ($candidate) use ($includeContent) {
$similarity = max(0, min(1, 1 - $candidate->distance));
$content = trim($candidate->content);
return [
'article_id' => $candidate->articleId,
'title' => $candidate->title,
'similarity' => round($similarity, 4),
'distance' => round($candidate->distance, 4),
'snippet' => str($content)->limit(220)->toString(),
'content' => $includeContent ? $content : null,
'source_url' => $candidate->sourceUrl,
'source_article_id' => $candidate->sourceArticleId,
'note' => $candidate->note,
'allowed_actions' => $candidate->allowedActions,
];
})
->filter(fn (array $result) => $result['similarity'] >= $minSimilarity)
->values();
return response()->json([
'data' => $results,
'meta' => [
'query' => $query,
'limit' => $limit,
'min_similarity' => $minSimilarity,
'published_only' => true,
'embedding_provider_instance_id' => $embeddingContext['provider_instance_id'] ?? null,
'embedding_model' => $embeddingContext['embedding_model'] ?? null,
],
]);
}
}

View File

@@ -19,6 +19,14 @@ class ArticleManager extends Component
public array $allowedActions = [];
public string $search = '';
public ?int $editingArticleId = null;
public string $editTitle = '';
public string $editContent = '';
public array $articleNotes = [];
public array $articleAllowedActions = [];
@@ -55,6 +63,51 @@ class ArticleManager extends Component
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function startEdit(int $articleId, AdminArticleService $service): void
{
$article = $service->findById($articleId);
if ($article === null) {
session()->flash('success', "Artikel #{$articleId} bestaat niet meer.");
return;
}
$this->editingArticleId = $article->id;
$this->editTitle = $article->title;
$this->editContent = $article->content;
}
public function cancelEdit(): void
{
$this->reset(['editingArticleId', 'editTitle', 'editContent']);
$this->resetValidation(['editTitle', 'editContent']);
}
public function saveEdit(AdminArticleService $service): void
{
if ($this->editingArticleId === null) {
return;
}
$this->validate([
'editTitle' => ['required', 'string', 'max:255'],
'editContent' => ['required', 'string', 'max:12000'],
]);
$updated = $service->updateContent($this->editingArticleId, $this->editTitle, $this->editContent);
session()->flash('success', $updated
? "Artikel #{$this->editingArticleId} is bijgewerkt en wordt opnieuw geindexeerd."
: "Artikel #{$this->editingArticleId} bestaat niet meer.");
$this->cancelEdit();
}
public function saveMetadata(int $articleId, AdminArticleService $service): void
{
$this->validate([
@@ -90,8 +143,8 @@ class ArticleManager extends Component
public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService)
{
$articles = $service->paginate(10);
$this->hydrateArticleMetadataState($articles->items());
$articles = $service->paginate(10, $this->search);
$this->fillArticleMetadataState($articles->items());
return view('livewire.admin.article-manager', [
'articles' => $articles,
@@ -99,7 +152,7 @@ class ArticleManager extends Component
]);
}
private function hydrateArticleMetadataState(array $articles): void
private function fillArticleMetadataState(array $articles): void
{
foreach ($articles as $article) {
if (! array_key_exists($article->id, $this->articleNotes)) {

View File

@@ -9,13 +9,20 @@ use App\Repositories\Contracts\ArticleRepositoryInterface;
class ArticleRepository implements ArticleRepositoryInterface
{
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = [], array $filters = []): array
{
$vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']';
$chunkDistances = ArticleChunk::query()
->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector])
->whereNotNull('embedding')
->when((bool) ($filters['published_only'] ?? false), function ($query) {
$query->whereHas('article', function ($articleQuery) {
$articleQuery
->where('status', 'published')
->where('is_ai_draft', false);
});
})
->when($embeddingContext !== [], function ($query) use ($embeddingContext) {
$query
->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null)

View File

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

View File

@@ -9,10 +9,29 @@ use Illuminate\Support\Facades\DB;
class AdminArticleService
{
public function paginate(int $perPage = 10): LengthAwarePaginator
public function paginate(int $perPage = 10, ?string $search = null): LengthAwarePaginator
{
$search = $search !== null ? trim($search) : '';
$numericSearch = ltrim($search, '#');
return Article::query()
->with('quickReplies')
->when($search !== '', function ($query) use ($numericSearch, $search): void {
$like = "%{$search}%";
$query->where(function ($query) use ($numericSearch, $like): void {
$query
->where('title', 'ilike', $like)
->orWhere('content', 'ilike', $like)
->orWhere('source_url', 'ilike', $like)
->orWhere('source_article_id', 'ilike', $like);
if (ctype_digit($numericSearch)) {
$query->orWhere('id', (int) $numericSearch);
}
});
})
->latest()
->paginate($perPage);
}
@@ -34,6 +53,25 @@ class AdminArticleService
return (bool) Article::query()->whereKey($articleId)->delete();
}
public function findById(int $articleId): ?Article
{
return Article::query()->find($articleId);
}
public function updateContent(int $articleId, string $title, string $content): bool
{
$article = Article::query()->find($articleId);
if ($article === null) {
return false;
}
$article->title = trim($title);
$article->content = trim($content);
$article->save();
return true;
}
public function updateMetadata(int $articleId, ?string $note, array $allowedActions, array $quickReplyIds = []): bool
{
$article = Article::query()->find($articleId);

View File

@@ -130,6 +130,20 @@ class AppSettingsService
return $default;
}
public function toneAddressing(): string
{
return $this->get('tone_addressing', 'je') === 'u' ? 'u' : 'je';
}
public function toneInstruction(): string
{
if ($this->toneAddressing() === 'u') {
return 'Als de klanttaal Nederlands is: spreek de klant consequent formeel aan met u/uw. Gebruik geen je/jij/jouw.';
}
return 'Als de klanttaal Nederlands is: spreek de klant consequent informeel aan met je/jij/jouw. Gebruik geen u/uw.';
}
public function promptSettings(): array
{
$settings = $this->all();

View File

@@ -8,6 +8,8 @@ use Illuminate\Support\Str;
class KnowledgeGapService
{
public const CONFIDENCE_THRESHOLD = 0.45;
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
@@ -16,7 +18,7 @@ class KnowledgeGapService
public function shouldCreateDraft(Ticket $ticket, array $result): bool
{
$confidence = (float) ($result['confidence'] ?? 0);
if ($confidence < 0.45) {
if ($confidence < self::CONFIDENCE_THRESHOLD) {
return true;
}
@@ -41,6 +43,7 @@ class KnowledgeGapService
$prompt = $basePrompt."\n\n".
"Klantvraag:\n{$question}\n\n".
"Originele taal: {$language}. Schrijf titel en inhoud in deze taal.\n\n".
'Aanspreekvorm: '.$this->settings->toneInstruction()."\n\n".
"Huidige kandidaten (mogelijk onvoldoende):\n{$topCandidates}\n\n".
'Content moet praktisch zijn met duidelijke stappen.';

View File

@@ -18,18 +18,22 @@ class SupportReplyService
public function build(Ticket $ticket, ?Article $bestArticle, string $explanation, ?array $toolCall = null): string
{
$basePrompt = $this->settings->getPrompt('support_reply', 'Give only direct advice in the requested output language.');
$toneInstruction = $this->settings->toneInstruction();
if ($bestArticle === null) {
return "Geen passend kennisbankartikel gevonden.\n\n1. Controleer of je vraag voldoende details bevat (product, domein, foutmelding).\n2. Escaleer naar support voor handmatige opvolging.\n3. Voeg een nieuw kennisbankartikel toe na afhandeling van deze case.";
return "Geen passend kennisbankartikel gevonden.\n\n1. Controleer of de klantvraag voldoende details bevat (product, domein, foutmelding).\n2. Escaleer naar support voor handmatige opvolging.\n3. Voeg een nieuw kennisbankartikel toe na afhandeling van deze case.";
}
if ((bool) config('services.llm.ranking_enabled', true)) {
$userInput = $ticket->normalized_message ?: $ticket->message;
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
$articleUrl = $bestArticle->source_url ?: 'niet beschikbaar';
$llmPrompt = $basePrompt."\n\n".
"Customer language: {$language}. Write the advice in this language.\n".
"Aanspreekvorm: {$toneInstruction}\n".
'Gebruikersvraag (genormaliseerd): '.$userInput."\n".
'Beste artikel titel: '.$bestArticle->title."\n".
'Beste artikel URL: '.$articleUrl."\n".
'Beste artikel content: '.$bestArticle->content."\n".
'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n".
'Waarom dit artikel gekozen is: '.$explanation."\n\n".
@@ -39,7 +43,9 @@ class SupportReplyService
"- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n".
"- Begin direct met de oplossing.\n".
"- Geef 3-6 genummerde actiepunten.\n".
"- Volg de aanspreekvorm exact.\n".
"- Voeg een korte controle-stap toe als laatste punt.\n".
"- Eindig altijd met precies 1 bronregel: 'Bron: <url>' of 'Bron: niet beschikbaar'.\n".
'- Geen markdown, geen codeblokken.';
try {
@@ -69,7 +75,8 @@ class SupportReplyService
"3. Voer de herstel- of controleacties uit die in het artikel worden genoemd.\n".
"4. Controleer daarna of het oorspronkelijke probleem niet meer optreedt.\n\n".
'Relevantie: '.$safeExplanation."\n".
'Bronsamenvatting: '.$snippet;
'Bronsamenvatting: '.$snippet."\n".
'Bron: '.($bestArticle->source_url ?: 'niet beschikbaar');
}
private function formatToolCallForPrompt(?array $toolCall): string
@@ -100,7 +107,7 @@ class SupportReplyService
|| str_contains($lower, 'curl error');
if ($hasTechnicalFailure) {
return 'Dit artikel sluit het beste aan op je vraag en bevat de meest relevante stappen om dit in te stellen.';
return 'Dit artikel sluit het beste aan op de klantvraag en bevat de meest relevante stappen om dit in te stellen.';
}
return $explanation;