284 lines
12 KiB
PHP
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' => (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 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]);
|
|
}
|
|
}
|
|
}
|