Add admin views for quick replies, settings, and ticket details

- Created `quick-replies.blade.php` for managing quick replies.
- Added `settings.blade.php` for admin settings management.
- Implemented `ticket-show.blade.php` to display ticket details.
- Introduced `timeline-card.blade.php` component for displaying timeline information.

Enhance quick reply management functionality

- Developed `quick-reply-manager.blade.php` for creating and editing quick replies.
- Integrated Livewire for dynamic interaction and validation.

Implement settings page for AI configuration

- Created `settings-page.blade.php` for managing AI settings, including prompts and provider instances.
- Added functionality for managing models and embeddings.

Add ticket show functionality with real-time updates

- Implemented ticket details view with processing status and tool call logs.
- Added support for displaying article suggestions and error messages.

Create unit tests for AI classifier and domain info tool

- Added `AIClassifierServiceTest.php` to validate AI classifier functionality.
- Implemented `DomainInfoToolTest.php` for domain parameter validation.
- Created `OxxaClientTest.php` to test API interactions and password hashing.
This commit is contained in:
SitiWeb
2026-04-30 01:50:21 +02:00
parent 01aa115a49
commit f939133fe0
103 changed files with 4721 additions and 245 deletions

View File

@@ -31,6 +31,7 @@ class VectorCast implements CastsAttributes
}
$vector = array_map(static fn ($item) => (float) $item, $value);
return '['.implode(',', $vector).']';
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Services\ArticleIndexingService;
use Illuminate\Console\Command;
class GenerateArticleEmbeddingsCommand extends Command
{
protected $signature = 'articles:embed
{--force : Re-generate embeddings for all articles}
{--limit= : Maximum number of articles to process}';
protected $description = 'Generate missing (or all) embeddings for articles.';
public function handle(ArticleIndexingService $indexingService): int
{
$force = (bool) $this->option('force');
$limitOption = $this->option('limit');
$limit = is_numeric($limitOption) ? (int) $limitOption : null;
$query = Article::query()->orderBy('id');
if (! $force) {
$query->whereDoesntHave('chunks');
}
$total = (clone $query)->count();
if ($limit !== null && $limit > 0) {
$total = min($total, $limit);
}
if ($total === 0) {
$this->info('No articles to process.');
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$updated = 0;
$failed = 0;
$query->chunkById(50, function ($articles) use ($indexingService, $limit, &$processed, &$updated, &$failed, $bar) {
foreach ($articles as $article) {
if ($limit !== null && $processed >= $limit) {
return false;
}
$processed++;
try {
$indexingService->indexArticle($article);
$updated++;
} catch (\Throwable) {
$failed++;
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
$this->table(['Metric', 'Value'], [
['Processed', $processed],
['Updated', $updated],
['Failed', $failed],
]);
return self::SUCCESS;
}
}

View File

@@ -10,7 +10,11 @@ class ArticleCandidateDTO
public readonly int $articleId,
public readonly string $title,
public readonly string $content,
public readonly float $distance
public readonly float $distance,
public readonly ?string $sourceUrl = null,
public readonly ?string $sourceArticleId = null,
public readonly ?string $note = null,
public readonly array $allowedActions = []
) {}
public static function fromArticle(Article $article, float $distance): self
@@ -19,7 +23,11 @@ class ArticleCandidateDTO
articleId: $article->id,
title: $article->title,
content: $article->content,
distance: $distance
distance: $distance,
sourceUrl: $article->source_url,
sourceArticleId: $article->source_article_id,
note: $article->note,
allowedActions: $article->allowed_actions ?? []
);
}
@@ -30,6 +38,10 @@ class ArticleCandidateDTO
'title' => $this->title,
'content' => $this->content,
'distance' => $this->distance,
'source_url' => $this->sourceUrl,
'source_article_id' => $this->sourceArticleId,
'note' => $this->note,
'allowed_actions' => $this->allowedActions,
];
}
}
}

View File

@@ -8,6 +8,7 @@ class ClassificationResultDTO
public readonly ?int $articleId,
public readonly float $confidence,
public readonly string $explanation,
public readonly ?array $toolCall = null,
public readonly array $rawResponse = []
) {}
@@ -17,7 +18,8 @@ class ClassificationResultDTO
'article_id' => $this->articleId,
'confidence' => $this->confidence,
'explanation' => $this->explanation,
'tool_call' => $this->toolCall,
'raw_response' => $this->rawResponse,
];
}
}
}

View File

@@ -6,4 +6,23 @@ use RuntimeException;
class OllamaUnavailableException extends RuntimeException
{
}
public function __construct(
string $provider,
string $operation,
?string $reason = null,
?\Throwable $previous = null,
?string $responseSnippet = null,
) {
$message = sprintf('%s provider unavailable during %s', $provider, $operation);
if ($reason) {
$message .= sprintf(' | reason: %s', $reason);
}
if ($responseSnippet) {
$message .= sprintf(' | response: %s', $responseSnippet);
}
parent::__construct($message, 0, $previous);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\AdminTicketService;
use Illuminate\Contracts\View\View;
class AdminTicketController extends Controller
{
public function show(int $ticket, AdminTicketService $service): View
{
$record = $service->findWithTimeline($ticket);
abort_if($record === null, 404);
return view('admin.ticket-show', [
'ticket' => $record,
]);
}
}

View File

@@ -25,4 +25,4 @@ class ArticleController extends Controller
'data' => $article,
], 201);
}
}
}

View File

@@ -5,48 +5,33 @@ namespace App\Http\Controllers\Api;
use App\Exceptions\OllamaUnavailableException;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTicketRequest;
use App\Models\Ticket;
use App\Services\EmbeddingService;
use App\Services\SemanticSearchService;
use App\Services\TicketIngestionService;
use Illuminate\Http\JsonResponse;
class TicketController extends Controller
{
public function __construct(
private readonly EmbeddingService $embeddingService,
private readonly SemanticSearchService $semanticSearchService,
private readonly TicketIngestionService $ticketIngestionService,
) {}
public function store(StoreTicketRequest $request): JsonResponse
{
try {
$embedding = $this->embeddingService->embed($request->string('message')->toString());
$ingested = $this->ticketIngestionService->ingest(
$request->string('message')->toString(),
$request->validated('api_credentials')
);
} catch (OllamaUnavailableException $e) {
return response()->json([
'message' => 'Ollama is unavailable. Could not generate embedding.',
'message' => 'LLM provider is unavailable. Ticket kon niet verwerkt worden.',
'reason' => $e->getMessage(),
], 503);
}
$ticket = Ticket::query()->create([
'message' => $request->string('message')->toString(),
'embedding' => $embedding,
]);
try {
$result = $this->semanticSearchService->findBestArticle($ticket);
} catch (OllamaUnavailableException $e) {
return response()->json([
'message' => 'Ticket saved, but Ollama ranking is unavailable.',
'ticket_id' => $ticket->id,
], 202);
}
return response()->json([
'ticket_id' => $ticket->id,
'best_article' => $result['best_article'],
'confidence' => $result['confidence'],
'explanation' => $result['explanation'],
'top_3_candidates' => $result['top_3_candidates'],
], 201);
'ticket_id' => $ingested['ticket']->id,
'status' => 'queued',
'message' => 'Ticket ontvangen en in verwerking gezet.',
], 202);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreArticleRequest extends FormRequest
{
@@ -16,6 +17,9 @@ class StoreArticleRequest extends FormRequest
return [
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:12000'],
'note' => ['nullable', 'string', 'max:12000'],
'allowed_actions' => ['nullable', 'array'],
'allowed_actions.*' => ['string', Rule::in(['domain_inf'])],
];
}
}
}

View File

