Files
TicketAssistent/app/Services/AppSettingsService.php
SitiWeb f939133fe0 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.
2026-04-30 01:50:21 +02:00

284 lines
12 KiB
PHP

<?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]);
}
}
}