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

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

Enhance quick reply management functionality

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

Implement settings page for AI configuration

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

Add ticket show functionality with real-time updates

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

Create unit tests for AI classifier and domain info tool

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

View File

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

View File

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

View File

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

View File

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

View File

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