@@ -15,6 +15,9 @@ class StoreTicketRequest extends FormRequest
{
return [
'message' => ['required', 'string', 'min:5', 'max:5000'],
'api_credentials' => ['nullable', 'array'],
'api_credentials.apiuser' => ['required_with:api_credentials', 'string', 'max:255'],
'api_credentials.apipassword' => ['required_with:api_credentials', 'string', 'max:255'],
];
}
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Jobs;
use App\Models\Article;
use App\Services\EmbeddingService;
use App\Services\ArticleIndexingService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,18 +14,15 @@ class GenerateArticleEmbeddingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly int $articleId)
{
}
public function __construct(public readonly int $articleId) {}
public function handle(EmbeddingService $embeddingService): void
public function handle(ArticleIndexingService $indexingService): void
{
$article = Article::find($this->articleId);
if ($article === null) {
return;
}
$article->embedding = $embeddingService->embed($article->title."\n".$article->content);
$article->save();
$indexingService->indexArticle($article);
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Jobs;
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\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
class ProcessTicketJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public readonly int $ticketId) {}
public function handle(
EmbeddingService $embeddingService,
SemanticSearchService $semanticSearchService,
TicketNormalizationService $normalizer,
TicketProcessingLoggerService $logger,
KnowledgeGapService $knowledgeGapService,
TicketToolCallService $toolCallService,
QuickReplyResolver $quickReplyResolver,
SupportReplyService $supportReplyService,
TicketResultPayloadBuilder $payloadBuilder,
): void {
$ticket = Ticket::query()->find($this->ticketId);
if ($ticket === null) {
return;
}
$ticket->update(['status' => 'processing']);
$logger->log($ticket, 'start', 'info', 'Ticket processing gestart.', [
'original_question' => $ticket->message,
]);
try {
$logger->log($ticket, 'normalize_question', 'info', 'Vraag normaliseren en PII redacteren.');
$normalized = $normalizer->normalize($ticket->message);
$ticket->normalized_message = $normalized['normalized_message'];
$ticket->redaction_report = $normalized['redaction_report'] ?? null;
$ticket->save();
$logger->log($ticket, 'normalize_question', 'success', 'Vraag genormaliseerd.', [
'normalized_message' => $ticket->normalized_message,
'redaction_report' => $ticket->redaction_report,
]);
if ($ticket->embedding === null) {
$logger->log($ticket, 'embedding', 'info', 'Embedding genereren.');
$ticket->embedding = $embeddingService->embed($ticket->normalized_message ?: $ticket->message);
$embeddingContext = $embeddingService->context();
$ticket->embedding_provider_instance_id = $embeddingContext['provider_instance_id'];
$ticket->embedding_model = $embeddingContext['embedding_model'];
$ticket->embedded_at = now();
$ticket->save();
}
$logger->log($ticket, 'embedding', 'success', 'Embedding beschikbaar.', [
'vector_dimensions' => is_array($ticket->embedding) ? count($ticket->embedding) : 0,
'vector_preview' => is_array($ticket->embedding) ? array_slice($ticket->embedding, 0, 8) : [],
]);
$logger->log($ticket, 'retrieval_ranking', 'info', 'Semantic retrieval en AI ranking uitvoeren.');
$result = $semanticSearchService->findBestArticle($ticket);
$logger->log($ticket, 'retrieval', 'success', 'Top kandidaten uit vector search bepaald.', [
'candidates' => $result['top_5_candidates'] ?? [],
'retrieval_meta' => $result['retrieval_meta'] ?? null,
]);
$logger->log($ticket, 'ranking', 'success', 'Classificatie afgerond.', [
'selected_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
'explanation' => $result['explanation'],
'classifier_raw_response' => $result['classifier_raw_response'] ?? null,
]);
$isKnowledgeGap = $knowledgeGapService->shouldCreateDraft($ticket, $result);
$draftSuggestion = null;
$supportReply = null;
$toolCallRecord = null;
$quickReply = null;
if ($isKnowledgeGap) {
$logger->log($ticket, 'knowledge_gap', 'warning', 'Onvoldoende match gevonden; geen passend artikel in kennisbank.');
$draftSuggestion = $knowledgeGapService->suggestArticleDraft($ticket, $result);
$logger->log($ticket, 'knowledge_gap', 'success', 'Voorstel voor nieuw kennisbankartikel gegenereerd (niet opgeslagen).', [
'suggested_title' => $draftSuggestion['title'] ?? null,
]);
} else {
$quickReply = $quickReplyResolver->resolveForArticle($result['best_article'] ?? null);
if ($quickReply !== null) {
$supportReply = $quickReply->content;
$logger->log($ticket, 'quick_reply', 'success', 'Snelantwoord gebruikt; AI antwoordgeneratie overgeslagen.', [
'quick_reply_id' => $quickReply->id,
'quick_reply_title' => $quickReply->title,
'article_id' => $result['best_article']?->id,
]);
} else {
$toolCallRecord = $toolCallService->executeRequestedTool(
$ticket,
$result['best_article'] ?? null,
$result['requested_tool_call'] ?? null
);
$logger->log($ticket, 'quick_reply', 'info', 'Geen actief snelantwoord gekoppeld; AI maakt conceptantwoord.', [
'article_id' => $result['best_article']?->id,
]);
$supportReply = $supportReplyService->build(
$ticket,
$result['best_article'] ?? null,
(string) $result['explanation'],
$toolCallRecord?->toArray()
);
}
}
$ticket->update([
'status' => 'completed',
'best_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
'explanation' => $result['explanation'],
'support_reply' => $supportReply,
'needs_article_draft' => $isKnowledgeGap,
'draft_article_id' => null,
'result_payload' => $payloadBuilder->build($result, $toolCallRecord, $quickReply, $isKnowledgeGap, $draftSuggestion),
'error_message' => null,
'processed_at' => now(),
]);
if ($supportReply !== null) {
$logger->log($ticket, 'support_reply', 'success', 'Concept supportantwoord opgebouwd.', [
'support_reply_preview' => Str::limit($supportReply, 220),
]);
} else {
$logger->log($ticket, 'support_reply', 'info', 'Geen klantreactie gegenereerd wegens knowledge gap; eerst artikelreview nodig.');
}
$logger->log($ticket, 'completed', 'success', 'Ticket processing afgerond.', [
'best_article_id' => $result['best_article']?->id,
'confidence' => $result['confidence'],
]);
} catch (\Throwable $e) {
$ticket->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'processed_at' => now(),
]);
$logger->log($ticket, 'failed', 'error', 'Ticket processing gefaald.', [
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Livewire\Admin;
use App\Services\AdminArticleService;
use App\Services\AdminQuickReplyService;
use Livewire\Component;
use Livewire\WithPagination;
@@ -11,26 +12,107 @@ class ArticleManager extends Component
use WithPagination;
public string $title = '';
public string $content = '';
public string $note = '';
public array $allowedActions = [];
public array $articleNotes = [];
public array $articleAllowedActions = [];
public array $articleQuickReplies = [];
public function save(AdminArticleService $service): void
{
$this->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:12000'],
'note' => ['nullable', 'string', 'max:12000'],
'allowedActions' => ['array'],
'allowedActions.*' => ['string', 'in:domain_inf'],
]);
$service->create($this->title, $this->content);
$service->create($this->title, $this->content, $this->note, $this->allowedActions);
$this->reset(['title', 'content']);
$this->reset(['title', 'content', 'note', 'allowedActions']);
$this->dispatch('article-saved');
session()->flash('success', 'Article opgeslagen en embedding wordt automatisch verwerkt.');
}
public function render(AdminArticleService $service)
public function deleteArticle(int $articleId, AdminArticleService $service): void
{
$deleted = $service->deleteById($articleId);
if ($deleted) {
session()->flash('success', "Artikel #{$articleId} is verwijderd.");
} else {
session()->flash('success', "Artikel #{$articleId} bestond niet meer.");
}
$this->resetPage();
}
public function saveMetadata(int $articleId, AdminArticleService $service): void
{
$this->validate([
"articleNotes.{$articleId}" => ['nullable', 'string', 'max:12000'],
"articleAllowedActions.{$articleId}" => ['array'],
"articleAllowedActions.{$articleId}.*" => ['string', 'in:domain_inf'],
"articleQuickReplies.{$articleId}" => ['array'],
"articleQuickReplies.{$articleId}.*" => ['integer', 'exists:quick_replies,id'],
]);
$updated = $service->updateMetadata(
$articleId,
$this->articleNotes[$articleId] ?? null,
$this->articleAllowedActions[$articleId] ?? [],
$this->articleQuickReplies[$articleId] ?? []
);
session()->flash('success', $updated
? "Metadata voor artikel #{$articleId} is opgeslagen."
: "Artikel #{$articleId} bestaat niet meer.");
}
public function approveDraft(int $articleId, AdminArticleService $service): void
{
$approved = $service->approveDraft($articleId);
if ($approved) {
session()->flash('success', "Conceptartikel #{$articleId} is gevalideerd en gepubliceerd.");
} else {
session()->flash('success', "Conceptartikel #{$articleId} bestaat niet meer.");
}
}
public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService)
{
$articles = $service->paginate(10);
$this->hydrateArticleMetadataState($articles->items());
return view('livewire.admin.article-manager', [
'articles' => $service->paginate(10),
'articles' => $articles,
'quickReplyOptions' => $quickReplyService->activeOptions(),
]);
}
private function hydrateArticleMetadataState(array $articles): void
{
foreach ($articles as $article) {
if (! array_key_exists($article->id, $this->articleNotes)) {
$this->articleNotes[$article->id] = $article->note ?? '';
}
if (! array_key_exists($article->id, $this->articleAllowedActions)) {
$this->articleAllowedActions[$article->id] = $article->allowed_actions ?? [];
}
if (! array_key_exists($article->id, $this->articleQuickReplies)) {
$this->articleQuickReplies[$article->id] = $article->quickReplies->pluck('id')->map(fn ($id) => (int) $id)->all();
}
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Admin;
use App\Services\AdminQuickReplyService;
use Livewire\Component;
use Livewire\WithPagination;
class QuickReplyManager extends Component
{
use WithPagination;
public string $title = '';
public string $content = '';
public bool $isActive = true;
public array $editRows = [];
public function save(AdminQuickReplyService $service): void
{
$this->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'max:12000'],
'isActive' => ['boolean'],
]);
$service->create($this->title, $this->content, $this->isActive);
$this->reset(['title', 'content']);
$this->isActive = true;
session()->flash('success', 'Snelantwoord opgeslagen.');
$this->resetPage();
}
public function updateQuickReply(int $id, AdminQuickReplyService $service): void
{
$this->validate([
"editRows.{$id}.title" => ['required', 'string', 'max:255'],
"editRows.{$id}.content" => ['required', 'string', 'max:12000'],
"editRows.{$id}.is_active" => ['boolean'],
]);
$row = $this->editRows[$id] ?? [];
$updated = $service->update(
$id,
(string) ($row['title'] ?? ''),
(string) ($row['content'] ?? ''),
(bool) ($row['is_active'] ?? false)
);
session()->flash('success', $updated
? "Snelantwoord #{$id} is opgeslagen."
: "Snelantwoord #{$id} bestaat niet meer.");
}
public function deleteQuickReply(int $id, AdminQuickReplyService $service): void
{
$deleted = $service->deleteById($id);
session()->flash('success', $deleted
? "Snelantwoord #{$id} is verwijderd."
: "Snelantwoord #{$id} bestond niet meer.");
unset($this->editRows[$id]);
$this->resetPage();
}
public function render(AdminQuickReplyService $service)
{
$quickReplies = $service->paginate(10);
$this->hydrateEditRows($quickReplies->items());
return view('livewire.admin.quick-reply-manager', [
'quickReplies' => $quickReplies,
]);
}
private function hydrateEditRows(array $quickReplies): void
{
foreach ($quickReplies as $quickReply) {
if (array_key_exists($quickReply->id, $this->editRows)) {
continue;
}
$this->editRows[$quickReply->id] = [
'title' => $quickReply->title,
'content' => $quickReply->content,
'is_active' => $quickReply->is_active,
];
}
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Livewire\Admin;
use App\Services\AppSettingsService;
use App\Services\ArticleEmbeddingMaintenanceService;
use App\Services\LlmModelCatalogService;
use Illuminate\Support\Str;
use Livewire\Component;
class SettingsPage extends Component
{
public string $activeTab = 'process';
public string $tone_addressing = 'je';
public string $activeProviderInstanceId = 'ollama_default';
public int $llm_timeout = 300;
public array $promptValues = [];
public array $providerInstances = [];
public array $modelValues = [];
public array $availableModels = [];
public array $embeddingStats = [];
public ?string $modelLoadError = null;
public array $processSteps = [];
public array $providerDefinitions = [];
public array $modelTasks = [];
public function mount(AppSettingsService $settings): void
{
$all = $settings->all();
$providerSettings = $settings->providerSettings();
$this->tone_addressing = (string) ($all['tone_addressing'] ?? 'je');
$this->activeProviderInstanceId = (string) ($providerSettings['active_instance_id'] ?? 'ollama_default');
$this->llm_timeout = (int) ($all['llm.timeout'] ?? 300);
$this->promptValues = $settings->promptValues();
$this->providerInstances = $providerSettings['instances'] ?? $settings->defaultProviderInstances();
$this->modelValues = $settings->modelSettings();
$this->processSteps = $settings->processSteps();
$this->providerDefinitions = $settings->providerDefinitions();
$this->modelTasks = $settings->modelTasks();
$this->refreshEmbeddingStats();
$this->loadModels();
}
public function setTab(string $tab): void
{
if (in_array($tab, ['process', 'providers', 'models', 'embeddings'], true)) {
$this->activeTab = $tab;
if ($tab === 'embeddings') {
$this->refreshEmbeddingStats();
}
}
}
public function refreshEmbeddingStats(): void
{
$this->embeddingStats = app(ArticleEmbeddingMaintenanceService::class)->stats();
}
public function reindexMissingEmbeddings(): void
{
$count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(false);
$this->refreshEmbeddingStats();
session()->flash('saved', "{$count} artikelen zonder chunks zijn in de queue geplaatst.");
}
public function reindexAllEmbeddings(): void
{
$count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(true);
$this->refreshEmbeddingStats();
session()->flash('saved', "{$count} artikelen zijn in de queue geplaatst voor volledige herindex.");
}
public function addProviderInstance(): void
{
$id = 'provider_'.Str::uuid()->toString();
$this->providerInstances[] = [
'id' => $id,
'name' => 'Nieuwe provider',
'type' => 'lmstudio',
'base_url' => 'http://localhost:1234',
'chat_model' => '',
'embedding_model' => '',
];
$this->activeProviderInstanceId = $id;
$this->availableModels = [];
$this->modelLoadError = null;
}
public function removeProviderInstance(string $id): void
{
if (count($this->providerInstances) <= 1) {
return;
}
$this->providerInstances = array_values(array_filter(
$this->providerInstances,
fn (array $instance) => ($instance['id'] ?? null) !== $id
));
if ($this->activeProviderInstanceId === $id) {
$this->activeProviderInstanceId = (string) ($this->providerInstances[0]['id'] ?? '');
}
$this->loadModels();
}
public function setActiveProviderInstance(string $id): void
{
$ids = array_column($this->providerInstances, 'id');
if (in_array($id, $ids, true)) {
$this->activeProviderInstanceId = $id;
$this->loadModels();
}
}
public function loadModels(bool $refresh = false): void
{
$instance = $this->activeProviderInstance();
if ($instance === null) {
$this->availableModels = [];
return;
}
try {
$this->availableModels = app(LlmModelCatalogService::class)->modelsFor($instance, $refresh);
$this->modelLoadError = null;
} catch (\Throwable $e) {
$this->availableModels = [];
$this->modelLoadError = $e->getMessage();
}
}
public function refreshModels(): void
{
$this->loadModels(true);
}
public function save(AppSettingsService $settings): void
{
$this->validate([
'tone_addressing' => ['required', 'in:je,u'],
'activeProviderInstanceId' => ['required', 'string'],
'llm_timeout' => ['required', 'integer', 'min:5', 'max:600'],
'promptValues' => ['array'],
'promptValues.*' => ['required', 'string', 'min:10'],
'providerInstances' => ['array', 'min:1'],
'providerInstances.*.id' => ['required', 'string'],
'providerInstances.*.name' => ['required', 'string', 'min:1'],
'providerInstances.*.type' => ['required', 'in:ollama,lmstudio'],
'providerInstances.*.base_url' => ['required', 'url'],
'providerInstances.*.chat_model' => ['nullable', 'string'],
'providerInstances.*.embedding_model' => ['nullable', 'string'],
'modelValues' => ['array'],
'modelValues.*' => ['required', 'string', 'min:1'],
]);
$settings->saveStructuredSettings(
promptValues: $this->promptValues,
providerInstances: $this->providerInstances,
activeProviderInstanceId: $this->activeProviderInstanceId,
modelValues: $this->modelValues,
timeout: $this->llm_timeout,
tone: $this->tone_addressing,
);
session()->flash('saved', 'Settings opgeslagen.');
$this->refreshEmbeddingStats();
}
private function activeProviderInstance(): ?array
{
foreach ($this->providerInstances as $instance) {
if (($instance['id'] ?? null) === $this->activeProviderInstanceId) {
return $instance;
}
}
return $this->providerInstances[0] ?? null;
}
public function render()
{
return view('livewire.admin.settings-page');
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Livewire\Admin;
use App\Exceptions\OllamaUnavailableException;
use App\Services\AdminTicketService;
use App\Services\TicketIngestionService;
use Livewire\Component;
use Livewire\WithPagination;
@@ -12,11 +14,61 @@ class TicketMonitor extends Component
public int $perPage = 10;
public string $newTicketMessage = '';
public string $apiUser = '';
public string $apiPassword = '';
public ?array $lastResult = null;
public ?string $submitError = null;
public function updatedPerPage(): void
{
$this->resetPage();
}
public function submitTicket(TicketIngestionService $ticketIngestionService): void
{
$this->submitError = null;
$this->lastResult = null;
$this->validate([
'newTicketMessage' => ['required', 'string', 'min:5', 'max:5000'],
'apiUser' => ['nullable', 'string', 'max:255', 'required_with:apiPassword'],
'apiPassword' => ['nullable', 'string', 'max:255', 'required_with:apiUser'],
]);
try {
$ingested = $ticketIngestionService->ingest(trim($this->newTicketMessage), $this->credentialsPayload());
} catch (OllamaUnavailableException $e) {
$this->submitError = 'LLM provider niet beschikbaar. Reden: '.$e->getMessage();
return;
}
$this->reset(['newTicketMessage', 'apiUser', 'apiPassword']);
$this->resetPage();
$this->lastResult = [
'ticket_id' => $ingested['ticket']->id,
'status' => $ingested['ticket']->status,
];
}
private function credentialsPayload(): ?array
{
if (trim($this->apiUser) === '' && trim($this->apiPassword) === '') {
return null;
}
return [
'apiuser' => $this->apiUser,
'apipassword' => $this->apiPassword,
];
}
public function render(AdminTicketService $service)
{
return view('livewire.admin.ticket-monitor', [

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Admin;
use App\Jobs\ProcessTicketJob;
use App\Models\Ticket;
use App\Services\AdminTicketService;
use App\Services\TicketProcessingLoggerService;
use Livewire\Component;
class TicketShow extends Component
{
public int $ticketId;
public function mount(int $ticketId): void
{
$this->ticketId = $ticketId;
}
public function reprocess(TicketProcessingLoggerService $logger): void
{
$ticket = Ticket::query()->find($this->ticketId);
abort_if($ticket === null, 404);
$ticket->update([
'status' => 'queued',
'best_article_id' => null,
'confidence' => null,
'explanation' => null,
'support_reply' => null,
'needs_article_draft' => false,
'draft_article_id' => null,
'result_payload' => null,
'error_message' => null,
'processed_at' => null,
]);
$logger->log($ticket, 'queued', 'info', 'Ticket handmatig opnieuw in queue geplaatst via admin.');
ProcessTicketJob::dispatch($ticket->id);
session()->flash('success', 'Ticket is opnieuw in de queue geplaatst.');
}
public function render(AdminTicketService $service)
{
$ticket = $service->findWithTimeline($this->ticketId);
abort_if($ticket === null, 404);
return view('livewire.admin.ticket-show', [
'ticket' => $ticket,
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Casts\VectorCast;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Article extends Model
@@ -12,16 +13,23 @@ class Article extends Model
protected $fillable = [
'title',
'content',
'note',
'allowed_actions',
'status',
'is_ai_draft',
'embedding',
'source',
'source_url',
'source_article_id',
'category_id',
'subcategory_id',
'source_ticket_id',
];
protected $casts = [
'embedding' => VectorCast::class,
'allowed_actions' => 'array',
'is_ai_draft' => 'boolean',
];
public function decisions(): HasMany
@@ -38,4 +46,24 @@ class Article extends Model
{
return $this->belongsTo(Category::class, 'subcategory_id');
}
public function sourceTicket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'source_ticket_id');
}
public function chunks(): HasMany
{
return $this->hasMany(ArticleChunk::class);
}
public function quickReplies(): BelongsToMany
{
return $this->belongsToMany(QuickReply::class)->withTimestamps();
}
public function activeQuickReplies(): BelongsToMany
{
return $this->quickReplies()->where('is_active', true);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Casts\VectorCast;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArticleChunk extends Model
{
protected $fillable = [
'article_id',
'chunk_index',
'content',
'embedding',
'embedding_provider_instance_id',
'embedding_model',
'embedded_at',
];
protected $casts = [
'embedding' => VectorCast::class,
'embedded_at' => 'datetime',
];
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
}

View File

@@ -8,9 +8,9 @@ class EmbeddingCache extends Model
{
protected $table = 'embedding_cache';
protected $fillable = ['text_hash', 'text', 'embedding'];
protected $fillable = ['provider_instance_id', 'embedding_model', 'text_hash', 'text', 'embedding'];
protected $casts = [
'embedding' => 'array',
];
}
}

View File

@@ -9,4 +9,4 @@ class Feedback extends Model
protected $table = 'feedback';
protected $fillable = ['ticket_id', 'article_id', 'is_correct', 'notes'];
}
}

24
app/Models/QuickReply.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class QuickReply extends Model
{
protected $fillable = [
'title',
'content',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function articles(): BelongsToMany
{
return $this->belongsToMany(Article::class)->withTimestamps();
}
}

10
app/Models/Setting.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = ['key', 'value'];
}

View File

@@ -4,14 +4,40 @@ namespace App\Models;
use App\Casts\VectorCast;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Ticket extends Model
{
protected $fillable = ['message', 'embedding'];
protected $fillable = [
'message',
'normalized_message',
'redaction_report',
'embedding',
'embedding_provider_instance_id',
'embedding_model',
'embedded_at',
'status',
'best_article_id',
'confidence',
'explanation',
'support_reply',
'needs_article_draft',
'draft_article_id',
'result_payload',
'api_credentials',
'error_message',
'processed_at',
];
protected $casts = [
'embedding' => VectorCast::class,
'redaction_report' => 'array',
'result_payload' => 'array',
'api_credentials' => 'encrypted:array',
'needs_article_draft' => 'boolean',
'embedded_at' => 'datetime',
'processed_at' => 'datetime',
];
public function decisions(): HasMany
@@ -23,4 +49,28 @@ class Ticket extends Model
{
return $this->hasMany(Feedback::class);
}
public function logs(): HasMany
{
return $this->hasMany(TicketProcessingLog::class)
->orderByDesc('created_at')
->orderByDesc('id');
}
public function toolCalls(): HasMany
{
return $this->hasMany(TicketToolCall::class)
->orderByDesc('created_at')
->orderByDesc('id');
}
public function bestArticle(): BelongsTo
{
return $this->belongsTo(Article::class, 'best_article_id');
}
public function draftArticle(): BelongsTo
{
return $this->belongsTo(Article::class, 'draft_article_id');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketProcessingLog extends Model
{
protected $fillable = ['ticket_id', 'step', 'status', 'message', 'context'];
protected $casts = [
'context' => 'array',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketToolCall extends Model
{
protected $fillable = [
'ticket_id',
'article_id',
'action',
'status',
'parameters',
'response',
'error',
'executed_at',
];
protected $casts = [
'parameters' => 'array',
'response' => 'array',
'executed_at' => 'datetime',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function article(): BelongsTo
{
return $this->belongsTo(Article::class);
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Observers;
use App\Jobs\GenerateArticleEmbeddingJob;
use App\Models\Article;
use App\Services\EmbeddingService;
use App\Services\ArticleIndexingService;
class ArticleObserver
{
@@ -16,12 +16,10 @@ class ArticleObserver
if ((bool) config('services.embedding.queue_embeddings')) {
GenerateArticleEmbeddingJob::dispatch($article->id);
return;
}
$service = app(EmbeddingService::class);
$article->embedding = $service->embed($article->title."\n".$article->content);
$article->saveQuietly();
app(ArticleIndexingService::class)->indexArticle($article);
}
}
}

View File

@@ -2,23 +2,35 @@
namespace App\Providers;
use App\Models\Article;
use App\Observers\ArticleObserver;
use App\Repositories\ArticleRepository;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use App\Services\AppSettingsService;
use App\Services\Llm\LlmClientInterface;
use App\Services\Llm\LmStudioClient;
use App\Services\Llm\OllamaClient;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
$this->app->bind(ArticleRepositoryInterface::class, ArticleRepository::class);
$this->app->bind(LlmClientInterface::class, function ($app) {
$settings = $app->make(AppSettingsService::class);
$provider = (string) ($settings->activeProviderInstance()['type'] ?? 'ollama');
return match ($provider) {
'lmstudio' => new LmStudioClient($settings),
default => new OllamaClient($settings),
};
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
Article::observe(ArticleObserver::class);
}
}

View File

@@ -4,25 +4,43 @@ namespace App\Repositories;
use App\DTOs\ArticleCandidateDTO;
use App\Models\Article;
use App\Models\ArticleChunk;
use App\Repositories\Contracts\ArticleRepositoryInterface;
use Illuminate\Support\Facades\DB;
class ArticleRepository implements ArticleRepositoryInterface
{
public function findSimilarByEmbedding(array $embedding, int $limit = 5): array
public function findSimilarByEmbedding(array $embedding, int $limit = 5, array $embeddingContext = []): array
{
$vector = '['.implode(',', array_map(static fn ($value) => (float) $value, $embedding)).']';
$rows = Article::query()
->select('articles.*')
->selectRaw('embedding <=> ?::vector as distance', [$vector])
$chunkDistances = ArticleChunk::query()
->selectRaw('article_id, MIN(embedding <=> ?::vector) as distance', [$vector])
->whereNotNull('embedding')
->orderByRaw('embedding <=> ?::vector', [$vector])
->when($embeddingContext !== [], function ($query) use ($embeddingContext) {
$query
->where('embedding_provider_instance_id', $embeddingContext['provider_instance_id'] ?? null)
->where('embedding_model', $embeddingContext['embedding_model'] ?? null);
})
->groupBy('article_id')
->orderByRaw('MIN(embedding <=> ?::vector)', [$vector])
->limit($limit)
->get();
return $rows
->map(fn (Article $article) => ArticleCandidateDTO::fromArticle($article, (float) $article->distance))
if ($chunkDistances->isEmpty()) {
return [];
}
$distanceByArticleId = $chunkDistances->pluck('distance', 'article_id');
$articleIds = $chunkDistances->pluck('article_id')->all();
$articles = Article::query()
->whereIn('id', $articleIds)
->get()
->sortBy(fn (Article $a) => (float) ($distanceByArticleId[$a->id] ?? 1))
->values();
return $articles
->map(fn (Article $article) => ArticleCandidateDTO::fromArticle($article, (float) ($distanceByArticleId[$article->id] ?? 1)))
->all();
}
}
}

View File

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

View File

@@ -4,80 +4,111 @@ namespace App\Services;
use App\DTOs\ArticleCandidateDTO;
use App\DTOs\ClassificationResultDTO;
use App\Exceptions\OllamaUnavailableException;
use Illuminate\Support\Facades\Http;
use Throwable;
use App\Services\Llm\LlmClientInterface;
class AIClassifierService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
private readonly ClassifierPromptBuilder $promptBuilder,
private readonly LlmJsonDecoder $jsonDecoder,
private readonly ToolCallRequestValidator $toolCallValidator,
) {}
/** @param array<ArticleCandidateDTO> $candidates */
public function rank(string $ticketMessage, array $candidates): ClassificationResultDTO
public function rank(string $ticketMessage, array $candidates, string $language = 'nl'): ClassificationResultDTO
{
if ($candidates === []) {
return new ClassificationResultDTO(null, 0.0, 'No article candidates available');
return new ClassificationResultDTO(null, 0.0, 'No article candidates available', rawResponse: ['mode' => 'none']);
}
$articlesBlock = collect($candidates)
->map(fn (ArticleCandidateDTO $item, int $idx) => sprintf(
"%d. article_id=%d\nTitle: %s\nContent: %s",
$idx + 1,
$item->articleId,
$item->title,
$item->content
))
->implode("\n\n");
if (! (bool) config('services.llm.ranking_enabled', true)) {
return new ClassificationResultDTO(
articleId: $candidates[0]->articleId,
confidence: 0.20,
explanation: 'LLM ranking disabled; using top semantic candidate.',
rawResponse: ['mode' => 'semantic_fallback', 'ranking_enabled' => false]
);
}
$prompt = <<<PROMPT
You are a support assistant.
User question:
"{$ticketMessage}"
Articles:
{$articlesBlock}
Task:
- Select the best matching article
- Return:
- article_id
- confidence (0-1)
- short explanation
Respond in JSON ONLY.
PROMPT;
$baseUrl = rtrim((string) config('services.ollama.base_url'), '/');
$basePrompt = $this->settings->getPrompt('classifier', 'Select best article and return JSON.');
$prompt = $this->promptBuilder->build($basePrompt, $ticketMessage, $candidates, $language);
try {
$response = Http::timeout((int) config('services.ollama.timeout', 30))
->post($baseUrl.'/api/generate', [
'model' => config('services.ollama.chat_model', 'llama3'),
'prompt' => $prompt,
'stream' => false,
])
->throw()
->json();
} catch (Throwable $e) {
throw new OllamaUnavailableException('Ollama generate endpoint is unavailable', 0, $e);
$text = $this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'classifier']);
} catch (\Throwable $e) {
return new ClassificationResultDTO(
articleId: $candidates[0]->articleId,
confidence: 0.25,
explanation: 'LLM unavailable; fallback to top semantic match. Reason: '.$e->getMessage(),
rawResponse: ['mode' => 'semantic_fallback', 'error' => $e->getMessage()]
);
}
$text = (string) ($response['response'] ?? '{}');
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
$decoded = $this->jsonDecoder->decode($text);
if (! is_array($decoded)) {
return new ClassificationResultDTO(
articleId: $candidates[0]->articleId,
confidence: 0.35,
explanation: 'LLM returned invalid JSON; defaulted to top semantic match.',
rawResponse: ['raw' => $text]
rawResponse: ['mode' => 'semantic_fallback', 'raw' => $text]
);
}
$validated = $this->validateClassificationSchema($decoded, $candidates);
if ($validated === null) {
return new ClassificationResultDTO(
articleId: $candidates[0]->articleId,
confidence: 0.35,
explanation: 'LLM JSON schema invalid; defaulted to top semantic match.',
rawResponse: ['mode' => 'semantic_fallback', 'raw' => $decoded]
);
}
$validated['_meta'] = [
'mode' => 'llm',
'provider' => $this->settings->get('llm.provider', (string) config('services.llm.provider')),
'model' => $this->settings->get('llm.models.classifier', (string) config('services.llm.chat_model')),
];
return new ClassificationResultDTO(
articleId: isset($decoded['article_id']) ? (int) $decoded['article_id'] : $candidates[0]->articleId,
confidence: isset($decoded['confidence']) ? (float) $decoded['confidence'] : 0.35,
explanation: (string) ($decoded['explanation'] ?? 'No explanation provided.'),
rawResponse: $decoded
articleId: (int) $validated['article_id'],
confidence: (float) $validated['confidence'],
explanation: (string) $validated['explanation'],
toolCall: $validated['tool_call'] ?? null,
rawResponse: $validated
);
}
}
private function validateClassificationSchema(array $decoded, array $candidates): ?array
{
if (! isset($decoded['article_id'], $decoded['confidence'], $decoded['explanation'])) {
return null;
}
$candidateIds = collect($candidates)->map(fn (ArticleCandidateDTO $c) => $c->articleId)->all();
$articleId = (int) $decoded['article_id'];
$confidence = (float) $decoded['confidence'];
$explanation = trim((string) $decoded['explanation']);
if (! in_array($articleId, $candidateIds, true)) {
return null;
}
if ($confidence < 0 || $confidence > 1) {
return null;
}
if ($explanation === '') {
return null;
}
return [
'article_id' => $articleId,
'confidence' => round($confidence, 4),
'explanation' => $explanation,
'tool_call' => $this->toolCallValidator->validate($decoded['tool_call'] ?? null),
];
}
}

View File

@@ -3,19 +3,88 @@
namespace App\Services;
use App\Models\Article;
use App\Models\QuickReply;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class AdminArticleService
{
public function paginate(int $perPage = 10)
public function paginate(int $perPage = 10): LengthAwarePaginator
{
return Article::query()->latest()->paginate($perPage);
return Article::query()
->with('quickReplies')
->latest()
->paginate($perPage);
}
public function create(string $title, string $content): Article
public function create(string $title, string $content, ?string $note = null, array $allowedActions = []): Article
{
return Article::query()->create([
'title' => trim($title),
'content' => trim($content),
'note' => $note !== null && trim($note) !== '' ? trim($note) : null,
'allowed_actions' => $this->sanitizeAllowedActions($allowedActions),
'status' => 'published',
'is_ai_draft' => false,
]);
}
public function deleteById(int $articleId): bool
{
return (bool) Article::query()->whereKey($articleId)->delete();
}
public function updateMetadata(int $articleId, ?string $note, array $allowedActions, array $quickReplyIds = []): bool
{
$article = Article::query()->find($articleId);
if ($article === null) {
return false;
}
DB::transaction(function () use ($article, $note, $allowedActions, $quickReplyIds): void {
$article->note = $note !== null && trim($note) !== '' ? trim($note) : null;
$article->allowed_actions = $this->sanitizeAllowedActions($allowedActions);
$article->save();
$article->quickReplies()->sync($this->existingQuickReplyIds($quickReplyIds));
});
return true;
}
public function approveDraft(int $articleId): bool
{
$article = Article::query()->find($articleId);
if ($article === null) {
return false;
}
$article->status = 'published';
$article->is_ai_draft = false;
$article->save();
return true;
}
private function sanitizeAllowedActions(array $allowedActions): array
{
return array_values(array_intersect(array_unique($allowedActions), ['domain_inf']));
}
private function existingQuickReplyIds(array $quickReplyIds): array
{
$ids = array_values(array_unique(array_filter(
array_map(static fn ($id) => (int) $id, $quickReplyIds),
static fn ($id) => $id > 0
)));
if ($ids === []) {
return [];
}
return QuickReply::query()
->whereIn('id', $ids)
->pluck('id')
->map(static fn ($id) => (int) $id)
->all();
}
}

View File

@@ -37,6 +37,7 @@ class AdminDashboardService
}
$correct = Feedback::query()->where('is_correct', true)->count();
return round(($correct / $total) * 100, 2);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use App\Models\QuickReply;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class AdminQuickReplyService
{
public function paginate(int $perPage = 10): LengthAwarePaginator
{
return QuickReply::query()
->withCount('articles')
->latest()
->paginate($perPage);
}
public function activeOptions(): Collection
{
return QuickReply::query()
->where('is_active', true)
->orderBy('title')
->get(['id', 'title']);
}
public function create(string $title, string $content, bool $isActive = true): QuickReply
{
return QuickReply::query()->create([
'title' => trim($title),
'content' => trim($content),
'is_active' => $isActive,
]);
}
public function update(int $id, string $title, string $content, bool $isActive): bool
{
$quickReply = QuickReply::query()->find($id);
if ($quickReply === null) {
return false;
}
$quickReply->update([
'title' => trim($title),
'content' => trim($content),
'is_active' => $isActive,
]);
return true;
}
public function deleteById(int $id): bool
{
return (bool) QuickReply::query()->whereKey($id)->delete();
}
}

View File

@@ -9,8 +9,15 @@ class AdminTicketService
public function paginateWithDecision(int $perPage = 10)
{
return Ticket::query()
->with(['decisions.article'])
->with(['decisions.article', 'bestArticle'])
->latest()
->paginate($perPage);
}
public function findWithTimeline(int $ticketId): ?Ticket
{
return Ticket::query()
->with(['bestArticle', 'draftArticle', 'logs', 'decisions.article', 'toolCalls.article'])
->find($ticketId);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Schema;
class AppSettingsService
{
public function defaults(): array
{
return [
'tone_addressing' => 'je',
'prompt.normalization' => 'You rewrite customer support questions. Keep intent, fix spelling, remove noise, redact PII. Return JSON with normalized_message and redaction_report.',
'prompt.classifier' => 'You are a support assistant. Select best article and return JSON. Include tool_call only when the selected article explicitly allows that action and all required parameters are present.',
'prompt.knowledge_gap' => 'Create a draft knowledge base article suggestion based on the customer question. Use the requested output language passed in the prompt. Return JSON only with keys: title, content.',
'prompt.support_reply' => 'Give only direct advice in the requested output language. No greeting, no closing, no thank-you text. Start directly with the solution. Give 3-6 numbered action points and end with a verification step.',
'llm.provider' => env('LLM_PROVIDER', 'ollama'),
'llm.active_instance_id' => env('LLM_PROVIDER', 'ollama').'_default',
'llm.provider_instances' => json_encode($this->defaultProviderInstances()),
'llm.timeout' => (string) env('LLM_TIMEOUT', env('OLLAMA_TIMEOUT', 30)),
'llm.providers.ollama.base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
'llm.providers.ollama.chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
'llm.providers.ollama.embedding_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'),
'llm.providers.lmstudio.base_url' => env('LLM_BASE_URL', 'http://localhost:1234'),
'llm.providers.lmstudio.chat_model' => env('LLM_CHAT_MODEL', 'local-model'),
'llm.providers.lmstudio.embedding_model' => env('LLM_EMBEDDING_MODEL', 'text-embedding-nomic-embed-text-v1.5@q6_k'),
'llm.models.normalization' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')),
'llm.models.classifier' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')),
'llm.models.knowledge_gap' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')),
'llm.models.support_reply' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')),
'llm.models.embedding' => env('LLM_EMBEDDING_MODEL', env('OLLAMA_EMBED_MODEL', 'nomic-embed-text')),
];
}
public function processSteps(): array
{
return [
['id' => 'normalization', 'number' => 1, 'title' => 'Vraag netjes maken', 'description' => 'Haalt typefouten, ruis en privegegevens uit de klantvraag voordat de app ermee verder werkt.', 'prompt_key' => 'prompt.normalization'],
['id' => 'embedding', 'number' => 2, 'title' => 'Betekenis-code maken', 'description' => 'Zet de opgeschoonde vraag om naar een code die beschrijft waar de vraag over gaat.'],
['id' => 'retrieval', 'number' => 3, 'title' => 'Kennisbank zoeken', 'description' => 'Zoekt kleine stukjes kennisbanktekst die qua betekenis het meest op de vraag lijken.'],
['id' => 'classifier', 'number' => 4, 'title' => 'Beste artikel kiezen', 'description' => 'Laat de AI kiezen welk gevonden artikel het beste past en waarom.', 'prompt_key' => 'prompt.classifier'],
['id' => 'tool_call', 'number' => 5, 'title' => 'Extra informatie ophalen', 'description' => 'Gebruikt alleen toegestane hulpmiddelen, zoals domeininformatie ophalen, als het artikel dit toestaat.'],
['id' => 'quick_reply', 'number' => 6, 'title' => 'Snelantwoord controleren', 'description' => 'Gebruikt een gekoppeld snelantwoord als dat beschikbaar is, zodat de AI geen nieuw antwoord hoeft te schrijven.'],
['id' => 'knowledge_gap', 'number' => 7, 'title' => 'Missend artikel herkennen', 'description' => 'Geeft aan dat de kennisbank waarschijnlijk geen goed artikel heeft voor deze vraag.', 'prompt_key' => 'prompt.knowledge_gap'],
['id' => 'support_reply', 'number' => 8, 'title' => 'Conceptadvies maken', 'description' => 'Schrijft alleen een korte conceptreactie als er geen snelantwoord beschikbaar is.', 'prompt_key' => 'prompt.support_reply'],
];
}
public function providerDefinitions(): array
{
return [
'lmstudio' => ['label' => 'LM Studio', 'description' => 'OpenAI-compatible endpoint op je lokale netwerk.'],
'ollama' => ['label' => 'Ollama', 'description' => 'Lokale Ollama API zonder externe providers.'],
];
}
public function defaultProviderInstances(): array
{
return [
[
'id' => 'lmstudio_default',
'name' => 'LM Studio',
'type' => 'lmstudio',
'base_url' => env('LLM_BASE_URL', 'http://localhost:1234'),
'chat_model' => env('LLM_CHAT_MODEL', 'local-model'),
'embedding_model' => env('LLM_EMBEDDING_MODEL', 'text-embedding-nomic-embed-text-v1.5@q6_k'),
],
[
'id' => 'ollama_default',
'name' => 'Ollama',
'type' => 'ollama',
'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
'embedding_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'),
],
];
}
public function modelTasks(): array
{
return [
['id' => 'normalization', 'number' => 1, 'title' => 'Vraag netjes maken', 'description' => 'Model dat de ruwe klantvraag herschrijft en privegegevens weghaalt.'],
['id' => 'embedding', 'number' => 2, 'title' => 'Betekenis-code maken', 'description' => 'Model dat vragen en artikelstukjes omzet naar zoekcodes.'],
['id' => 'classifier', 'number' => 3, 'title' => 'Beste artikel kiezen', 'description' => 'Model dat kiest welk artikel het beste past en hoe zeker die keuze is.'],
['id' => 'knowledge_gap', 'number' => 4, 'title' => 'Missend artikel herkennen', 'description' => 'Model dat een voorstel maakt als de kennisbank tekortschiet.'],
['id' => 'support_reply', 'number' => 5, 'title' => 'Conceptadvies maken', 'description' => 'Model dat het uiteindelijke advies opstelt.'],
];
}
public function all(): array
{
$this->ensureDefaultsPersisted();
$defaults = $this->defaults();
$stored = Setting::query()->pluck('value', 'key')->toArray();
return array_merge($defaults, $stored);
}
public function get(string $key, ?string $default = null): ?string
{
$all = $this->all();
return $all[$key] ?? $default;
}
public function getPrompt(string $key, ?string $default = null): ?string
{
$promptValue = $this->get('prompt.'.$key);
if (is_string($promptValue) && trim($promptValue) !== '') {
return $promptValue;
}
// Backward compatibility for earlier stored keys.
$legacyKeyMap = [
'normalization' => 'normalization_prompt',
'classifier' => 'classifier_prompt',
'support_reply' => 'support_reply_prompt',
];
$legacyKey = $legacyKeyMap[$key] ?? null;
if ($legacyKey !== null) {
$legacyValue = $this->get($legacyKey);
if (is_string($legacyValue) && trim($legacyValue) !== '') {
return $legacyValue;
}
}
return $default;
}
public function promptSettings(): array
{
$settings = $this->all();
$prompts = [];
foreach ($settings as $key => $value) {
if (str_starts_with((string) $key, 'prompt.')) {
$prompts[$key] = (string) $value;
}
}
ksort($prompts);
return $prompts;
}
public function promptValues(): array
{
$values = [];
foreach ($this->processSteps() as $step) {
if (isset($step['prompt_key'])) {
$id = (string) $step['id'];
$values[$id] = (string) $this->getPrompt($id, '');
}
}
return $values;
}
public function providerSettings(): array
{
return [
'instances' => $this->providerInstances(),
'active_instance_id' => $this->activeProviderInstanceId(),
];
}
public function providerInstances(): array
{
$raw = $this->get('llm.provider_instances');
$decoded = is_string($raw) ? json_decode($raw, true) : null;
if (! is_array($decoded) || $decoded === []) {
return $this->defaultProviderInstances();
}
return array_values(array_filter($decoded, static fn ($item) => is_array($item) && isset($item['id'], $item['type'])));
}
public function activeProviderInstanceId(): string
{
$active = (string) $this->get('llm.active_instance_id', '');
$ids = array_column($this->providerInstances(), 'id');
if ($active !== '' && in_array($active, $ids, true)) {
return $active;
}
return (string) ($ids[0] ?? 'ollama_default');
}
public function activeProviderInstance(): array
{
$active = $this->activeProviderInstanceId();
foreach ($this->providerInstances() as $instance) {
if (($instance['id'] ?? null) === $active) {
return $instance;
}
}
return $this->defaultProviderInstances()[0];
}
public function modelSettings(): array
{
$models = [];
foreach ($this->modelTasks() as $task) {
$id = (string) $task['id'];
$models[$id] = (string) $this->get("llm.models.{$id}", '');
}
return $models;
}
public function setMany(array $pairs): void
{
foreach ($pairs as $key => $value) {
Setting::query()->updateOrCreate(['key' => $key], ['value' => (string) $value]);
}
}
public function savePromptSettings(array $prompts): void
{
$keys = array_map(static fn ($key) => (string) $key, array_keys($prompts));
Setting::query()
->where('key', 'like', 'prompt.%')
->whereNotIn('key', $keys)
->delete();
foreach ($prompts as $key => $value) {
Setting::query()->updateOrCreate(['key' => (string) $key], ['value' => (string) $value]);
}
}
public function saveStructuredSettings(array $promptValues, array $providerInstances, string $activeProviderInstanceId, array $modelValues, int $timeout, string $tone): void
{
$activeInstance = collect($providerInstances)->firstWhere('id', $activeProviderInstanceId) ?: ($providerInstances[0] ?? []);
$activeProvider = (string) ($activeInstance['type'] ?? 'ollama');
$pairs = [
'tone_addressing' => $tone,
'llm.provider' => $activeProvider,
'llm.active_instance_id' => $activeProviderInstanceId,
'llm.provider_instances' => json_encode(array_values($providerInstances), JSON_UNESCAPED_SLASHES),
'llm.timeout' => (string) $timeout,
];
foreach ($promptValues as $id => $value) {
$pairs['prompt.'.$id] = (string) $value;
}
foreach ($providerInstances as $instance) {
$provider = (string) ($instance['type'] ?? '');
if ($provider === '') {
continue;
}
foreach (['base_url', 'chat_model', 'embedding_model'] as $field) {
$pairs["llm.providers.{$provider}.{$field}"] = (string) ($instance[$field] ?? '');
}
}
foreach ($modelValues as $id => $value) {
$pairs["llm.models.{$id}"] = (string) $value;
}
$this->setMany($pairs);
}
private function ensureDefaultsPersisted(): void
{
if (! Schema::hasTable('settings')) {
return;
}
foreach ($this->defaults() as $key => $value) {
Setting::query()->firstOrCreate(['key' => $key], ['value' => (string) $value]);
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services;
class ArticleChunkerService
{
/** @return array<int, string> */
public function chunk(string $text, int $targetTokens = 100, int $overlapTokens = 20): array
{
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
if ($text === '') {
return [];
}
// Approximate tokenization by words for deterministic local chunking.
$words = preg_split('/\s+/', $text) ?: [];
$count = count($words);
if ($count === 0) {
return [];
}
$step = max(1, $targetTokens - $overlapTokens);
$chunks = [];
for ($start = 0; $start < $count; $start += $step) {
$slice = array_slice($words, $start, $targetTokens);
if ($slice === []) {
continue;
}
$chunks[] = implode(' ', $slice);
if (($start + $targetTokens) >= $count) {
break;
}
}
return $chunks;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services;
use App\Jobs\GenerateArticleEmbeddingJob;
use App\Models\Article;
use App\Models\ArticleChunk;
class ArticleEmbeddingMaintenanceService
{
public function __construct(private readonly EmbeddingService $embeddingService) {}
public function stats(): array
{
$context = $this->embeddingService->context();
$articles = Article::query()->count();
$articlesWithChunks = Article::query()->has('chunks')->count();
$chunks = ArticleChunk::query()->count();
$chunksWithEmbedding = ArticleChunk::query()->whereNotNull('embedding')->count();
$currentChunks = ArticleChunk::query()
->where('embedding_provider_instance_id', $context['provider_instance_id'])
->where('embedding_model', $context['embedding_model'])
->count();
return [
'articles' => $articles,
'articles_with_chunks' => $articlesWithChunks,
'articles_without_chunks' => max(0, $articles - $articlesWithChunks),
'chunks' => $chunks,
'chunks_with_embedding' => $chunksWithEmbedding,
'chunks_without_embedding' => max(0, $chunks - $chunksWithEmbedding),
'current_embedding_chunks' => $currentChunks,
'stale_or_other_model_chunks' => max(0, $chunks - $currentChunks),
'active_provider_instance_id' => $context['provider_instance_id'],
'active_embedding_model' => $context['embedding_model'],
];
}
public function dispatchReindex(bool $force = false): int
{
$query = Article::query()->orderBy('id');
if (! $force) {
$query->whereDoesntHave('chunks');
}
$count = 0;
$query->chunkById(100, function ($articles) use (&$count) {
foreach ($articles as $article) {
GenerateArticleEmbeddingJob::dispatch($article->id);
$count++;
}
});
return $count;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services;
use App\Models\Article;
class ArticleIndexingService
{
public function __construct(
private readonly EmbeddingService $embeddingService,
private readonly ArticleChunkerService $chunkerService,
) {}
public function indexArticle(Article $article): void
{
$chunks = $this->chunkerService->chunk($article->title."\n\n".$article->content, 100, 20);
$context = $this->embeddingService->context();
$article->chunks()->delete();
foreach ($chunks as $idx => $chunkText) {
$article->chunks()->create([
'chunk_index' => $idx,
'content' => $chunkText,
'embedding' => $this->embeddingService->embed($chunkText),
'embedding_provider_instance_id' => $context['provider_instance_id'],
'embedding_model' => $context['embedding_model'],
'embedded_at' => now(),
]);
}
// Keep article-level embedding for backward compatibility/debugging.
$article->embedding = $this->embeddingService->embed($article->title."\n".$article->content);
$article->saveQuietly();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Services;
use App\DTOs\ArticleCandidateDTO;
class ClassifierPromptBuilder
{
/** @param array<ArticleCandidateDTO> $candidates */
public function build(string $basePrompt, string $ticketMessage, array $candidates, string $language): string
{
$articlesBlock = collect($candidates)
->map(fn (ArticleCandidateDTO $item, int $idx) => sprintf(
"%d. article_id=%d\nTitle: %s\nSource URL: %s\nAllowed actions: %s\nInternal note for support assistant: %s\nContent: %s",
$idx + 1,
$item->articleId,
$item->title,
$item->sourceUrl ?? '-',
$item->allowedActions === [] ? '[]' : json_encode($item->allowedActions, JSON_UNESCAPED_SLASHES),
$item->note ?: '-',
$item->content
))
->implode("\n\n");
return $basePrompt."\n\n".
"User language: {$language}. Return explanation in this language.\n\n".
"Return JSON only with this schema:\n".
"{\n".
" \"article_id\": number,\n".
" \"confidence\": number,\n".
" \"explanation\": string,\n".
" \"tool_call\": null | {\"action\": \"domain_inf\", \"parameters\": {\"sld\": string, \"tld\": string}, \"reason\": string}\n".
"}\n\n".
"Only include a tool_call when the selected article lists that action in Allowed actions and both required parameters are present in the user question.\n".
"Never invent sld or tld. If either parameter is missing, set tool_call to null.\n\n".
"User question:\n\"{$ticketMessage}\"\n\nArticles:\n{$articlesBlock}";
}
}

View File

@@ -4,44 +4,59 @@ namespace App\Services;
use App\Exceptions\OllamaUnavailableException;
use App\Models\EmbeddingCache;
use Illuminate\Support\Facades\Http;
use Throwable;
use App\Services\Llm\LlmClientInterface;
class EmbeddingService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
) {}
public function embed(string $text): array
{
$hash = hash('sha256', $text);
$cached = EmbeddingCache::query()->where('text_hash', $hash)->first();
$context = $this->context();
$cached = EmbeddingCache::query()
->where('provider_instance_id', $context['provider_instance_id'])
->where('embedding_model', $context['embedding_model'])
->where('text_hash', $hash)
->first();
if ($cached !== null) {
return $cached->embedding;
}
$baseUrl = rtrim((string) config('services.ollama.base_url'), '/');
try {
$response = Http::timeout((int) config('services.ollama.timeout', 30))
->post($baseUrl.'/api/embeddings', [
'model' => config('services.ollama.embed_model', 'nomic-embed-text'),
'prompt' => $text,
])
->throw()
->json();
} catch (Throwable $e) {
throw new OllamaUnavailableException('Ollama embedding endpoint is unavailable', 0, $e);
}
$embedding = $response['embedding'] ?? [];
if (!is_array($embedding) || $embedding === []) {
throw new OllamaUnavailableException('Ollama embedding response did not include a valid embedding');
$embedding = $this->llmClient->embed($text);
if (! is_array($embedding) || $embedding === []) {
throw new OllamaUnavailableException('LLM embedding response did not include a valid embedding');
}
EmbeddingCache::query()->updateOrCreate(
['text_hash' => $hash],
[
'provider_instance_id' => $context['provider_instance_id'],
'embedding_model' => $context['embedding_model'],
'text_hash' => $hash,
],
['text' => $text, 'embedding' => $embedding]
);
return $embedding;
}
}
public function context(): array
{
$instance = $this->settings->activeProviderInstance();
$instanceId = (string) ($instance['id'] ?? $this->settings->activeProviderInstanceId());
$model = trim((string) $this->settings->get('llm.models.embedding', ''));
if ($model === '') {
$model = (string) ($instance['embedding_model'] ?? '');
}
return [
'provider_instance_id' => $instanceId,
'embedding_model' => $model,
];
}
}

View File

@@ -38,12 +38,14 @@ class HelpdeskImportService
if ($parsed === null) {
$skipped++;
$progress && $progress($processed, $total, $articleUrl, 'skipped');
continue;
}
if ($dryRun) {
$imported++;
$progress && $progress($processed, $total, $articleUrl, 'dry-run');
continue;
}
@@ -52,18 +54,16 @@ class HelpdeskImportService
$categoryId = $this->resolveCategoryId($meta['category_external_id'] ?? null, $categoryMap);
$subcategoryId = $this->resolveCategoryId($meta['subcategory_external_id'] ?? null, $categoryMap);
$result = Article::withoutEvents(function () use ($title, $content, $articleUrl, $sourceArticleId, $categoryId, $subcategoryId) {
return Article::query()->updateOrCreate(
['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId],
[
'title' => $title,
'content' => $content,
'source_url' => $articleUrl,
'category_id' => $categoryId,
'subcategory_id' => $subcategoryId,
]
);
});
$result = Article::query()->updateOrCreate(
['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId],
[
'title' => $title,
'content' => $content,
'source_url' => $articleUrl,
'category_id' => $categoryId,
'subcategory_id' => $subcategoryId,
]
);
if ($result->wasRecentlyCreated) {
$imported++;
@@ -92,11 +92,12 @@ class HelpdeskImportService
private function extractCategories(string $html): array
{
if (!preg_match('/const\s+categories\s*=\s*(\[.*?\]);/s', $html, $matches)) {
if (! preg_match('/const\s+categories\s*=\s*(\[.*?\]);/s', $html, $matches)) {
return [];
}
$decoded = json_decode($matches[1], true);
return is_array($decoded) ? $decoded : [];
}
@@ -104,12 +105,12 @@ class HelpdeskImportService
{
$map = [];
foreach ($categories as $category) {
if (!isset($category['id'], $category['title'], $category['slug'])) {
if (! isset($category['id'], $category['title'], $category['slug'])) {
continue;
}
$parentId = null;
if (!$dryRun) {
if (! $dryRun) {
$model = Category::query()->updateOrCreate(
['external_id' => (int) $category['id']],
['name' => (string) $category['title'], 'slug' => (string) $category['slug'], 'parent_id' => null]
@@ -120,11 +121,11 @@ class HelpdeskImportService
$map[(int) $category['id']] = $parentId;
foreach (($category['children'] ?? []) as $child) {
if (!isset($child['id'], $child['title'], $child['slug'])) {
if (! isset($child['id'], $child['title'], $child['slug'])) {
continue;
}
if (!$dryRun && $parentId !== null) {
if (! $dryRun && $parentId !== null) {
$childModel = Category::query()->updateOrCreate(
['external_id' => (int) $child['id']],
['name' => (string) $child['title'], 'slug' => (string) $child['slug'], 'parent_id' => $parentId]
@@ -143,7 +144,7 @@ class HelpdeskImportService
{
$sections = [];
foreach ($categories as $category) {
if (!isset($category['id'], $category['slug'])) {
if (! isset($category['id'], $category['slug'])) {
continue;
}
@@ -154,7 +155,7 @@ class HelpdeskImportService
];
foreach (($category['children'] ?? []) as $child) {
if (!isset($child['id'], $child['slug'])) {
if (! isset($child['id'], $child['slug'])) {
continue;
}
@@ -186,7 +187,7 @@ class HelpdeskImportService
preg_match_all('/https:\/\/www\.internettoday\.nl\/helpdesk\/(\d+)-[a-z0-9\-]+/i', $html, $matches);
foreach (($matches[0] ?? []) as $match) {
$url = strtolower($match);
if (!isset($result[$url])) {
if (! isset($result[$url])) {
$result[$url] = [
'category_external_id' => $source['category_external_id'],
'subcategory_external_id' => $source['subcategory_external_id'],
@@ -206,7 +207,7 @@ class HelpdeskImportService
return null;
}
if (!preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $titleMatch)) {
if (! preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $titleMatch)) {
return null;
}
@@ -215,7 +216,7 @@ class HelpdeskImportService
return null;
}
if (!preg_match('/<div\s+class="main_1_column">\s*(<p.*?<\/p>)\s*<\/div>/is', $html, $contentMatch)) {
if (! preg_match('/<div\s+class="main_1_column">\s*(<p.*?<\/p>)\s*<\/div>/is', $html, $contentMatch)) {
return null;
}
@@ -228,7 +229,7 @@ class HelpdeskImportService
return null;
}
if (!preg_match('/\/helpdesk\/(\d+)-/', $url, $idMatch)) {
if (! preg_match('/\/helpdesk\/(\d+)-/', $url, $idMatch)) {
return null;
}
@@ -248,6 +249,7 @@ class HelpdeskImportService
{
$decoded = html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$decoded = preg_replace('/\s+/', ' ', $decoded) ?? $decoded;
return trim($decoded);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use App\Models\Ticket;
use App\Services\Llm\LlmClientInterface;
use Illuminate\Support\Str;
class KnowledgeGapService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
) {}
public function shouldCreateDraft(Ticket $ticket, array $result): bool
{
$confidence = (float) ($result['confidence'] ?? 0);
if ($confidence < 0.45) {
return true;
}
$explanation = mb_strtolower((string) ($result['explanation'] ?? ''));
$signals = ['does not contain', 'niet relevant', 'mismatch', 'no article', 'onvoldoende', 'server outage'];
foreach ($signals as $signal) {
if (str_contains($explanation, $signal)) {
return true;
}
}
return false;
}
public function suggestArticleDraft(Ticket $ticket, array $result): array
{
$question = $ticket->normalized_message ?: $ticket->message;
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
$topCandidates = json_encode($result['top_3_candidates'] ?? [], JSON_UNESCAPED_UNICODE);
$basePrompt = $this->settings->getPrompt('knowledge_gap', 'Create a draft knowledge base article suggestion. Return JSON only with keys: title, content.');
$prompt = $basePrompt."\n\n".
"Klantvraag:\n{$question}\n\n".
"Originele taal: {$language}. Schrijf titel en inhoud in deze taal.\n\n".
"Huidige kandidaten (mogelijk onvoldoende):\n{$topCandidates}\n\n".
'Content moet praktisch zijn met duidelijke stappen.';
$title = 'Concept: '.Str::limit($question, 80, '');
$content = "Deze vraag kon nog niet goed worden beantwoord vanuit de huidige kennisbank.\n\n".
"Klantvraag:\n".$question."\n\n".
'Actie: supportmedewerker vult dit artikel aan met definitieve stappen en context.';
try {
$raw = trim($this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'knowledge_gap']));
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
$title = trim((string) ($decoded['title'] ?? $title));
$content = trim((string) ($decoded['content'] ?? $content));
}
} catch (\Throwable) {
}
return [
'title' => $title,
'content' => $content,
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Services\Llm;
interface LlmClientInterface
{
public function embed(string $text): array;
public function generate(string $prompt, array $options = []): string;
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Services\Llm;
use App\Exceptions\OllamaUnavailableException;
use App\Services\AppSettingsService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
class LmStudioClient implements LlmClientInterface
{
public function __construct(private readonly AppSettingsService $settings) {}
public function embed(string $text): array
{
$timeout = $this->timeout();
$baseUrl = $this->baseUrl();
try {
$response = Http::connectTimeout(5)
->timeout($timeout)
->withOptions(['read_timeout' => $timeout])
->post($baseUrl.'/v1/embeddings', [
'model' => $this->model('embedding', (string) config('services.llm.embedding_model')),
'input' => $text,
])
->throw()
->json();
} catch (Throwable $e) {
throw $this->mapException($e, 'embedding');
}
$embedding = $response['data'][0]['embedding'] ?? [];
if (! is_array($embedding) || $embedding === []) {
throw new OllamaUnavailableException('lmstudio', 'embedding', 'No embedding in response');
}
return $embedding;
}
public function generate(string $prompt, array $options = []): string
{
$timeout = $this->timeout();
$baseUrl = $this->baseUrl();
$expectJson = (bool) ($options['expect_json'] ?? false);
$task = (string) ($options['task'] ?? 'chat');
$payload = [
'model' => $this->model($task, (string) config('services.llm.chat_model')),
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'temperature' => 0.1,
];
if ($expectJson) {
// OpenAI-compatible endpoints vary: some support json_schema/text only.
$payload['response_format'] = ['type' => 'json_schema'];
}
try {
$response = Http::connectTimeout(5)
->timeout($timeout)
->withOptions(['read_timeout' => $timeout])
->post($baseUrl.'/v1/chat/completions', $payload)
->throw()
->json();
} catch (RequestException $e) {
$body = (string) ($e->response?->body() ?? '');
$isResponseFormatError = str_contains($body, 'response_format.type')
|| str_contains($body, 'json_schema');
if ($expectJson && $isResponseFormatError) {
// Fallback retry without response_format for stricter local servers.
try {
$retryPayload = $payload;
unset($retryPayload['response_format']);
$response = Http::connectTimeout(5)
->timeout($timeout)
->withOptions(['read_timeout' => $timeout])
->post($baseUrl.'/v1/chat/completions', $retryPayload)
->throw()
->json();
return (string) ($response['choices'][0]['message']['content'] ?? '');
} catch (Throwable $retryException) {
throw $this->mapException($retryException, 'generation');
}
}
throw $this->mapException($e, 'generation');
} catch (Throwable $e) {
throw $this->mapException($e, 'generation');
}
return (string) ($response['choices'][0]['message']['content'] ?? '');
}
private function baseUrl(): string
{
$instance = $this->settings->activeProviderInstance();
return rtrim((string) ($instance['base_url'] ?? $this->settings->get('llm.providers.lmstudio.base_url', (string) config('services.llm.base_url'))), '/');
}
private function timeout(): int
{
return (int) $this->settings->get('llm.timeout', (string) config('services.llm.timeout', 30));
}
private function model(string $task, string $fallback): string
{
$instance = $this->settings->activeProviderInstance();
$chatModel = (string) ($instance['chat_model'] ?? $this->settings->get('llm.providers.lmstudio.chat_model', $fallback));
$embeddingModel = (string) ($instance['embedding_model'] ?? $this->settings->get('llm.providers.lmstudio.embedding_model', $fallback));
if ($task === 'chat') {
return $chatModel;
}
$configured = trim((string) $this->settings->get('llm.models.'.$task, ''));
if ($configured !== '') {
return $configured;
}
return $task === 'embedding' ? $embeddingModel : $chatModel;
}
private function mapException(Throwable $e, string $operation): OllamaUnavailableException
{
if ($e instanceof RequestException) {
$body = $e->response?->body();
$snippet = $body ? mb_substr($body, 0, 280) : null;
return new OllamaUnavailableException('lmstudio', $operation, $e->getMessage(), $e, $snippet);
}
return new OllamaUnavailableException('lmstudio', $operation, $e->getMessage(), $e);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Services\Llm;
use App\Exceptions\OllamaUnavailableException;
use App\Services\AppSettingsService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
class OllamaClient implements LlmClientInterface
{
public function __construct(private readonly AppSettingsService $settings) {}
public function embed(string $text): array
{
$baseUrl = $this->baseUrl();
try {
$response = Http::timeout($this->timeout())
->post($baseUrl.'/api/embeddings', [
'model' => $this->model('embedding', (string) config('services.llm.embedding_model', 'nomic-embed-text')),
'prompt' => $text,
])
->throw()
->json();
} catch (Throwable $e) {
throw $this->mapException($e, 'embedding');
}
$embedding = $response['embedding'] ?? [];
if (! is_array($embedding) || $embedding === []) {
throw new OllamaUnavailableException('ollama', 'embedding', 'No embedding in response');
}
return $embedding;
}
public function generate(string $prompt, array $options = []): string
{
$baseUrl = $this->baseUrl();
$expectJson = (bool) ($options['expect_json'] ?? false);
$task = (string) ($options['task'] ?? 'chat');
$payload = [
'model' => $this->model($task, (string) config('services.llm.chat_model', 'llama3')),
'prompt' => $prompt,
'stream' => false,
];
if ($expectJson) {
$payload['format'] = 'json';
}
try {
$response = Http::timeout($this->timeout())
->post($baseUrl.'/api/generate', $payload)
->throw()
->json();
} catch (Throwable $e) {
throw $this->mapException($e, 'generation');
}
return (string) ($response['response'] ?? '');
}
private function baseUrl(): string
{
$instance = $this->settings->activeProviderInstance();
return rtrim((string) ($instance['base_url'] ?? $this->settings->get('llm.providers.ollama.base_url', (string) config('services.llm.base_url'))), '/');
}
private function timeout(): int
{
return (int) $this->settings->get('llm.timeout', (string) config('services.llm.timeout', 30));
}
private function model(string $task, string $fallback): string
{
$instance = $this->settings->activeProviderInstance();
$chatModel = (string) ($instance['chat_model'] ?? $this->settings->get('llm.providers.ollama.chat_model', $fallback));
$embeddingModel = (string) ($instance['embedding_model'] ?? $this->settings->get('llm.providers.ollama.embedding_model', $fallback));
if ($task === 'chat') {
return $chatModel;
}
$configured = trim((string) $this->settings->get('llm.models.'.$task, ''));
if ($configured !== '') {
return $configured;
}
return $task === 'embedding' ? $embeddingModel : $chatModel;
}
private function mapException(Throwable $e, string $operation): OllamaUnavailableException
{
if ($e instanceof RequestException) {
$body = $e->response?->body();
$snippet = $body ? mb_substr($body, 0, 280) : null;
return new OllamaUnavailableException('ollama', $operation, $e->getMessage(), $e, $snippet);
}
return new OllamaUnavailableException('ollama', $operation, $e->getMessage(), $e);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services;
class LlmJsonDecoder
{
public function decode(string $raw): ?array
{
$raw = trim($raw);
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
// Handles responses wrapped as ```json ... ```
if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $raw, $matches) === 1) {
$decoded = json_decode(trim($matches[1]), true);
if (is_array($decoded)) {
return $decoded;
}
}
// Last-resort: extract first JSON object from mixed text.
$start = strpos($raw, '{');
$end = strrpos($raw, '}');
if ($start !== false && $end !== false && $end > $start) {
$candidate = substr($raw, $start, $end - $start + 1);
$decoded = json_decode($candidate, true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class LlmModelCatalogService
{
public function modelsFor(array $instance, bool $refresh = false): array
{
$cacheKey = 'llm_models_'.sha1(json_encode([
'type' => $instance['type'] ?? '',
'base_url' => $instance['base_url'] ?? '',
]));
if ($refresh) {
Cache::forget($cacheKey);
}
return Cache::remember($cacheKey, now()->addMinutes(5), fn () => $this->fetchModels($instance));
}
public function cachedUntil(array $instance): ?string
{
$models = $this->modelsFor($instance);
return $models === [] ? null : now()->addMinutes(5)->format('H:i:s');
}
private function fetchModels(array $instance): array
{
$type = (string) ($instance['type'] ?? '');
$baseUrl = rtrim((string) ($instance['base_url'] ?? ''), '/');
if ($baseUrl === '') {
return [];
}
return match ($type) {
'lmstudio' => $this->fetchLmStudioModels($baseUrl),
'ollama' => $this->fetchOllamaModels($baseUrl),
default => [],
};
}
private function fetchLmStudioModels(string $baseUrl): array
{
$response = Http::connectTimeout(2)
->timeout(5)
->get($baseUrl.'/v1/models')
->throw()
->json();
return collect($response['data'] ?? [])
->pluck('id')
->filter()
->map(fn ($model) => (string) $model)
->unique()
->sort()
->values()
->all();
}
private function fetchOllamaModels(string $baseUrl): array
{
$response = Http::connectTimeout(2)
->timeout(5)
->get($baseUrl.'/api/tags')
->throw()
->json();
return collect($response['models'] ?? [])
->map(fn ($model) => (string) ($model['name'] ?? $model['model'] ?? ''))
->filter()
->unique()
->sort()
->values()
->all();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\QuickReply;
class QuickReplyResolver
{
public function resolveForArticle(?Article $article): ?QuickReply
{
if ($article === null) {
return null;
}
if (! $article->relationLoaded('quickReplies')) {
$article->load('quickReplies');
}
return $article->quickReplies
->where('is_active', true)
->sortBy('title')
->first();
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Services;
use App\DTOs\ClassificationResultDTO;
use App\Models\AIDecision;
use App\Models\Article;
use App\Models\Ticket;
@@ -18,14 +17,26 @@ class SemanticSearchService
public function findBestArticle(Ticket $ticket): array
{
$embedding = $ticket->embedding ?? $this->embeddingService->embed($ticket->message);
if ($ticket->embedding === null) {
$queryText = $ticket->normalized_message ?: $ticket->message;
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
$embeddingContext = $this->embeddingService->context();
$ticketEmbeddingIsCurrent = $ticket->embedding !== null
&& $ticket->embedding_provider_instance_id === $embeddingContext['provider_instance_id']
&& $ticket->embedding_model === $embeddingContext['embedding_model'];
$embedding = $ticketEmbeddingIsCurrent ? $ticket->embedding : $this->embeddingService->embed($queryText);
if (! $ticketEmbeddingIsCurrent) {
$ticket->embedding = $embedding;
$ticket->embedding_provider_instance_id = $embeddingContext['provider_instance_id'];
$ticket->embedding_model = $embeddingContext['embedding_model'];
$ticket->embedded_at = now();
$ticket->save();
}
$candidates = $this->articleRepository->findSimilarByEmbedding($embedding, 5);
$classification = $this->classifierService->rank($ticket->message, $candidates);
$rawCandidates = $this->articleRepository->findSimilarByEmbedding($embedding, 12, $embeddingContext);
$candidates = $this->prepareCandidates($queryText, $rawCandidates, 5);
$classification = $this->classifierService->rank($queryText, $candidates, $language);
$bestArticle = $classification->articleId ? Article::find($classification->articleId) : null;
@@ -42,6 +53,61 @@ class SemanticSearchService
'confidence' => $classification->confidence,
'explanation' => $classification->explanation,
'top_3_candidates' => collect($candidates)->take(3)->map(fn ($c) => $c->toArray())->values()->all(),
'top_5_candidates' => collect($candidates)->map(fn ($c) => $c->toArray())->values()->all(),
'retrieval_meta' => [
'raw_candidates_count' => count($rawCandidates),
'deduped_candidates_count' => count($candidates),
],
'requested_tool_call' => $classification->toolCall,
'classifier_raw_response' => $classification->rawResponse,
];
}
}
private function prepareCandidates(string $queryText, array $candidates, int $limit): array
{
$seen = [];
$unique = [];
foreach ($candidates as $candidate) {
$dedupeKey = $candidate->sourceArticleId
? 'source_id:'.$candidate->sourceArticleId
: ($candidate->sourceUrl ? 'source_url:'.$candidate->sourceUrl : 'article:'.$candidate->articleId);
if (isset($seen[$dedupeKey])) {
continue;
}
$seen[$dedupeKey] = true;
$unique[] = $candidate;
}
$query = mb_strtolower($queryText);
$isHowTo = str_contains($query, 'hoe') || str_contains($query, 'instel') || str_contains($query, 'stap');
usort($unique, function ($a, $b) use ($isHowTo) {
$scoreA = $this->candidateScore($a->title.' '.$a->content, $a->distance, $isHowTo);
$scoreB = $this->candidateScore($b->title.' '.$b->content, $b->distance, $isHowTo);
return $scoreB <=> $scoreA;
});
return array_values(array_slice($unique, 0, $limit));
}
private function candidateScore(string $text, float $distance, bool $isHowTo): float
{
$score = 1.0 - $distance;
$haystack = mb_strtolower($text);
if ($isHowTo) {
$proceduralKeywords = ['hoe', 'stap', 'instellen', 'aanmaken', 'wijzigen', 'klik', 'menu', 'beheren', 'dns'];
foreach ($proceduralKeywords as $keyword) {
if (str_contains($haystack, $keyword)) {
$score += 0.03;
}
}
}
return $score;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\Ticket;
use App\Services\Llm\LlmClientInterface;
use Illuminate\Support\Str;
class SupportReplyService
{
public function __construct(
private readonly AppSettingsService $settings,
private readonly LlmClientInterface $llmClient,
private readonly TicketProcessingLoggerService $logger,
) {}
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.');
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.";
}
if ((bool) config('services.llm.ranking_enabled', true)) {
$userInput = $ticket->normalized_message ?: $ticket->message;
$language = (string) ($ticket->redaction_report['language'] ?? 'nl');
$llmPrompt = $basePrompt."\n\n".
"Customer language: {$language}. Write the advice in this language.\n".
'Gebruikersvraag (genormaliseerd): '.$userInput."\n".
'Beste artikel titel: '.$bestArticle->title."\n".
'Beste artikel content: '.$bestArticle->content."\n".
'Interne artikelnotitie: '.($bestArticle->note ?: '-')."\n".
'Waarom dit artikel gekozen is: '.$explanation."\n\n".
"Toolresultaat, indien beschikbaar:\n".$this->formatToolCallForPrompt($toolCall)."\n\n".
"Write direct advice only in the customer language.\n".
"Vereisten:\n".
"- GEEN aanhef, GEEN afsluiting, GEEN bedanktekst.\n".
"- Begin direct met de oplossing.\n".
"- Geef 3-6 genummerde actiepunten.\n".
"- Voeg een korte controle-stap toe als laatste punt.\n".
'- Geen markdown, geen codeblokken.';
try {
$reply = trim($this->llmClient->generate($llmPrompt, ['task' => 'support_reply']));
if ($reply !== '') {
return $reply;
}
$this->logger->log($ticket, 'support_reply', 'warning', 'LLM supportantwoord was leeg; artikel-gebaseerde fallback gebruikt.', [
'article_id' => $bestArticle->id,
'article_title' => $bestArticle->title,
]);
} catch (\Throwable $e) {
$this->logger->log($ticket, 'support_reply', 'warning', 'LLM supportantwoord faalde; artikel-gebaseerde fallback gebruikt.', [
'article_id' => $bestArticle->id,
'article_title' => $bestArticle->title,
'error' => $e->getMessage(),
]);
}
}
$snippet = Str::limit(trim($bestArticle->content), 420);
$safeExplanation = $this->sanitizeCustomerExplanation($explanation);
return "1. Gebruik het kennisbankartikel '".$bestArticle->title."' als basis voor de vervolgstappen.\n".
"2. Controleer de situatie aan de hand van de kernpunten uit het artikel.\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".
'Relevantie: '.$safeExplanation."\n".
'Bronsamenvatting: '.$snippet;
}
private function formatToolCallForPrompt(?array $toolCall): string
{
if ($toolCall === null) {
return 'Geen toolcall uitgevoerd.';
}
$safe = [
'action' => $toolCall['action'] ?? null,
'status' => $toolCall['status'] ?? null,
'parameters' => $toolCall['parameters'] ?? null,
'response' => $toolCall['response'] ?? null,
'error' => $toolCall['error'] ?? null,
];
return json_encode($safe, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Toolresultaat kon niet worden geformatteerd.';
}
private function sanitizeCustomerExplanation(string $explanation): string
{
$lower = mb_strtolower($explanation);
$hasTechnicalFailure = str_contains($lower, 'llm unavailable')
|| str_contains($lower, 'invalid json')
|| str_contains($lower, 'fallback')
|| str_contains($lower, 'http request')
|| str_contains($lower, 'response_format')
|| 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 $explanation;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Jobs\ProcessTicketJob;
use App\Models\Ticket;
class TicketIngestionService
{
public function __construct(
private readonly TicketProcessingLoggerService $logger,
) {}
public function ingest(string $message, ?array $apiCredentials = null): array
{
$ticket = Ticket::query()->create([
'message' => $message,
'status' => 'queued',
'api_credentials' => $this->sanitizeCredentials($apiCredentials),
]);
$this->logger->log($ticket, 'queued', 'info', 'Ticket in queue geplaatst.');
ProcessTicketJob::dispatch($ticket->id);
return [
'ticket' => $ticket,
'result' => null,
];
}
private function sanitizeCredentials(?array $apiCredentials): ?array
{
if ($apiCredentials === null) {
return null;
}
$apiuser = trim((string) ($apiCredentials['apiuser'] ?? ''));
$apipassword = trim((string) ($apiCredentials['apipassword'] ?? ''));
if ($apiuser === '' || $apipassword === '') {
return null;
}
return [
'apiuser' => $apiuser,
'apipassword' => $apipassword,
];
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Services\Llm\LlmClientInterface;
class TicketNormalizationService
{
public function __construct(
private readonly LlmClientInterface $llmClient,
private readonly AppSettingsService $settings,
) {}
public function normalize(string $original): array
{
$fallback = $this->fallbackNormalize($original);
$fallbackLanguage = $this->detectLanguage($original);
if (! (bool) config('services.llm.ranking_enabled', true)) {
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_normalization_disabled',
],
];
}
$basePrompt = $this->settings->getPrompt('normalization', 'Rewrite and redact PII. Return JSON.');
$prompt = $basePrompt."\n\n".
'Also detect the original language. Return JSON with keys: normalized_message, redaction_report. '.
"redaction_report must include pii_types and language as an ISO 639-1 code such as nl, en, de, fr.\n\n".
"Original question:\n\"\"\"\n{$original}\n\"\"\"";
try {
$raw = $this->llmClient->generate($prompt, ['expect_json' => true, 'task' => 'normalization']);
$decoded = $this->decodeJsonResponse($raw);
if (is_array($decoded) && ! empty($decoded['normalized_message'])) {
return [
'normalized_message' => (string) $decoded['normalized_message'],
'redaction_report' => [
'mode' => 'llm',
'pii_types' => $decoded['redaction_report']['pii_types'] ?? [],
'language' => $this->normalizeLanguageCode($decoded['redaction_report']['language'] ?? $fallbackLanguage),
'notes' => $decoded['redaction_report']['notes'] ?? null,
'raw' => $decoded,
],
];
}
} catch (\Throwable $e) {
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_exception',
'error' => $e->getMessage(),
],
];
}
return [
'normalized_message' => $fallback['text'],
'redaction_report' => [
'mode' => 'fallback_regex',
'pii_types' => $fallback['pii_types'],
'language' => $fallbackLanguage,
'reason' => 'llm_invalid_json_or_missing_fields',
],
];
}
private function fallbackNormalize(string $text): array
{
$pii = [];
// Replace highly-structured values first so looser patterns (phone) do not corrupt them.
$orderedPatterns = [
'email' => '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i',
'iban' => '/\b[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}\b/i',
'url' => '/https?:\/\/\S+/i',
'ip' => '/\b(?:\d{1,3}\.){3}\d{1,3}\b/',
// Require separators to avoid matching long account-like sequences.
'phone' => '/(?<![A-Z0-9])(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-])\d[\d\s\-]{4,}\d(?![A-Z0-9])/i',
];
foreach ($orderedPatterns as $type => $pattern) {
if (preg_match($pattern, $text) === 1) {
$pii[] = $type;
$text = preg_replace($pattern, '['.strtoupper($type).']', $text) ?? $text;
}
}
$text = preg_replace('/\s+/', ' ', trim($text)) ?? trim($text);
return ['text' => $text, 'pii_types' => array_values(array_unique($pii))];
}
private function detectLanguage(string $text): string
{
$lower = mb_strtolower($text);
$dutchSignals = [' ik ', ' mijn ', ' een ', ' het ', ' de ', ' hoe ', ' niet ', ' wordt ', ' domeinnaam ', ' website '];
$englishSignals = [' i ', ' my ', ' the ', ' how ', ' not ', ' website ', ' domain ', ' redirected '];
$padded = ' '.$lower.' ';
$nl = 0;
$en = 0;
foreach ($dutchSignals as $signal) {
$nl += substr_count($padded, $signal);
}
foreach ($englishSignals as $signal) {
$en += substr_count($padded, $signal);
}
return $en > $nl ? 'en' : 'nl';
}
private function normalizeLanguageCode(mixed $language): string
{
$value = mb_strtolower(trim((string) $language));
return match (true) {
str_starts_with($value, 'nl'), str_contains($value, 'dutch'), str_contains($value, 'nederlands') => 'nl',
str_starts_with($value, 'en'), str_contains($value, 'english') => 'en',
str_starts_with($value, 'de'), str_contains($value, 'german'), str_contains($value, 'duits') => 'de',
str_starts_with($value, 'fr'), str_contains($value, 'french'), str_contains($value, 'frans') => 'fr',
default => 'nl',
};
}
private function decodeJsonResponse(string $raw): ?array
{
$raw = trim($raw);
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
if (preg_match('/```(?:json)?\s*(\{.*\})\s*```/is', $raw, $matches) === 1) {
$decoded = json_decode(trim($matches[1]), true);
if (is_array($decoded)) {
return $decoded;
}
}
$start = strpos($raw, '{');
$end = strrpos($raw, '}');
if ($start !== false && $end !== false && $end > $start) {
$candidate = substr($raw, $start, $end - $start + 1);
$decoded = json_decode($candidate, true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Services;
use App\Models\Ticket;
use App\Models\TicketProcessingLog;
class TicketProcessingLoggerService
{
public function log(Ticket $ticket, string $step, string $status = 'info', ?string $message = null, array $context = []): void
{
TicketProcessingLog::query()->create([
'ticket_id' => $ticket->id,
'step' => $step,
'status' => $status,
'message' => $message,
'context' => $context === [] ? null : $context,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services;
use App\Models\QuickReply;
use App\Models\TicketToolCall;
class TicketResultPayloadBuilder
{
public function build(array $result, ?TicketToolCall $toolCallRecord, ?QuickReply $quickReply, bool $knowledgeGap, ?array $draftSuggestion): array
{
return [
'top_3_candidates' => $result['top_3_candidates'],
'top_5_candidates' => $result['top_5_candidates'] ?? [],
'classifier_raw_response' => $result['classifier_raw_response'] ?? null,
'requested_tool_call' => $result['requested_tool_call'] ?? null,
'tool_call' => $toolCallRecord?->toArray(),
'quick_reply' => $quickReply?->only(['id', 'title']),
'knowledge_gap' => $knowledgeGap,
'draft_article_suggestion' => $draftSuggestion,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services;
class ToolCallRequestValidator
{
public function validate(mixed $toolCall): ?array
{
if ($toolCall === null || $toolCall === '') {
return null;
}
if (! is_array($toolCall)) {
return null;
}
$action = (string) ($toolCall['action'] ?? '');
if ($action !== 'domain_inf') {
return null;
}
$parameters = is_array($toolCall['parameters'] ?? null) ? $toolCall['parameters'] : [];
$sld = trim((string) ($parameters['sld'] ?? ''));
$tld = trim((string) ($parameters['tld'] ?? ''));
if ($sld === '' || $tld === '') {
return null;
}
return [
'action' => $action,
'parameters' => [
'sld' => mb_strtolower($sld),
'tld' => mb_strtolower($tld),
],
'reason' => trim((string) ($toolCall['reason'] ?? '')),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services\Tools;
use InvalidArgumentException;
class DomainInfoTool
{
public const ACTION = 'domain_inf';
public function __construct(private readonly OxxaClient $client) {}
public function execute(array $parameters, array $credentials): array
{
$parameters = $this->validateParameters($parameters);
return $this->client->request(self::ACTION, [
'apiuser' => (string) ($credentials['apiuser'] ?? ''),
'apipassword' => (string) ($credentials['apipassword'] ?? ''),
'sld' => $parameters['sld'],
'tld' => $parameters['tld'],
]);
}
public function validateParameters(array $parameters): array
{
$sld = mb_strtolower(trim((string) ($parameters['sld'] ?? '')));
$tld = mb_strtolower(trim((string) ($parameters['tld'] ?? '')));
if ($sld === '' || $tld === '') {
throw new InvalidArgumentException('domain_inf requires both sld and tld parameters.');
}
if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $sld) !== 1) {
throw new InvalidArgumentException('domain_inf parameter sld is invalid.');
}
if (preg_match('/^[a-z0-9-]{2,63}(?:\.[a-z0-9-]{2,63})*$/', $tld) !== 1) {
throw new InvalidArgumentException('domain_inf parameter tld is invalid.');
}
return [
'sld' => $sld,
'tld' => $tld,
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Services\Tools;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use RuntimeException;
use SimpleXMLElement;
class OxxaClient
{
public function request(string $command, array $data = []): array|SimpleXMLElement
{
$endpoint = rtrim((string) config('services.oxxa.endpoint'), '?&');
if ($endpoint === '') {
throw new InvalidArgumentException('OXXA_API_ENDPOINT is not configured.');
}
if (! isset($data['apiuser'], $data['apipassword'])) {
throw new InvalidArgumentException('Oxxa credentials are required.');
}
$payload = $data;
$payload['apipassword'] = 'MD5'.md5((string) $payload['apipassword']);
$response = Http::timeout((int) config('services.oxxa.timeout', 60))
->get($endpoint, ['command' => $command] + $payload)
->throw();
$xml = simplexml_load_string((string) $response->body(), SimpleXMLElement::class, LIBXML_NOCDATA);
if (! $xml instanceof SimpleXMLElement) {
throw new RuntimeException('Oxxa returned invalid XML.');
}
$responseData = $this->xmlToArray($xml);
$statusCode = trim((string) data_get($responseData, 'order.status_code', ''));
$statusDescription = trim((string) data_get($responseData, 'order.status_description', ''));
if ($statusCode === 'XMLERR 1') {
return [
'ok' => false,
'error' => 'Credentials are incorrect. Please change the credentials in the registrar module.',
'status_code' => $statusCode,
'status_description' => $statusDescription,
];
}
if (str_contains($statusCode, 'XMLOK')) {
return [
'ok' => true,
'status_code' => $statusCode,
'status_description' => $statusDescription,
'data' => $responseData,
];
}
return [
'ok' => false,
'error' => trim($statusCode.' '.$statusDescription),
'status_code' => $statusCode,
'status_description' => $statusDescription,
'data' => $responseData,
];
}
private function xmlToArray(SimpleXMLElement $xml): array
{
return json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services\Tools;
use App\Models\Article;
use App\Models\Ticket;
use App\Models\TicketToolCall;
use App\Services\TicketProcessingLoggerService;
use Throwable;
class TicketToolCallService
{
public function __construct(
private readonly DomainInfoTool $domainInfoTool,
private readonly TicketProcessingLoggerService $logger,
) {}
public function executeRequestedTool(Ticket $ticket, ?Article $article, ?array $toolCall): ?TicketToolCall
{
if ($toolCall === null || $article === null) {
return null;
}
$action = (string) ($toolCall['action'] ?? '');
$parameters = is_array($toolCall['parameters'] ?? null) ? $toolCall['parameters'] : [];
$allowedActions = $article->allowed_actions ?? [];
if (! in_array($action, $allowedActions, true)) {
return $this->recordSkipped($ticket, $article, $action, $parameters, 'Action is not allowed for the selected article.');
}
if ($action !== DomainInfoTool::ACTION) {
return $this->recordSkipped($ticket, $article, $action, $parameters, 'Unsupported tool action.');
}
try {
$parameters = $this->domainInfoTool->validateParameters($parameters);
} catch (Throwable $e) {
return $this->recordSkipped($ticket, $article, $action, $parameters, $e->getMessage());
}
$credentials = $ticket->api_credentials;
if (! is_array($credentials) || empty($credentials['apiuser']) || empty($credentials['apipassword'])) {
return $this->recordSkipped($ticket, $article, $action, $parameters, 'API credentials are missing for this ticket.');
}
$record = TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => $action,
'status' => 'running',
'parameters' => $parameters,
]);
$this->logger->log($ticket, 'tool_call', 'info', 'Toolcall gestart.', [
'tool_call_id' => $record->id,
'action' => $action,
'parameters' => $parameters,
]);
try {
$response = $this->domainInfoTool->execute($parameters, $credentials);
$status = ($response['ok'] ?? false) === true ? 'success' : 'failed';
$record->update([
'status' => $status,
'response' => $response,
'error' => $status === 'failed' ? (string) ($response['error'] ?? 'Tool returned an error.') : null,
'executed_at' => now(),
]);
$this->logger->log($ticket, 'tool_call', $status, 'Toolcall afgerond.', [
'tool_call_id' => $record->id,
'action' => $action,
'parameters' => $parameters,
'response' => $response,
]);
} catch (Throwable $e) {
$record->update([
'status' => 'failed',
'error' => $e->getMessage(),
'executed_at' => now(),
]);
$this->logger->log($ticket, 'tool_call', 'error', 'Toolcall gefaald.', [
'tool_call_id' => $record->id,
'action' => $action,
'parameters' => $parameters,
'error' => $e->getMessage(),
]);
}
return $record->refresh();
}
private function recordSkipped(Ticket $ticket, Article $article, string $action, array $parameters, string $reason): TicketToolCall
{
$record = TicketToolCall::query()->create([
'ticket_id' => $ticket->id,
'article_id' => $article->id,
'action' => $action !== '' ? $action : 'unknown',
'status' => 'skipped',
'parameters' => $parameters,
'error' => $reason,
'executed_at' => now(),
]);
$this->logger->log($ticket, 'tool_call', 'warning', 'Toolcall overgeslagen.', [
'tool_call_id' => $record->id,
'action' => $record->action,
'parameters' => $parameters,
'reason' => $reason,
]);
return $record;
}
}