'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' => (string) config('services.llm.provider', 'ollama'), 'llm.active_instance_id' => (string) config('services.llm.provider', 'ollama').'_default', 'llm.provider_instances' => json_encode($this->defaultProviderInstances()), 'llm.timeout' => (string) config('services.llm.timeout', 30), 'llm.providers.ollama.base_url' => (string) config('services.llm.base_url', 'http://localhost:11434'), 'llm.providers.ollama.chat_model' => (string) config('services.llm.chat_model', 'llama3'), 'llm.providers.ollama.embedding_model' => (string) config('services.llm.embedding_model', 'nomic-embed-text'), 'llm.providers.lmstudio.base_url' => (string) config('services.llm.base_url', 'http://localhost:1234'), 'llm.providers.lmstudio.chat_model' => (string) config('services.llm.chat_model', 'local-model'), 'llm.providers.lmstudio.embedding_model' => (string) config('services.llm.embedding_model', 'text-embedding-nomic-embed-text-v1.5@q6_k'), 'llm.models.normalization' => (string) config('services.llm.chat_model', 'llama3'), 'llm.models.classifier' => (string) config('services.llm.chat_model', 'llama3'), 'llm.models.knowledge_gap' => (string) config('services.llm.chat_model', 'llama3'), 'llm.models.support_reply' => (string) config('services.llm.chat_model', 'llama3'), 'llm.models.embedding' => (string) config('services.llm.embedding_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' => (string) config('services.llm.base_url', 'http://localhost:1234'), 'chat_model' => (string) config('services.llm.chat_model', 'local-model'), 'embedding_model' => (string) config('services.llm.embedding_model', 'text-embedding-nomic-embed-text-v1.5@q6_k'), ], [ 'id' => 'ollama_default', 'name' => 'Ollama', 'type' => 'ollama', 'base_url' => (string) config('services.llm.base_url', 'http://localhost:11434'), 'chat_model' => (string) config('services.llm.chat_model', 'llama3'), 'embedding_model' => (string) config('services.llm.embedding_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 toneAddressing(): string { return $this->get('tone_addressing', 'je') === 'u' ? 'u' : 'je'; } public function toneInstruction(): string { if ($this->toneAddressing() === 'u') { return 'Als de klanttaal Nederlands is: spreek de klant consequent formeel aan met u/uw. Gebruik geen je/jij/jouw.'; } return 'Als de klanttaal Nederlands is: spreek de klant consequent informeel aan met je/jij/jouw. Gebruik geen u/uw.'; } 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]); } } }