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:
@@ -13,17 +13,26 @@ DB_PORT=5432
|
||||
DB_DATABASE=ticket_assistant
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_SSLMODE=disable
|
||||
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
OXXA_API_ENDPOINT=
|
||||
OXXA_TIMEOUT=60
|
||||
CACHE_STORE=file
|
||||
|
||||
# External Ollama server in your network
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_BASE_URL=http://192.168.1.50:11434
|
||||
LLM_EMBEDDING_MODEL=nomic-embed-text
|
||||
LLM_CHAT_MODEL=llama3
|
||||
LLM_TIMEOUT=300
|
||||
|
||||
# legacy compatibility (optional)
|
||||
OLLAMA_BASE_URL=http://192.168.1.50:11434
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text
|
||||
OLLAMA_CHAT_MODEL=llama3
|
||||
OLLAMA_TIMEOUT=30
|
||||
|
||||
EMBEDDING_DIMENSION=768
|
||||
QUEUE_EMBEDDINGS=false
|
||||
|
||||
DB_SSLMODE=disable
|
||||
|
||||
|
||||
12
.env.example
12
.env.example
@@ -37,6 +37,9 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
OXXA_API_ENDPOINT=
|
||||
OXXA_TIMEOUT=60
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
@@ -63,3 +66,12 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_BASE_URL=http://localhost:11434
|
||||
LLM_EMBEDDING_MODEL=nomic-embed-text
|
||||
LLM_CHAT_MODEL=llama3
|
||||
LLM_TIMEOUT=300
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +18,7 @@ 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
'ticket_id' => $ingested['ticket']->id,
|
||||
'status' => 'queued',
|
||||
'message' => 'Ticket ontvangen en in verwerking gezet.',
|
||||
], 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);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +8,7 @@ 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',
|
||||
|
||||
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);
|
||||
|
||||
$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: 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: $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: (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'] ?? [];
|
||||
$embedding = $this->llmClient->embed($text);
|
||||
if (! is_array($embedding) || $embedding === []) {
|
||||
throw new OllamaUnavailableException('Ollama embedding response did not include a valid 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,8 +54,7 @@ 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(
|
||||
$result = Article::query()->updateOrCreate(
|
||||
['source' => 'internettoday_helpdesk', 'source_article_id' => $sourceArticleId],
|
||||
[
|
||||
'title' => $title,
|
||||
@@ -63,7 +64,6 @@ class HelpdeskImportService
|
||||
'subcategory_id' => $subcategoryId,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
if ($result->wasRecentlyCreated) {
|
||||
$imported++;
|
||||
@@ -97,6 +97,7 @@ class HelpdeskImportService
|
||||
}
|
||||
|
||||
$decoded = json_decode($matches[1], true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"fakerphp/faker": "^1.23"
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/pint": "^1.29"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -19,6 +21,11 @@
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
|
||||
254
composer.lock
generated
254
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "dec022180cb6dd4ae53f2078195e8931",
|
||||
"content-hash": "d577f9da472918b362d4f0001beff9e5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -6029,6 +6029,205 @@
|
||||
},
|
||||
"time": "2024-11-21T13:46:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "iamcal/sql-parser",
|
||||
"version": "v0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/iamcal/SQLParser.git",
|
||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
|
||||
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^1.0",
|
||||
"phpunit/phpunit": "^5|^6|^7|^8|^9"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"iamcal\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Cal Henderson",
|
||||
"email": "cal@iamcal.com"
|
||||
}
|
||||
],
|
||||
"description": "MySQL schema parser",
|
||||
"support": {
|
||||
"issues": "https://github.com/iamcal/SQLParser/issues",
|
||||
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
|
||||
},
|
||||
"time": "2026-01-28T22:20:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "larastan/larastan",
|
||||
"version": "v3.9.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/larastan/larastan.git",
|
||||
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
||||
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"iamcal/sql-parser": "^0.7.0",
|
||||
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
|
||||
"php": "^8.2",
|
||||
"phpstan/phpstan": "^2.1.44"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^13",
|
||||
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nikic/php-parser": "^5.4",
|
||||
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
|
||||
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
|
||||
},
|
||||
"suggest": {
|
||||
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
|
||||
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
|
||||
},
|
||||
"type": "phpstan-extension",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Larastan\\Larastan\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Can Vural",
|
||||
"email": "can9119@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
|
||||
"keywords": [
|
||||
"PHPStan",
|
||||
"code analyse",
|
||||
"code analysis",
|
||||
"larastan",
|
||||
"laravel",
|
||||
"package",
|
||||
"php",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/larastan/larastan/issues",
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.9.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/canvural",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-16T10:02:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pint",
|
||||
"version": "v1.29.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/pint.git",
|
||||
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
|
||||
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"ext-xml": "*",
|
||||
"php": "^8.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.95.1",
|
||||
"illuminate/view": "^12.56.0",
|
||||
"larastan/larastan": "^3.9.6",
|
||||
"laravel-zero/framework": "^12.1.0",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest": "^3.8.6",
|
||||
"shipfastlabs/agent-detector": "^1.1.3"
|
||||
},
|
||||
"bin": [
|
||||
"builds/pint"
|
||||
],
|
||||
"type": "project",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Seeders\\": "database/seeders/",
|
||||
"Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nuno Maduro",
|
||||
"email": "enunomaduro@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An opinionated code formatter for PHP.",
|
||||
"homepage": "https://laravel.com",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"format",
|
||||
"formatter",
|
||||
"lint",
|
||||
"linter",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/pint/issues",
|
||||
"source": "https://github.com/laravel/pint"
|
||||
},
|
||||
"time": "2026-04-20T15:26:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
"version": "1.13.4",
|
||||
@@ -6265,6 +6464,59 @@
|
||||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.54",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan-shim": "*"
|
||||
},
|
||||
"bin": [
|
||||
"phpstan",
|
||||
"phpstan.phar"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPStan - PHP Static Analysis Tool",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||
"source": "https://github.com/phpstan/phpstan-src"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/ondrejmirtes",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/phpstan",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-29T13:31:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "11.0.12",
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'ollama' => [
|
||||
'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
|
||||
'embed_model' => env('OLLAMA_EMBED_MODEL', 'nomic-embed-text'),
|
||||
'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
|
||||
'timeout' => (int) env('OLLAMA_TIMEOUT', 30),
|
||||
'llm' => [
|
||||
'provider' => env('LLM_PROVIDER', 'ollama'),
|
||||
'base_url' => env('LLM_BASE_URL', env('OLLAMA_BASE_URL', 'http://localhost:11434')),
|
||||
'embedding_model' => env('LLM_EMBEDDING_MODEL', env('OLLAMA_EMBED_MODEL', 'nomic-embed-text')),
|
||||
'chat_model' => env('LLM_CHAT_MODEL', env('OLLAMA_CHAT_MODEL', 'llama3')),
|
||||
'timeout' => (int) env('LLM_TIMEOUT', env('OLLAMA_TIMEOUT', 30)),
|
||||
'ranking_enabled' => filter_var(env('LLM_RANKING_ENABLED', true), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
'embedding' => [
|
||||
'dimension' => (int) env('EMBEDDING_DIMENSION', 768),
|
||||
'queue_embeddings' => filter_var(env('QUEUE_EMBEDDINGS', false), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
'oxxa' => [
|
||||
'endpoint' => env('OXXA_API_ENDPOINT', ''),
|
||||
'timeout' => (int) env('OXXA_TIMEOUT', 60),
|
||||
],
|
||||
];
|
||||
@@ -3,7 +3,8 @@
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
|
||||
|
||||
@@ -5,7 +5,8 @@ use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('articles', function (Blueprint $table) {
|
||||
|
||||
@@ -5,7 +5,8 @@ use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tickets', function (Blueprint $table) {
|
||||
|
||||
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('embedding_cache', function (Blueprint $table) {
|
||||
|
||||
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ai_decisions', function (Blueprint $table) {
|
||||
|
||||
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('feedback', function (Blueprint $table) {
|
||||
|
||||
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->string('status')->default('queued')->after('embedding');
|
||||
$table->foreignId('best_article_id')->nullable()->after('status')->constrained('articles')->nullOnDelete();
|
||||
$table->float('confidence')->nullable()->after('best_article_id');
|
||||
$table->text('explanation')->nullable()->after('confidence');
|
||||
$table->json('result_payload')->nullable()->after('explanation');
|
||||
$table->text('error_message')->nullable()->after('result_payload');
|
||||
$table->timestamp('processed_at')->nullable()->after('error_message');
|
||||
$table->index('status');
|
||||
});
|
||||
|
||||
Schema::create('ticket_processing_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('step', 100);
|
||||
$table->string('status', 30)->default('info');
|
||||
$table->text('message')->nullable();
|
||||
$table->json('context')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ticket_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_processing_logs');
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('best_article_id');
|
||||
$table->dropColumn(['status', 'confidence', 'explanation', 'result_payload', 'error_message', 'processed_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->text('normalized_message')->nullable()->after('message');
|
||||
$table->json('redaction_report')->nullable()->after('normalized_message');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn(['normalized_message', 'redaction_report']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->text('support_reply')->nullable()->after('explanation');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('support_reply');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->longText('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->string('status')->default('published')->after('content');
|
||||
$table->boolean('is_ai_draft')->default(false)->after('status');
|
||||
$table->foreignId('source_ticket_id')->nullable()->after('subcategory_id')->constrained('tickets')->nullOnDelete();
|
||||
});
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->boolean('needs_article_draft')->default(false)->after('support_reply');
|
||||
$table->foreignId('draft_article_id')->nullable()->after('needs_article_draft')->constrained('articles')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('draft_article_id');
|
||||
$table->dropColumn('needs_article_draft');
|
||||
});
|
||||
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('source_ticket_id');
|
||||
$table->dropColumn(['status', 'is_ai_draft']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('article_chunks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('chunk_index');
|
||||
$table->text('content');
|
||||
$table->timestamps();
|
||||
$table->unique(['article_id', 'chunk_index']);
|
||||
});
|
||||
|
||||
$dimension = (int) config('services.embedding.dimension', 768);
|
||||
DB::statement("ALTER TABLE article_chunks ADD COLUMN embedding vector({$dimension})");
|
||||
DB::statement('CREATE INDEX article_chunks_embedding_cosine_idx ON article_chunks USING ivfflat (embedding vector_cosine_ops)');
|
||||
DB::statement('CREATE INDEX article_chunks_article_id_idx ON article_chunks(article_id)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('article_chunks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('embedding_cache', function (Blueprint $table) {
|
||||
$table->string('provider_instance_id')->nullable()->after('id');
|
||||
$table->string('embedding_model')->nullable()->after('provider_instance_id');
|
||||
$table->dropUnique(['text_hash']);
|
||||
$table->unique(['provider_instance_id', 'embedding_model', 'text_hash'], 'embedding_cache_model_text_unique');
|
||||
});
|
||||
|
||||
Schema::table('article_chunks', function (Blueprint $table) {
|
||||
$table->string('embedding_provider_instance_id')->nullable()->after('embedding');
|
||||
$table->string('embedding_model')->nullable()->after('embedding_provider_instance_id');
|
||||
$table->timestamp('embedded_at')->nullable()->after('embedding_model');
|
||||
$table->index(['embedding_provider_instance_id', 'embedding_model'], 'article_chunks_embedding_model_idx');
|
||||
});
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->string('embedding_provider_instance_id')->nullable()->after('embedding');
|
||||
$table->string('embedding_model')->nullable()->after('embedding_provider_instance_id');
|
||||
$table->timestamp('embedded_at')->nullable()->after('embedding_model');
|
||||
});
|
||||
|
||||
DB::table('embedding_cache')->truncate();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn(['embedding_provider_instance_id', 'embedding_model', 'embedded_at']);
|
||||
});
|
||||
|
||||
Schema::table('article_chunks', function (Blueprint $table) {
|
||||
$table->dropIndex('article_chunks_embedding_model_idx');
|
||||
$table->dropColumn(['embedding_provider_instance_id', 'embedding_model', 'embedded_at']);
|
||||
});
|
||||
|
||||
Schema::table('embedding_cache', function (Blueprint $table) {
|
||||
$table->dropUnique('embedding_cache_model_text_unique');
|
||||
$table->unique('text_hash');
|
||||
$table->dropColumn(['provider_instance_id', 'embedding_model']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->text('note')->nullable()->after('content');
|
||||
$table->json('allowed_actions')->nullable()->after('note');
|
||||
});
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->text('api_credentials')->nullable()->after('result_payload');
|
||||
});
|
||||
|
||||
Schema::create('ticket_tool_calls', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('article_id')->nullable()->constrained('articles')->nullOnDelete();
|
||||
$table->string('action', 100);
|
||||
$table->string('status', 30)->default('pending');
|
||||
$table->json('parameters')->nullable();
|
||||
$table->json('response')->nullable();
|
||||
$table->text('error')->nullable();
|
||||
$table->timestamp('executed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ticket_id', 'created_at']);
|
||||
$table->index(['action', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_tool_calls');
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('api_credentials');
|
||||
});
|
||||
|
||||
Schema::table('articles', function (Blueprint $table) {
|
||||
$table->dropColumn(['note', 'allowed_actions']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('quick_replies', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->text('content');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('is_active');
|
||||
});
|
||||
|
||||
Schema::create('article_quick_reply', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('quick_reply_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['article_id', 'quick_reply_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('article_quick_reply');
|
||||
Schema::dropIfExists('quick_replies');
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,8 @@ server {
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_read_timeout 300;
|
||||
fastcgi_send_timeout 300;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
|
||||
17
phpstan.neon
Normal file
17
phpstan.neon
Normal file
@@ -0,0 +1,17 @@
|
||||
includes:
|
||||
- vendor/larastan/larastan/extension.neon
|
||||
- vendor/nesbot/carbon/extension.neon
|
||||
|
||||
parameters:
|
||||
|
||||
paths:
|
||||
- app/
|
||||
|
||||
# Level 10 is the highest level
|
||||
level: 5
|
||||
|
||||
# ignoreErrors:
|
||||
# - '#PHPDoc tag @var#'
|
||||
#
|
||||
# excludePaths:
|
||||
# - ./*/*/FileToBeExcluded.php
|
||||
196
resources/views/admin/process.blade.php
Normal file
196
resources/views/admin/process.blade.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<x-layouts.admin title="Proces">
|
||||
<div class="space-y-6">
|
||||
<section class="bg-white rounded-xl p-5 shadow">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Hoe werkt de Ticket Assistant?</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-slate-600">
|
||||
Zie het systeem als een supportmedewerker met een hele grote map handleidingen. Eerst maakt hij
|
||||
de vraag netjes leesbaar, daarna zoekt hij de beste stukjes uitleg, en daarna schrijft hij een
|
||||
kort advies dat een supportmedewerker kan controleren.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded bg-slate-100 px-3 py-2 text-xs text-slate-600">
|
||||
Draait lokaal met je eigen kennisbank en je eigen AI-model
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Wat gebeurt er met een ticket?</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">1. De vraag komt binnen</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Een klantvraag komt binnen via de API of via het handmatige testveld in het admin scherm.
|
||||
Het ticket komt eerst in de wachtrij, zodat de zware stappen op de achtergrond kunnen lopen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">2. De vraag wordt netjes gemaakt</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
De AI haalt ruis, typefouten en privegegevens weg. Denk aan: naam, adres, IBAN of andere
|
||||
gegevens die niet nodig zijn om het probleem op te lossen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">3. De betekenis wordt omgezet naar een zoekcode</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
De app maakt van de nette vraag een soort getallen-code. Die code beschrijft niet de exacte
|
||||
woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen vinden
|
||||
waarin "e-mail storing" staat.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">4. De kennisbank wordt doorzocht</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Artikelen zijn opgeknipt in kleine tekstkaartjes. De app zoekt de kaartjes die het meest
|
||||
lijken op de klantvraag en groepeert die daarna terug naar artikelen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">5. De AI kiest het beste artikel</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
De AI krijgt de beste kandidaten te zien en kiest welk artikel het beste past. Daarbij geeft
|
||||
hij ook aan hoe zeker hij is en waarom hij dat artikel kiest.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">6. Eventueel wordt extra informatie opgehaald</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Sommige artikelen mogen extra hulpmiddelen gebruiken. Voor nu is dat alleen domeininformatie
|
||||
ophalen. Dat gebeurt alleen als het artikel dit toestaat en de vraag genoeg gegevens bevat.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-l-4 border-slate-900 pl-4">
|
||||
<div class="font-medium">7. Er komt een advies of een melding</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Als het artikel een snelantwoord heeft, gebruikt de app dat direct. Anders maakt de AI een
|
||||
korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord maar
|
||||
meldt hij dat de kennisbank iets mist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Hoe werkt de kennisbank?</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<div class="font-medium">Artikelen zijn de handleidingen</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Elk artikel is een uitleg die support kan gebruiken. Artikelen kunnen uit de externe
|
||||
helpdesk komen of handmatig worden aangemaakt.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">Artikelen worden in kleine kaartjes geknipt</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Een lang artikel kan over meerdere dingen gaan. Daarom knippen we het in kleine stukken.
|
||||
Zo kan de app precies het stukje vinden dat bij de vraag hoort.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">Elk kaartje krijgt een betekenis-code</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Die code helpt zoeken op betekenis. Het gaat dus niet alleen om dezelfde woorden, maar om
|
||||
dezelfde bedoeling.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">Bij een nieuw AI-model opnieuw indexeren</div>
|
||||
<p class="text-sm text-slate-600">
|
||||
Als je het model wijzigt dat deze betekenis-codes maakt, moeten de kaartjes opnieuw worden
|
||||
berekend. Dat kan via Settings bij Embeddings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Wat zie je op een ticket?</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Je ziet de originele vraag, de opgeschoonde vraag, de gekozen artikelen, eventuele hulpmiddelen die
|
||||
gebruikt zijn, fouten, en alle stappen in volgorde. De nieuwste stap staat bovenaan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Wanneer komt er geen antwoord?</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Als de app geen passend artikel vindt, maakt hij bewust geen reactie naar de klant. Dan zie je een
|
||||
melding dat de kennisbank tekortschiet, plus een suggestie voor wat er mist.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Waarvoor zijn artikelnotities?</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Een artikelnotitie is een interne tip voor de AI. Bijvoorbeeld: "Gebruik dit artikel alleen voor
|
||||
domeinen" of "Vraag eerst om klantnummer als dit ontbreekt". De notitie is geen klanttekst.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Snelantwoorden</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Sommige artikelen hebben uiteindelijk hetzelfde antwoord nodig. Dan kun je een snelantwoord maken en
|
||||
aan meerdere artikelen koppelen. Als zo'n artikel wordt gekozen, gebruikt de app het snelantwoord en
|
||||
hoeft de AI geen nieuwe klantreactie te schrijven.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Hulpmiddelen en allowed actions</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Sommige antwoorden worden beter als de app iets kan nakijken. Daarom kan een artikel aangeven welke
|
||||
hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie controleert
|
||||
daarna zelf of het echt mag.
|
||||
</p>
|
||||
<div class="mt-4 rounded border p-3">
|
||||
<div class="font-medium">Nu beschikbaar: domain_inf</div>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf` toestaat,
|
||||
als de vraag een domeinnaam bevat, en als er API-gegevens op het ticket zijn opgeslagen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Woordenlijst</h3>
|
||||
<div class="mt-4 space-y-3 text-sm text-slate-600">
|
||||
<div><span class="font-medium text-slate-900">Embedding:</span> een betekenis-code van tekst.</div>
|
||||
<div><span class="font-medium text-slate-900">Chunk:</span> een klein stukje van een artikel.</div>
|
||||
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.</div>
|
||||
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft waarschijnlijk nog geen goed artikel voor deze vraag.</div>
|
||||
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de app extra informatie kan ophalen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-xl p-5 shadow">
|
||||
<h3 class="font-semibold">Voorbeeld in gewone taal</h3>
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-4">
|
||||
<div class="rounded border p-3">
|
||||
<div class="font-medium">Klant vraagt</div>
|
||||
<p class="mt-1 text-sm text-slate-600">"Mijn mail doet het niet op mijn domein."</p>
|
||||
</div>
|
||||
<div class="rounded border p-3">
|
||||
<div class="font-medium">App zoekt</div>
|
||||
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.</p>
|
||||
</div>
|
||||
<div class="rounded border p-3">
|
||||
<div class="font-medium">AI kiest</div>
|
||||
<p class="mt-1 text-sm text-slate-600">De AI kiest het artikel dat het meest bruikbaar lijkt.</p>
|
||||
</div>
|
||||
<div class="rounded border p-3">
|
||||
<div class="font-medium">Support controleert</div>
|
||||
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
3
resources/views/admin/quick-replies.blade.php
Normal file
3
resources/views/admin/quick-replies.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Snelantwoorden">
|
||||
<livewire:admin.quick-reply-manager />
|
||||
</x-layouts.admin>
|
||||
3
resources/views/admin/settings.blade.php
Normal file
3
resources/views/admin/settings.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Settings">
|
||||
<livewire:admin.settings-page />
|
||||
</x-layouts.admin>
|
||||
3
resources/views/admin/ticket-show.blade.php
Normal file
3
resources/views/admin/ticket-show.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-layouts.admin title="Ticket detail">
|
||||
<livewire:admin.ticket-show :ticket-id="$ticket->id" />
|
||||
</x-layouts.admin>
|
||||
27
resources/views/components/admin/timeline-card.blade.php
Normal file
27
resources/views/components/admin/timeline-card.blade.php
Normal file
@@ -0,0 +1,27 @@
|
||||
@props([
|
||||
'number' => null,
|
||||
'title',
|
||||
'description' => null,
|
||||
'badge' => null,
|
||||
])
|
||||
|
||||
<div class="grid gap-3 border rounded p-3 md:grid-cols-[2.5rem_1fr]">
|
||||
<div class="h-8 w-8 rounded-full bg-slate-900 text-white text-sm font-semibold flex items-center justify-center">
|
||||
{{ $number }}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">{{ $title }}</div>
|
||||
@if($description)
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if($badge)
|
||||
<span class="shrink-0 rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{{ $badge }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,7 +15,10 @@
|
||||
<nav class="flex gap-3 text-sm">
|
||||
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
|
||||
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
|
||||
<a href="{{ route('admin.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
|
||||
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
|
||||
<a href="{{ route('admin.process') }}" class="hover:underline">Proces</a>
|
||||
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<textarea wire:model="content" class="w-full border rounded p-2 min-h-40" placeholder="Content"></textarea>
|
||||
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24" placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
|
||||
@error('note') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input wire:model="allowedActions" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
|
||||
<span>
|
||||
<span class="font-medium">domain_inf toestaan</span>
|
||||
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld aanwezig zijn.</span>
|
||||
</span>
|
||||
</label>
|
||||
@error('allowedActions.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -18,8 +28,84 @@
|
||||
<div class="space-y-3">
|
||||
@foreach($articles as $article)
|
||||
<div class="border rounded p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
|
||||
@if(($article->status ?? 'published') === 'draft')
|
||||
<span class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept (AI)</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if(($article->status ?? 'published') === 'draft')
|
||||
<button
|
||||
type="button"
|
||||
wire:click="approveDraft({{ $article->id }})"
|
||||
class="text-sm text-green-700 hover:underline"
|
||||
>
|
||||
Valideren & publiceren
|
||||
</button>
|
||||
@endif
|
||||
<button
|
||||
type="button"
|
||||
wire:click="deleteArticle({{ $article->id }})"
|
||||
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
|
||||
class="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if($article->note)
|
||||
<div class="mt-2 text-xs rounded bg-slate-50 p-2 text-slate-600">
|
||||
<span class="font-semibold">LLM note:</span> {{ \Illuminate\Support\Str::limit($article->note, 180) }}
|
||||
</div>
|
||||
@endif
|
||||
@if(($article->allowed_actions ?? []) !== [])
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@foreach($article->allowed_actions as $action)
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}</div>
|
||||
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
|
||||
<label class="block text-xs font-semibold text-slate-500">LLM note</label>
|
||||
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm" placeholder="Interne aanwijzingen voor dit artikel"></textarea>
|
||||
@error("articleNotes.{$article->id}") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
|
||||
<span>domain_inf toestaan</span>
|
||||
</label>
|
||||
@error("articleAllowedActions.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde snelantwoorden</label>
|
||||
@if($quickReplyOptions->isEmpty())
|
||||
<p class="text-xs text-slate-500">Nog geen actieve snelantwoorden beschikbaar.</p>
|
||||
@else
|
||||
<div class="grid gap-1 sm:grid-cols-2">
|
||||
@foreach($quickReplyOptions as $quickReply)
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
wire:model="articleQuickReplies.{{ $article->id }}"
|
||||
type="checkbox"
|
||||
value="{{ $quickReply->id }}"
|
||||
class="rounded border-slate-300"
|
||||
>
|
||||
<span>{{ $quickReply->title }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@error("articleQuickReplies.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="saveMetadata({{ $article->id }})"
|
||||
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
|
||||
>
|
||||
Metadata opslaan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
77
resources/views/livewire/admin/quick-reply-manager.blade.php
Normal file
77
resources/views/livewire/admin/quick-reply-manager.blade.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<h2 class="font-semibold mb-1">Nieuw snelantwoord</h2>
|
||||
<p class="text-sm text-slate-600 mb-3">
|
||||
Gebruik dit voor vaste antwoorden die bij meerdere kennisbankartikelen passen. Als een gekozen artikel een
|
||||
actief snelantwoord heeft, wordt er geen AI-antwoord meer gegenereerd.
|
||||
</p>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mb-3 text-green-700 bg-green-100 p-2 rounded">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="save" class="space-y-3">
|
||||
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
|
||||
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
|
||||
<textarea wire:model="content" class="w-full border rounded p-2 min-h-36" placeholder="Snelantwoord tekst"></textarea>
|
||||
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input wire:model="isActive" type="checkbox" class="rounded border-slate-300">
|
||||
<span>Actief</span>
|
||||
</label>
|
||||
|
||||
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<h2 class="font-semibold mb-3">Snelantwoorden</h2>
|
||||
<div class="space-y-3">
|
||||
@foreach($quickReplies as $quickReply)
|
||||
<div class="border rounded p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">#{{ $quickReply->id }} {{ $quickReply->title }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
Gekoppeld aan {{ $quickReply->articles_count }} artikel(en)
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="deleteQuickReply({{ $quickReply->id }})"
|
||||
wire:confirm="Weet je zeker dat je dit snelantwoord wilt verwijderen?"
|
||||
class="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
|
||||
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text" class="w-full border rounded p-2 text-sm">
|
||||
@error("editRows.{$quickReply->id}.title") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
|
||||
<textarea wire:model="editRows.{{ $quickReply->id }}.content" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
|
||||
@error("editRows.{$quickReply->id}.content") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox" class="rounded border-slate-300">
|
||||
<span>Actief</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
wire:click="updateQuickReply({{ $quickReply->id }})"
|
||||
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
|
||||
>
|
||||
Wijzigingen opslaan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $quickReplies->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
300
resources/views/livewire/admin/settings-page.blade.php
Normal file
300
resources/views/livewire/admin/settings-page.blade.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">AI Settings</h2>
|
||||
<p class="text-sm text-slate-600">Beheer prompts, providers en modellen per stap in de pipeline.</p>
|
||||
</div>
|
||||
<button form="ai-settings-form" class="bg-slate-900 text-white px-4 py-2 rounded text-sm" type="submit">
|
||||
Opslaan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (session('saved'))
|
||||
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('saved') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-sm">
|
||||
@foreach(['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="setTab('{{ $tab }}')"
|
||||
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}"
|
||||
>
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="ai-settings-form" wire:submit="save" class="space-y-4">
|
||||
@if($activeTab === 'process')
|
||||
<section class="bg-white rounded-xl p-4 shadow space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold">Proces & prompts</h3>
|
||||
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt, kun je die hier aanpassen.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Aanspreekvorm fallback</label>
|
||||
<select wire:model="tone_addressing" class="border rounded px-2 py-1">
|
||||
<option value="je">je/jij</option>
|
||||
<option value="u">u/uw</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach($processSteps as $step)
|
||||
<x-admin.timeline-card
|
||||
:number="$step['number']"
|
||||
:title="$step['title']"
|
||||
:description="$step['description']"
|
||||
:badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'"
|
||||
>
|
||||
@if(isset($step['prompt_key']))
|
||||
<label class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
|
||||
<textarea
|
||||
wire:model="promptValues.{{ $step['id'] }}"
|
||||
class="w-full border rounded p-2 min-h-28 text-sm"
|
||||
></textarea>
|
||||
@error('promptValues.'.$step['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
@endif
|
||||
</x-admin.timeline-card>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if($activeTab === 'providers')
|
||||
<section class="bg-white rounded-xl p-4 shadow space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">LLM provider instances</h3>
|
||||
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke instance actief is.</p>
|
||||
</div>
|
||||
<button type="button" wire:click="addProviderInstance" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
|
||||
Instance toevoegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Actieve instance</label>
|
||||
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels" class="w-full border rounded p-2">
|
||||
@foreach($providerInstances as $instance)
|
||||
<option value="{{ $instance['id'] }}">{{ $instance['name'] }} ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Timeout seconden</label>
|
||||
<input wire:model="llm_timeout" type="number" min="5" max="600" class="w-full border rounded p-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach($providerInstances as $index => $instance)
|
||||
@php($type = $instance['type'] ?? 'lmstudio')
|
||||
@php($definition = $providerDefinitions[$type] ?? ['label' => $type, 'description' => ''])
|
||||
<div class="border rounded p-4 space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-medium">{{ $instance['name'] ?? 'Provider' }}</div>
|
||||
<p class="text-sm text-slate-600">{{ $definition['description'] }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($activeProviderInstanceId === ($instance['id'] ?? null))
|
||||
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
|
||||
@else
|
||||
<button type="button" wire:click="setActiveProviderInstance('{{ $instance['id'] }}')" class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
|
||||
Actief maken
|
||||
</button>
|
||||
@endif
|
||||
<button
|
||||
type="button"
|
||||
wire:click="removeProviderInstance('{{ $instance['id'] }}')"
|
||||
class="text-xs text-red-600 hover:underline"
|
||||
>
|
||||
Verwijderen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Naam</label>
|
||||
<input wire:model="providerInstances.{{ $index }}.name" type="text" class="w-full border rounded p-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Type</label>
|
||||
<select wire:model="providerInstances.{{ $index }}.type" class="w-full border rounded p-2">
|
||||
@foreach($providerDefinitions as $provider => $providerDefinition)
|
||||
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Base URL</label>
|
||||
<input wire:model="providerInstances.{{ $index }}.base_url" type="url" class="w-full border rounded p-2">
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Standaard chat model</label>
|
||||
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text" class="w-full border rounded p-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Standaard embedding model</label>
|
||||
<input wire:model="providerInstances.{{ $index }}.embedding_model" type="text" class="w-full border rounded p-2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if($activeTab === 'models')
|
||||
<section class="bg-white rounded-xl p-4 shadow space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">Modellen per stap</h3>
|
||||
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst wordt 5 minuten gecachet.</p>
|
||||
</div>
|
||||
<button type="button" wire:click="refreshModels" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
|
||||
Modellen verversen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($modelLoadError)
|
||||
<div class="rounded bg-amber-100 p-2 text-sm text-amber-800">
|
||||
Modellen konden niet live worden opgehaald: {{ $modelLoadError }}
|
||||
</div>
|
||||
@elseif($availableModels === [])
|
||||
<div class="rounded bg-slate-100 p-2 text-sm text-slate-700">
|
||||
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam invullen.
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded bg-green-100 p-2 text-sm text-green-800">
|
||||
{{ count($availableModels) }} modellen gevonden op de actieve instance.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded border p-3 text-sm text-slate-600">
|
||||
Actieve instance:
|
||||
@foreach($providerInstances as $instance)
|
||||
@if(($instance['id'] ?? null) === $activeProviderInstanceId)
|
||||
<strong>{{ $instance['name'] }}</strong> ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@foreach($modelTasks as $task)
|
||||
<x-admin.timeline-card
|
||||
:number="$task['number']"
|
||||
:title="$task['title']"
|
||||
:description="$task['description']"
|
||||
badge="Model"
|
||||
>
|
||||
@if($availableModels !== [])
|
||||
<select wire:model="modelValues.{{ $task['id'] }}" class="w-full border rounded p-2 text-sm">
|
||||
<option value="">Kies model</option>
|
||||
@foreach($availableModels as $model)
|
||||
<option value="{{ $model }}">{{ $model }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input
|
||||
wire:model="modelValues.{{ $task['id'] }}"
|
||||
type="text"
|
||||
class="w-full border rounded p-2 text-sm"
|
||||
placeholder="Modelnaam"
|
||||
>
|
||||
@endif
|
||||
@error('modelValues.'.$task['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
|
||||
</x-admin.timeline-card>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if($activeTab === 'embeddings')
|
||||
<section class="bg-white rounded-xl p-4 shadow space-y-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">Chunk embeddings</h3>
|
||||
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
|
||||
</div>
|
||||
<button type="button" wire:click="refreshEmbeddingStats" class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
Status verversen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded border p-3">
|
||||
<div class="text-xs text-slate-500">Artikelen</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['articles'] ?? 0 }}</div>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }} zonder chunks</p>
|
||||
</div>
|
||||
<div class="rounded border p-3">
|
||||
<div class="text-xs text-slate-500">Chunks</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks'] ?? 0 }}</div>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }} zonder embedding</p>
|
||||
</div>
|
||||
<div class="rounded border p-3">
|
||||
<div class="text-xs text-slate-500">Chunks met embedding</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}</div>
|
||||
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }} artikelen geindexeerd</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<div class="font-medium">Actieve embedding context</div>
|
||||
<div class="mt-2 grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-slate-500">Provider instance:</span>
|
||||
<span class="font-mono">{{ $embeddingStats['active_provider_instance_id'] ?? '-' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-500">Embedding model:</span>
|
||||
<span class="font-mono">{{ $embeddingStats['active_embedding_model'] ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-slate-600">
|
||||
{{ $embeddingStats['current_embedding_chunks'] ?? 0 }} chunks passen bij het actieve embeddingmodel.
|
||||
{{ $embeddingStats['stale_or_other_model_chunks'] ?? 0 }} chunks zijn leeg, oud of voor een ander model.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="border rounded p-4 space-y-3">
|
||||
<div>
|
||||
<h4 class="font-medium">Alleen ontbrekende chunks genereren</h4>
|
||||
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de queue.</p>
|
||||
</div>
|
||||
<button type="button" wire:click="reindexMissingEmbeddings" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
|
||||
Ontbrekende chunks genereren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border rounded p-4 space-y-3">
|
||||
<div>
|
||||
<h4 class="font-medium">Alles opnieuw genereren</h4>
|
||||
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande chunks worden per artikel vervangen tijdens verwerking.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="reindexAllEmbeddings"
|
||||
wire:confirm="Weet je zeker dat je alle artikelchunks opnieuw wilt genereren?"
|
||||
class="rounded bg-red-700 px-3 py-2 text-sm text-white"
|
||||
>
|
||||
Alle chunks opnieuw genereren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,6 +1,39 @@
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<h2 class="font-semibold mb-3">Ticket simulatie (handmatig inschieten)</h2>
|
||||
|
||||
@if($submitError)
|
||||
<div class="mb-3 rounded bg-red-100 text-red-700 px-3 py-2 text-sm">{{ $submitError }}</div>
|
||||
@endif
|
||||
|
||||
@if($lastResult)
|
||||
<div class="mb-3 rounded bg-green-100 text-green-800 px-3 py-2 text-sm">
|
||||
Ticket #{{ $lastResult['ticket_id'] }} aangemaakt met status '{{ $lastResult['status'] }}'.
|
||||
<a class="underline" href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="submitTicket" class="space-y-3">
|
||||
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28" placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
|
||||
@error('newTicketMessage') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<input wire:model="apiUser" type="text" class="w-full border rounded p-2" placeholder="API user (optioneel)">
|
||||
@error('apiUser') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2" placeholder="API password/key (optioneel)">
|
||||
@error('apiPassword') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt voor toegestane toolcalls.</p>
|
||||
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Ticket inschieten</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="font-semibold">Tickets + AI Decisions</h2>
|
||||
<h2 class="font-semibold">Tickets + status</h2>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<label for="perPage">Per pagina</label>
|
||||
<select id="perPage" wire:model.live="perPage" class="border rounded px-2 py-1">
|
||||
@@ -14,17 +47,19 @@
|
||||
<div class="space-y-3">
|
||||
@foreach($tickets as $ticket)
|
||||
<div class="border rounded p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
|
||||
<span class="text-xs px-2 py-1 rounded bg-slate-100">{{ $ticket->status }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
|
||||
@php($decision = $ticket->decisions->first())
|
||||
@if($decision)
|
||||
<div class="text-sm">Article: #{{ $decision->article_id ?? 'N/A' }} | Confidence: {{ number_format($decision->confidence, 2) }}</div>
|
||||
<div class="text-xs text-slate-500">{{ $decision->explanation }}</div>
|
||||
@else
|
||||
<div class="text-sm text-slate-500">Nog geen AI beslissing.</div>
|
||||
@if($ticket->bestArticle)
|
||||
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence: {{ number_format((float) $ticket->confidence, 2) }}</div>
|
||||
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
|
||||
@endif
|
||||
<a class="text-sm underline" href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-4">{{ $tickets->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
147
resources/views/livewire/admin/ticket-show.blade.php
Normal file
147
resources/views/livewire/admin/ticket-show.blade.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<div wire:poll.5s class="space-y-6">
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Ticket #{{ $ticket->id }}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="reprocess"
|
||||
wire:confirm="Weet je zeker dat je ticket #{{ $ticket->id }} opnieuw wilt verwerken?"
|
||||
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
|
||||
>
|
||||
Herverwerk ticket
|
||||
</button>
|
||||
<span class="text-sm px-2 py-1 rounded bg-slate-100">Status: {{ $ticket->status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
@if($ticket->needs_article_draft)
|
||||
<div class="mt-3 rounded bg-amber-50 border border-amber-300 p-3">
|
||||
<div class="text-sm font-semibold text-amber-900 mb-1">Kennisbank-gat gedetecteerd</div>
|
||||
<p class="text-sm text-amber-900">
|
||||
Er is geen geschikt artikel in de kennisbank gevonden voor deze vraag.
|
||||
</p>
|
||||
@php($suggestion = $ticket->result_payload['draft_article_suggestion'] ?? null)
|
||||
@if(is_array($suggestion))
|
||||
<div class="mt-3">
|
||||
<p class="text-sm font-semibold text-amber-900">Voorgestelde titel</p>
|
||||
<p class="text-sm text-amber-900">{{ $suggestion['title'] ?? '-' }}</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm font-semibold text-amber-900">Voorgestelde inhoud</p>
|
||||
<pre class="text-sm whitespace-pre-wrap text-amber-900">{{ $suggestion['content'] ?? '-' }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($ticket->support_reply && !$ticket->needs_article_draft)
|
||||
<div class="mt-3 rounded bg-blue-50 border border-blue-200 p-3">
|
||||
<div class="text-sm font-semibold text-blue-900 mb-1">Concept reactie voor klant</div>
|
||||
@if(is_array($ticket->result_payload['quick_reply'] ?? null))
|
||||
<div class="mb-2 inline-flex rounded bg-blue-100 px-2 py-1 text-xs text-blue-900">
|
||||
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }} {{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
|
||||
</div>
|
||||
@endif
|
||||
<pre class="text-sm whitespace-pre-wrap text-blue-900">{{ $ticket->support_reply }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mt-3 text-sm text-slate-800"><strong>Origineel:</strong> {{ $ticket->message }}</p>
|
||||
@if($ticket->normalized_message)
|
||||
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}</p>
|
||||
@endif
|
||||
@if($ticket->redaction_report)
|
||||
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
@endif
|
||||
@if(is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
|
||||
<div class="mt-3 text-sm rounded bg-slate-100 text-slate-700 p-2">
|
||||
API credentials aanwezig voor deze ticket-run. Waarden worden niet getoond.
|
||||
</div>
|
||||
@endif
|
||||
@if($ticket->error_message)
|
||||
<div class="mt-3 text-sm rounded bg-red-100 text-red-700 p-2">{{ $ticket->error_message }}</div>
|
||||
@endif
|
||||
@if($ticket->bestArticle)
|
||||
<div class="mt-3 text-sm rounded bg-green-100 text-green-800 p-2">
|
||||
Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }}
|
||||
@if($ticket->confidence !== null)
|
||||
(confidence {{ number_format($ticket->confidence, 2) }})
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<h3 class="font-semibold mb-3">Toolcalls</h3>
|
||||
@if($ticket->toolCalls->isEmpty())
|
||||
<p class="text-sm text-slate-600">Geen toolcalls uitgevoerd of voorgesteld.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach($ticket->toolCalls as $toolCall)
|
||||
<div class="border rounded p-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">
|
||||
{{ $toolCall->action }} ({{ $toolCall->status }})
|
||||
@if($toolCall->article)
|
||||
<span class="text-slate-500">via artikel #{{ $toolCall->article->id }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
|
||||
</div>
|
||||
@if($toolCall->parameters)
|
||||
<div class="mt-2">
|
||||
<div class="text-xs font-semibold text-slate-500">Parameters</div>
|
||||
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
@if($toolCall->response)
|
||||
<div class="mt-2">
|
||||
<div class="text-xs font-semibold text-slate-500">Response</div>
|
||||
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
@if($toolCall->error)
|
||||
<div class="mt-2 rounded bg-amber-50 text-amber-900 p-2">{{ $toolCall->error }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-4 shadow">
|
||||
<h3 class="font-semibold mb-3">Verwerkingsstappen</h3>
|
||||
@php($orderedLogs = $ticket->logs->sortBy([['created_at', 'desc'], ['id', 'desc']]))
|
||||
@php($latestLog = $orderedLogs->first())
|
||||
@if($ticket->status === 'processing' && $latestLog)
|
||||
<div class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
|
||||
<span>Huidige stap: <strong>{{ $latestLog->step }}</strong></span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-blue-600 animate-pulse"></span>
|
||||
Bezig
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="space-y-2">
|
||||
@foreach($orderedLogs as $log)
|
||||
<div class="border rounded p-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">{{ $log->step }} ({{ $log->status }})</span>
|
||||
<span class="text-xs text-slate-500">{{ $log->created_at }}</span>
|
||||
</div>
|
||||
@if($log->message)
|
||||
<div class="text-slate-700 mt-1">{{ $log->message }}</div>
|
||||
@endif
|
||||
@if($log->context)
|
||||
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AdminTicketController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
@@ -9,5 +10,9 @@ Route::get('/', function () {
|
||||
Route::prefix('admin')->group(function () {
|
||||
Route::view('/dashboard', 'admin.dashboard')->name('admin.dashboard');
|
||||
Route::view('/articles', 'admin.articles')->name('admin.articles');
|
||||
Route::view('/quick-replies', 'admin.quick-replies')->name('admin.quick-replies');
|
||||
Route::view('/tickets', 'admin.tickets')->name('admin.tickets');
|
||||
Route::get('/tickets/{ticket}', [AdminTicketController::class, 'show'])->name('admin.tickets.show');
|
||||
Route::view('/process', 'admin.process')->name('admin.process');
|
||||
Route::view('/settings', 'admin.settings')->name('admin.settings');
|
||||
});
|
||||
|
||||
@@ -14,6 +14,6 @@ class ExampleTest extends TestCase
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertRedirect(route('admin.dashboard'));
|
||||
}
|
||||
}
|
||||
|
||||
84
tests/Unit/AIClassifierServiceTest.php
Normal file
84
tests/Unit/AIClassifierServiceTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\DTOs\ArticleCandidateDTO;
|
||||
use App\Services\AIClassifierService;
|
||||
use App\Services\AppSettingsService;
|
||||
use App\Services\ClassifierPromptBuilder;
|
||||
use App\Services\Llm\LlmClientInterface;
|
||||
use App\Services\LlmJsonDecoder;
|
||||
use App\Services\ToolCallRequestValidator;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AIClassifierServiceTest extends TestCase
|
||||
{
|
||||
public function test_it_returns_validated_domain_tool_call_from_llm_json(): void
|
||||
{
|
||||
$client = new class implements LlmClientInterface
|
||||
{
|
||||
public string $prompt = '';
|
||||
|
||||
public function embed(string $text): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function generate(string $prompt, array $options = []): string
|
||||
{
|
||||
$this->prompt = $prompt;
|
||||
|
||||
return json_encode([
|
||||
'article_id' => 42,
|
||||
'confidence' => 0.91,
|
||||
'explanation' => 'Past bij domeininformatie.',
|
||||
'tool_call' => [
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'Example', 'tld' => 'NL'],
|
||||
'reason' => 'Domeinstatus is nodig.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
$settings = new class extends AppSettingsService
|
||||
{
|
||||
public function getPrompt(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return 'Select best article.';
|
||||
}
|
||||
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return $default;
|
||||
}
|
||||
};
|
||||
|
||||
$service = new AIClassifierService(
|
||||
$client,
|
||||
$settings,
|
||||
new ClassifierPromptBuilder,
|
||||
new LlmJsonDecoder,
|
||||
new ToolCallRequestValidator
|
||||
);
|
||||
$result = $service->rank('Hoe staat example.nl ingesteld?', [
|
||||
new ArticleCandidateDTO(
|
||||
articleId: 42,
|
||||
title: 'Domein controleren',
|
||||
content: 'Controleer domeininformatie.',
|
||||
distance: 0.12,
|
||||
note: 'Gebruik domain_inf wanneer een volledig domein genoemd wordt.',
|
||||
allowedActions: ['domain_inf'],
|
||||
),
|
||||
]);
|
||||
|
||||
$this->assertSame(42, $result->articleId);
|
||||
$this->assertSame([
|
||||
'action' => 'domain_inf',
|
||||
'parameters' => ['sld' => 'example', 'tld' => 'nl'],
|
||||
'reason' => 'Domeinstatus is nodig.',
|
||||
], $result->toolCall);
|
||||
$this->assertStringContainsString('Allowed actions: ["domain_inf"]', $client->prompt);
|
||||
$this->assertStringContainsString('Internal note for support assistant', $client->prompt);
|
||||
}
|
||||
}
|
||||
33
tests/Unit/DomainInfoToolTest.php
Normal file
33
tests/Unit/DomainInfoToolTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Tools\DomainInfoTool;
|
||||
use App\Services\Tools\OxxaClient;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DomainInfoToolTest extends TestCase
|
||||
{
|
||||
public function test_it_normalizes_and_validates_domain_parameters(): void
|
||||
{
|
||||
$tool = new DomainInfoTool(new OxxaClient);
|
||||
|
||||
$parameters = $tool->validateParameters([
|
||||
'sld' => 'Example-Domain',
|
||||
'tld' => 'NL',
|
||||
]);
|
||||
|
||||
$this->assertSame(['sld' => 'example-domain', 'tld' => 'nl'], $parameters);
|
||||
}
|
||||
|
||||
public function test_it_rejects_missing_domain_parameters(): void
|
||||
{
|
||||
$tool = new DomainInfoTool(new OxxaClient);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('domain_inf requires both sld and tld parameters.');
|
||||
|
||||
$tool->validateParameters(['sld' => 'example']);
|
||||
}
|
||||
}
|
||||
43
tests/Unit/OxxaClientTest.php
Normal file
43
tests/Unit/OxxaClientTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Tools\OxxaClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OxxaClientTest extends TestCase
|
||||
{
|
||||
public function test_it_hashes_password_and_parses_successful_xml(): void
|
||||
{
|
||||
config()->set('services.oxxa.endpoint', 'https://api.example.test/');
|
||||
config()->set('services.oxxa.timeout', 5);
|
||||
|
||||
Http::fake([
|
||||
'api.example.test/*' => Http::response(
|
||||
'<response><order><status_code>XMLOK 0</status_code><status_description>OK</status_description></order><domain><name>example.nl</name></domain></response>',
|
||||
200,
|
||||
['Content-Type' => 'application/xml']
|
||||
),
|
||||
]);
|
||||
|
||||
$result = (new OxxaClient)->request('domain_inf', [
|
||||
'apiuser' => 'demo',
|
||||
'apipassword' => 'secret',
|
||||
'sld' => 'example',
|
||||
'tld' => 'nl',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('XMLOK 0', $result['status_code']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
$url = (string) $request->url();
|
||||
|
||||
return str_contains($url, 'command=domain_inf')
|
||||
&& str_contains($url, 'apiuser=demo')
|
||||
&& str_contains($url, 'apipassword=MD5'.md5('secret'))
|
||||
&& ! str_contains($url, 'apipassword=secret');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user