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:
@@ -31,6 +31,7 @@ class VectorCast implements CastsAttributes
|
||||
}
|
||||
|
||||
$vector = array_map(static fn ($item) => (float) $item, $value);
|
||||
|
||||
return '['.implode(',', $vector).']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
app/Console/Commands/GenerateArticleEmbeddingsCommand.php
Normal file
76
app/Console/Commands/GenerateArticleEmbeddingsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
20
app/Http/Controllers/AdminTicketController.php
Normal file
20
app/Http/Controllers/AdminTicketController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ class ArticleController extends Controller
|
||||
'data' => $article,
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
172
app/Jobs/ProcessTicketJob.php
Normal file
172
app/Jobs/ProcessTicketJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
94
app/Livewire/Admin/QuickReplyManager.php
Normal file
94
app/Livewire/Admin/QuickReplyManager.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/Livewire/Admin/SettingsPage.php
Normal file
199
app/Livewire/Admin/SettingsPage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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', [
|
||||
|
||||
53
app/Livewire/Admin/TicketShow.php
Normal file
53
app/Livewire/Admin/TicketShow.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Models/ArticleChunk.php
Normal file
30
app/Models/ArticleChunk.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
24
app/Models/QuickReply.php
Normal 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
10
app/Models/Setting.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['key', 'value'];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
20
app/Models/TicketProcessingLog.php
Normal file
20
app/Models/TicketProcessingLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
36
app/Models/TicketToolCall.php
Normal file
36
app/Models/TicketToolCall.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class AdminDashboardService
|
||||
}
|
||||
|
||||
$correct = Feedback::query()->where('is_correct', true)->count();
|
||||
|
||||
return round(($correct / $total) * 100, 2);
|
||||
}
|
||||
}
|
||||
|
||||
56
app/Services/AdminQuickReplyService.php
Normal file
56
app/Services/AdminQuickReplyService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
283
app/Services/AppSettingsService.php
Normal file
283
app/Services/AppSettingsService.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Services/ArticleChunkerService.php
Normal file
39
app/Services/ArticleChunkerService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Services/ArticleEmbeddingMaintenanceService.php
Normal file
56
app/Services/ArticleEmbeddingMaintenanceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Services/ArticleIndexingService.php
Normal file
36
app/Services/ArticleIndexingService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
38
app/Services/ClassifierPromptBuilder.php
Normal file
38
app/Services/ClassifierPromptBuilder.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
67
app/Services/KnowledgeGapService.php
Normal file
67
app/Services/KnowledgeGapService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
10
app/Services/Llm/LlmClientInterface.php
Normal file
10
app/Services/Llm/LlmClientInterface.php
Normal 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;
|
||||
}
|
||||
142
app/Services/Llm/LmStudioClient.php
Normal file
142
app/Services/Llm/LmStudioClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
108
app/Services/Llm/OllamaClient.php
Normal file
108
app/Services/Llm/OllamaClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
36
app/Services/LlmJsonDecoder.php
Normal file
36
app/Services/LlmJsonDecoder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
app/Services/LlmModelCatalogService.php
Normal file
81
app/Services/LlmModelCatalogService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
25
app/Services/QuickReplyResolver.php
Normal file
25
app/Services/QuickReplyResolver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
108
app/Services/SupportReplyService.php
Normal file
108
app/Services/SupportReplyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
49
app/Services/TicketIngestionService.php
Normal file
49
app/Services/TicketIngestionService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
164
app/Services/TicketNormalizationService.php
Normal file
164
app/Services/TicketNormalizationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
app/Services/TicketProcessingLoggerService.php
Normal file
20
app/Services/TicketProcessingLoggerService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Services/TicketResultPayloadBuilder.php
Normal file
23
app/Services/TicketResultPayloadBuilder.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Services/ToolCallRequestValidator.php
Normal file
39
app/Services/ToolCallRequestValidator.php
Normal 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'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Services/Tools/DomainInfoTool.php
Normal file
47
app/Services/Tools/DomainInfoTool.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Services/Tools/OxxaClient.php
Normal file
70
app/Services/Tools/OxxaClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
117
app/Services/Tools/TicketToolCallService.php
Normal file
117
app/Services/Tools/TicketToolCallService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user