diff --git a/README.md b/README.md index 1ac38c5..564648e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Die App kann heute: - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. - Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build). - Globalen Master-Prompt in Settings pflegen sowie Prompt-Bloecke fuer den spaeteren LLM-Flow als Standard konfigurieren. -- Im Settings-/Config-Bereich die LLM-Basiskonfiguration pflegen: aktiver Provider, aktives Modell, Base URL fuer Ollama/kompatible Endpoints sowie getrennte API-Key-Speicher je Provider (OpenAI, Anthropic, Google, xAI, Ollama). +- Im Settings-/Config-Bereich die LLM-Basiskonfiguration pflegen: aktiver Provider, aktives Modell (provider-aware statische Auswahlliste), Base URL fuer Ollama/kompatible Endpoints, Temperature/Max Tokens sowie getrennte API-Key-Speicher je Provider (OpenAI, Anthropic, Google, xAI, Ollama). +- LLM-Provider-Konfiguration in Settings per leichtgewichtigem Validate-Action pruefen (aktiver Provider/Modell/Key/Base URL via kurzem Runtime-Request). - Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. - Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). @@ -22,7 +23,7 @@ Die App kann heute: Wichtig: - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. - LLM-Autofill bleibt Assistenz im Review-Flow: Vorschlaege werden separat gespeichert und manuell angewendet; bei Provider-Ausfall greift ein Fallback-Pfad (QC-kompatibel, danach deterministisch Rule-based). -- Provider-/Modell-/Base-URL/API-Key-Settings steuern den primaeren Suggestion-Runtimepfad produktiv. +- Provider-/Modell-/Base-URL/API-Key/Temperature/Max-Tokens-Settings steuern den primaeren Suggestion-Runtimepfad produktiv. ## Lokaler Start @@ -38,7 +39,7 @@ Wichtig: ## Persistenz Default ist SQLite. -Gespeichert werden Settings (inkl. Prompt-Konfig und LLM-Provider-/Modell-/Key-Grundlagen), Templates, Manifeste/Felder, Drafts und Site-Builds. +Gespeichert werden Settings (inkl. Prompt-Konfig und LLM-Provider-/Modell-/Runtime-/Key-Grundlagen), Templates, Manifeste/Felder, Drafts und Site-Builds. ## Draft-/Review-Flow diff --git a/docs/TARGET_STATE_AND_ROADMAP.md b/docs/TARGET_STATE_AND_ROADMAP.md index 2b20bad..e29e3bf 100644 --- a/docs/TARGET_STATE_AND_ROADMAP.md +++ b/docs/TARGET_STATE_AND_ROADMAP.md @@ -42,7 +42,9 @@ Aktueller Stand: - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. - LLM-first Suggestion-State fuer Draft-/Build-UI ist vorhanden: Vorschlaege werden separat von Feldwerten gespeichert und per Generate/Regenerate/Apply (global und per Feld) explizit gesteuert; Rule-based bleibt als letzter Fallback/Testpfad aktiv. -- Provider-aware Suggestion-Runtime ist aktiv: Settings (`llm_active_provider`, `llm_active_model`, provider-spezifischer API-Key, `llm_base_url` fuer Ollama/kompatible Endpoints) steuern den primaeren Laufzeitpfad; der bestehende QC-Pfad bleibt als Kompatibilitaetsfallback erhalten. +- Provider-aware Suggestion-Runtime ist aktiv: Settings (`llm_active_provider`, `llm_active_model`, `llm_temperature`, `llm_max_tokens`, provider-spezifischer API-Key, `llm_base_url` fuer Ollama/kompatible Endpoints) steuern den primaeren Laufzeitpfad; der bestehende QC-Pfad bleibt als Kompatibilitaetsfallback erhalten. +- Settings enthalten einen leichtgewichtigen Validate-Action fuer die aktive Provider-Konfiguration (kurzer Runtime-Check), ohne den Draft-/Review-Flow zu umgehen. +- Modellauswahl ist provider-aware statisch umgesetzt und so strukturiert, dass spaeter dynamische Model-Listen/Refresh anschliessbar sind. - Technische Felddetails (z. B. Feldpfade/Slots/Suggestion-Metadaten) sind im UI per Debug-Toggle optional einblendbar. - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. @@ -110,6 +112,7 @@ Statusmarker: - [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings in den LLM-Suggestionspfad eingebunden; Build-Flow ohne prominente Prompt-Interna. - [x] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer LLM-Autofill aktiv genutzt (inkl. verbesserter Trennung in Repeated-Bereichen). - [x] Phase A/B Provider-/Modell-Settings-Fundament inkl. produktiver Runtime-Umschaltung umgesetzt (Provider-/Modellwahl + provider-spezifische Keys + Base URL fuer Ollama/kompatible Endpoints steuern Suggestions direkt). +- [x] Phase C Komfort/Qualitaet umgesetzt: Temperature/Max Tokens in Settings + Runtime, Settings-Validate-Action, robustere Provider-Response-/Fehlerbehandlung und statisch provider-aware Modell-UX mit spaeterem Ausbaupfad. ### F) Security und Betriebsreife - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). diff --git a/internal/app/app.go b/internal/app/app.go index b82d027..38ee5cc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -91,6 +91,8 @@ func New(cfg config.Config) (*App, error) { JobPollTimeoutSeconds: cfg.PollTimeoutSeconds, LLMActiveProvider: domain.DefaultLLMProvider(), LLMActiveModel: domain.NormalizeLLMModel(domain.DefaultLLMProvider(), ""), + LLMTemperature: domain.DefaultLLMTemperature(), + LLMMaxTokens: domain.DefaultLLMMaxTokens(), MasterPrompt: domain.SeedMasterPrompt, PromptBlocks: domain.DefaultPromptBlocks(), } @@ -98,6 +100,8 @@ func New(cfg config.Config) (*App, error) { baseSettings.LLMActiveProvider = existing.LLMActiveProvider baseSettings.LLMActiveModel = existing.LLMActiveModel baseSettings.LLMBaseURL = existing.LLMBaseURL + baseSettings.LLMTemperature = existing.LLMTemperature + baseSettings.LLMMaxTokens = existing.LLMMaxTokens baseSettings.OpenAIAPIKeyEncrypted = existing.OpenAIAPIKeyEncrypted baseSettings.AnthropicAPIKeyEncrypted = existing.AnthropicAPIKeyEncrypted baseSettings.GoogleAPIKeyEncrypted = existing.GoogleAPIKeyEncrypted @@ -118,6 +122,7 @@ func New(cfg config.Config) (*App, error) { r.Get("/", ui.Home) r.Get("/settings", ui.Settings) r.Post("/settings/llm", ui.SaveLLMSettings) + r.Post("/settings/llm/validate", ui.ValidateLLMSettings) r.Post("/settings/prompt", ui.SavePromptSettings) r.Get("/templates", ui.Templates) r.Post("/templates/sync", ui.SyncTemplates) diff --git a/internal/domain/llm_settings.go b/internal/domain/llm_settings.go index a80d0d9..3c9c94a 100644 --- a/internal/domain/llm_settings.go +++ b/internal/domain/llm_settings.go @@ -1,6 +1,9 @@ package domain -import "strings" +import ( + "math" + "strings" +) const ( LLMProviderOpenAI = "openai" @@ -8,6 +11,13 @@ const ( LLMProviderGoogle = "google" LLMProviderXAI = "xai" LLMProviderOllama = "ollama" + + defaultLLMTemperature = 0.2 + minLLMTemperature = 0.0 + maxLLMTemperature = 2.0 + defaultLLMMaxTokens = 1200 + minLLMMaxTokens = 64 + maxLLMMaxTokens = 8192 ) type LLMModelOption struct { @@ -106,3 +116,54 @@ func NormalizeLLMModel(provider, model string) string { } return models[0].Value } + +func DefaultLLMTemperature() float64 { + return defaultLLMTemperature +} + +func NormalizeLLMTemperature(value float64) float64 { + if math.IsNaN(value) || math.IsInf(value, 0) { + return defaultLLMTemperature + } + if value < minLLMTemperature { + value = minLLMTemperature + } + if value > maxLLMTemperature { + value = maxLLMTemperature + } + return math.Round(value*100) / 100 +} + +func DefaultLLMMaxTokens() int { + return defaultLLMMaxTokens +} + +func NormalizeLLMMaxTokens(value int) int { + if value <= 0 { + return defaultLLMMaxTokens + } + if value < minLLMMaxTokens { + return minLLMMaxTokens + } + if value > maxLLMMaxTokens { + return maxLLMMaxTokens + } + return value +} + +func LLMAPIKeyForProvider(provider string, settings AppSettings) string { + switch NormalizeLLMProvider(provider) { + case LLMProviderOpenAI: + return strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) + case LLMProviderAnthropic: + return strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) + case LLMProviderGoogle: + return strings.TrimSpace(settings.GoogleAPIKeyEncrypted) + case LLMProviderXAI: + return strings.TrimSpace(settings.XAIAPIKeyEncrypted) + case LLMProviderOllama: + return strings.TrimSpace(settings.OllamaAPIKeyEncrypted) + default: + return "" + } +} diff --git a/internal/domain/models.go b/internal/domain/models.go index 1106ab6..ef78d53 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -155,6 +155,8 @@ type AppSettings struct { LLMActiveProvider string `json:"llmActiveProvider,omitempty"` LLMActiveModel string `json:"llmActiveModel,omitempty"` LLMBaseURL string `json:"llmBaseUrl,omitempty"` + LLMTemperature float64 `json:"llmTemperature,omitempty"` + LLMMaxTokens int `json:"llmMaxTokens,omitempty"` OpenAIAPIKeyEncrypted string `json:"openAiApiKeyEncrypted,omitempty"` AnthropicAPIKeyEncrypted string `json:"anthropicApiKeyEncrypted,omitempty"` GoogleAPIKeyEncrypted string `json:"googleApiKeyEncrypted,omitempty"` diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index 0aab5d7..92bbce1 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -19,6 +19,7 @@ import ( "qctextbuilder/internal/config" "qctextbuilder/internal/domain" "qctextbuilder/internal/draftsvc" + "qctextbuilder/internal/llmruntime" "qctextbuilder/internal/mapping" "qctextbuilder/internal/onboarding" "qctextbuilder/internal/store" @@ -65,6 +66,8 @@ type settingsPageData struct { LLMActiveProvider string LLMActiveModel string LLMBaseURL string + LLMTemperature float64 + LLMMaxTokens int OpenAIKeyConfigured bool AnthropicKeyConfigured bool GoogleKeyConfigured bool @@ -259,6 +262,8 @@ func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { LLMActiveProvider: activeProvider, LLMActiveModel: domain.NormalizeLLMModel(activeProvider, settings.LLMActiveModel), LLMBaseURL: strings.TrimSpace(settings.LLMBaseURL), + LLMTemperature: domain.NormalizeLLMTemperature(settings.LLMTemperature), + LLMMaxTokens: domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens), OpenAIKeyConfigured: strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) != "", AnthropicKeyConfigured: strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) != "", GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", @@ -289,30 +294,34 @@ func (u *UI) SaveLLMSettings(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) return } - settings := u.loadPromptSettings(r.Context()) - settings.LLMActiveProvider = domain.NormalizeLLMProvider(r.FormValue("llm_provider")) - settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, r.FormValue("llm_model")) - settings.LLMBaseURL = strings.TrimSpace(r.FormValue("llm_base_url")) - if value := strings.TrimSpace(r.FormValue("llm_api_key_openai")); value != "" { - settings.OpenAIAPIKeyEncrypted = value - } - if value := strings.TrimSpace(r.FormValue("llm_api_key_anthropic")); value != "" { - settings.AnthropicAPIKeyEncrypted = value + settings, err := applyLLMSettingsForm(u.loadPromptSettings(r.Context()), r) + if err != nil { + http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) + return } - if value := strings.TrimSpace(r.FormValue("llm_api_key_google")); value != "" { - settings.GoogleAPIKeyEncrypted = value + if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { + http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) + return } - if value := strings.TrimSpace(r.FormValue("llm_api_key_xai")); value != "" { - settings.XAIAPIKeyEncrypted = value + http.Redirect(w, r, "/settings?msg=llm+settings+saved", http.StatusSeeOther) +} + +func (u *UI) ValidateLLMSettings(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) + return } - if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { - settings.OllamaAPIKeyEncrypted = value + settings, err := applyLLMSettingsForm(u.loadPromptSettings(r.Context()), r) + if err != nil { + http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) + return } - if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { + if err := validateLLMProviderConfig(r.Context(), settings); err != nil { http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, "/settings?msg=llm+settings+saved", http.StatusSeeOther) + msg := fmt.Sprintf("llm provider config validated (%s / %s)", settings.LLMActiveProvider, settings.LLMActiveModel) + http.Redirect(w, r, "/settings?msg="+urlQuery(msg), http.StatusSeeOther) } func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { @@ -749,6 +758,104 @@ func urlQuery(s string) string { return url.QueryEscape(s) } +func applyLLMSettingsForm(settings domain.AppSettings, r *http.Request) (domain.AppSettings, error) { + next := settings + next.LLMActiveProvider = domain.NormalizeLLMProvider(r.FormValue("llm_provider")) + next.LLMActiveModel = domain.NormalizeLLMModel(next.LLMActiveProvider, r.FormValue("llm_model")) + next.LLMBaseURL = strings.TrimSpace(r.FormValue("llm_base_url")) + + tempRaw := strings.TrimSpace(r.FormValue("llm_temperature")) + if tempRaw == "" { + next.LLMTemperature = domain.NormalizeLLMTemperature(next.LLMTemperature) + } else { + temp, err := strconv.ParseFloat(tempRaw, 64) + if err != nil { + return settings, fmt.Errorf("invalid llm temperature") + } + next.LLMTemperature = domain.NormalizeLLMTemperature(temp) + } + + maxTokensRaw := strings.TrimSpace(r.FormValue("llm_max_tokens")) + if maxTokensRaw == "" { + next.LLMMaxTokens = domain.NormalizeLLMMaxTokens(next.LLMMaxTokens) + } else { + maxTokens, err := strconv.Atoi(maxTokensRaw) + if err != nil { + return settings, fmt.Errorf("invalid llm max tokens") + } + next.LLMMaxTokens = domain.NormalizeLLMMaxTokens(maxTokens) + } + + if value := strings.TrimSpace(r.FormValue("llm_api_key_openai")); value != "" { + next.OpenAIAPIKeyEncrypted = value + } + if value := strings.TrimSpace(r.FormValue("llm_api_key_anthropic")); value != "" { + next.AnthropicAPIKeyEncrypted = value + } + if value := strings.TrimSpace(r.FormValue("llm_api_key_google")); value != "" { + next.GoogleAPIKeyEncrypted = value + } + if value := strings.TrimSpace(r.FormValue("llm_api_key_xai")); value != "" { + next.XAIAPIKeyEncrypted = value + } + if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { + next.OllamaAPIKeyEncrypted = value + } + return next, nil +} + +func validateLLMProviderConfig(ctx context.Context, settings domain.AppSettings) error { + provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) + model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) + if strings.TrimSpace(model) == "" { + return fmt.Errorf("no active model configured") + } + baseURL := strings.TrimSpace(settings.LLMBaseURL) + if baseURL != "" { + parsed, err := url.Parse(baseURL) + if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" { + return fmt.Errorf("invalid llm base url") + } + } + apiKey := domain.LLMAPIKeyForProvider(provider, settings) + if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { + return fmt.Errorf("api key for provider %s is not configured", provider) + } + + runtimeFactory := llmruntime.NewFactory(10 * time.Second) + client, err := runtimeFactory.ClientFor(provider) + if err != nil { + return err + } + + temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature) + maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens) + validationTokens := maxTokens + if validationTokens > 64 { + validationTokens = 64 + } + if validationTokens < 16 { + validationTokens = 16 + } + resp, err := client.Generate(ctx, llmruntime.Request{ + Provider: provider, + Model: model, + BaseURL: baseURL, + APIKey: apiKey, + Temperature: &temperature, + MaxTokens: &validationTokens, + SystemPrompt: "You validate LLM connectivity for settings. Answer with plain text OK.", + UserPrompt: "Return OK", + }) + if err != nil { + return fmt.Errorf("provider validation failed (%s/%s): %w", provider, model, err) + } + if strings.TrimSpace(resp) == "" { + return fmt.Errorf("provider validation failed (%s/%s): empty response", provider, model) + } + return nil +} + func boolPtr(v bool) *bool { return &v } func intPtr(v int) *int { return &v } func int64Ptr(v int64) *int64 { return &v } @@ -1660,6 +1767,8 @@ func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds, LLMActiveProvider: defaultProvider, LLMActiveModel: domain.NormalizeLLMModel(defaultProvider, ""), + LLMTemperature: domain.DefaultLLMTemperature(), + LLMMaxTokens: domain.DefaultLLMMaxTokens(), MasterPrompt: domain.SeedMasterPrompt, PromptBlocks: domain.DefaultPromptBlocks(), } @@ -1688,6 +1797,8 @@ func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { settings.LLMActiveProvider = domain.NormalizeLLMProvider(stored.LLMActiveProvider) settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, stored.LLMActiveModel) settings.LLMBaseURL = strings.TrimSpace(stored.LLMBaseURL) + settings.LLMTemperature = domain.NormalizeLLMTemperature(stored.LLMTemperature) + settings.LLMMaxTokens = domain.NormalizeLLMMaxTokens(stored.LLMMaxTokens) settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(stored.OpenAIAPIKeyEncrypted) settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(stored.AnthropicAPIKeyEncrypted) settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) diff --git a/internal/llmruntime/runtime.go b/internal/llmruntime/runtime.go index 8ac631f..b815f97 100644 --- a/internal/llmruntime/runtime.go +++ b/internal/llmruntime/runtime.go @@ -17,6 +17,8 @@ type Request struct { Model string BaseURL string APIKey string + Temperature *float64 + MaxTokens *int SystemPrompt string UserPrompt string } @@ -71,7 +73,8 @@ func (c *openAICompatibleClient) Generate(ctx context.Context, req Request) (str payload := map[string]any{ "model": strings.TrimSpace(req.Model), - "temperature": 0, + "temperature": optionalFloat64(req.Temperature, 0), + "max_tokens": optionalInt(req.MaxTokens, 1200), "messages": []map[string]string{ {"role": "system", "content": strings.TrimSpace(req.SystemPrompt)}, {"role": "user", "content": strings.TrimSpace(req.UserPrompt)}, @@ -110,8 +113,8 @@ func (c *anthropicClient) Generate(ctx context.Context, req Request) (string, er } payload := map[string]any{ "model": strings.TrimSpace(req.Model), - "max_tokens": 1200, - "temperature": 0, + "max_tokens": optionalInt(req.MaxTokens, 1200), + "temperature": optionalFloat64(req.Temperature, 0), "system": strings.TrimSpace(req.SystemPrompt), "messages": []map[string]any{ {"role": "user", "content": strings.TrimSpace(req.UserPrompt)}, @@ -164,7 +167,8 @@ func (c *googleClient) Generate(ctx context.Context, req Request) (string, error {"parts": []map[string]string{{"text": strings.TrimSpace(req.UserPrompt)}}}, }, "generationConfig": map[string]any{ - "temperature": 0, + "temperature": optionalFloat64(req.Temperature, 0), + "maxOutputTokens": optionalInt(req.MaxTokens, 1200), }, } if strings.TrimSpace(req.SystemPrompt) != "" { @@ -239,11 +243,72 @@ func doJSON(ctx context.Context, httpClient *http.Client, method, endpoint, apiK return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode >= 400 { - message := strings.TrimSpace(string(respBody)) - if len(message) > 500 { - message = message[:500] - } + message := trimProviderErrorMessage(respBody) return nil, fmt.Errorf("provider http %d: %s", resp.StatusCode, message) } return respBody, nil } + +func optionalFloat64(value *float64, fallback float64) float64 { + if value == nil { + return fallback + } + return *value +} + +func optionalInt(value *int, fallback int) int { + if value == nil { + return fallback + } + return *value +} + +func trimProviderErrorMessage(respBody []byte) string { + message := extractProviderErrorMessage(respBody) + if len(message) > 500 { + return message[:500] + } + return message +} + +func extractProviderErrorMessage(respBody []byte) string { + raw := strings.TrimSpace(string(respBody)) + if raw == "" { + return "empty error response" + } + var parsed map[string]any + if err := json.Unmarshal(respBody, &parsed); err == nil { + if value := nestedString(parsed, "error", "message"); value != "" { + return value + } + if value := nestedString(parsed, "error"); value != "" { + return value + } + if value := nestedString(parsed, "message"); value != "" { + return value + } + } + return raw +} + +func nestedString(values map[string]any, path ...string) string { + if len(path) == 0 || values == nil { + return "" + } + current := any(values) + for _, key := range path { + nextMap, ok := current.(map[string]any) + if !ok { + return "" + } + current = nextMap[key] + } + switch value := current.(type) { + case string: + return strings.TrimSpace(value) + case fmt.Stringer: + return strings.TrimSpace(value.String()) + default: + return "" + } +} diff --git a/internal/mapping/provider_suggestion_generator.go b/internal/mapping/provider_suggestion_generator.go index 75792df..f31e9c3 100644 --- a/internal/mapping/provider_suggestion_generator.go +++ b/internal/mapping/provider_suggestion_generator.go @@ -40,9 +40,9 @@ func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req Sug if strings.TrimSpace(model) == "" { return SuggestionResult{}, fmt.Errorf("no active model configured") } - apiKey := apiKeyForProvider(provider, *settings) + apiKey := domain.LLMAPIKeyForProvider(provider, *settings) if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { - return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured", provider) + return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured in settings", provider) } targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled) @@ -59,21 +59,25 @@ func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req Sug return SuggestionResult{}, err } systemPrompt, userPrompt := buildProviderPrompts(req, targets) + temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature) + maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens) raw, err := providerClient.Generate(ctx, llmruntime.Request{ Provider: provider, Model: model, BaseURL: strings.TrimSpace(settings.LLMBaseURL), APIKey: strings.TrimSpace(apiKey), + Temperature: &temperature, + MaxTokens: &maxTokens, SystemPrompt: systemPrompt, UserPrompt: userPrompt, }) if err != nil { - return SuggestionResult{}, err + return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err) } parsed, err := parseProviderSuggestions(raw) if err != nil { - return SuggestionResult{}, err + return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err) } out := SuggestionResult{ @@ -127,27 +131,71 @@ func parseProviderSuggestions(raw string) ([]providerSuggestion, error) { candidates = append(candidates, object) } + var firstErr error for _, candidate := range candidates { - items, ok := parseSuggestionsCandidate(candidate) - if ok { + items, err := parseSuggestionsCandidate(candidate) + if err == nil { return items, nil } + if firstErr == nil { + firstErr = err + } + } + if firstErr != nil { + return nil, firstErr } return nil, fmt.Errorf("provider response is not valid suggestions json") } -func parseSuggestionsCandidate(raw string) ([]providerSuggestion, bool) { - var objectPayload struct { - Suggestions []providerSuggestion `json:"suggestions"` +func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) { + var root any + if err := json.Unmarshal([]byte(raw), &root); err != nil { + return nil, fmt.Errorf("provider response is not valid json: %w", err) + } + + var itemsRaw []any + switch value := root.(type) { + case map[string]any: + suggestions, ok := value["suggestions"] + if !ok { + return nil, fmt.Errorf("provider json object must contain \"suggestions\" array") + } + list, ok := suggestions.([]any) + if !ok { + return nil, fmt.Errorf("provider \"suggestions\" must be an array") + } + itemsRaw = list + case []any: + itemsRaw = value + default: + return nil, fmt.Errorf("provider json payload must be an object or array") } - if err := json.Unmarshal([]byte(raw), &objectPayload); err == nil && len(objectPayload.Suggestions) > 0 { - return objectPayload.Suggestions, true + if len(itemsRaw) == 0 { + return nil, fmt.Errorf("provider returned an empty suggestions array") } - var listPayload []providerSuggestion - if err := json.Unmarshal([]byte(raw), &listPayload); err == nil && len(listPayload) > 0 { - return listPayload, true + + out := make([]providerSuggestion, 0, len(itemsRaw)) + for idx, rawItem := range itemsRaw { + itemMap, ok := rawItem.(map[string]any) + if !ok { + return nil, fmt.Errorf("suggestion #%d is not an object", idx+1) + } + fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"])) + if fieldPath == "" { + return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1) + } + value := strings.TrimSpace(anyToString(itemMap["value"])) + if value == "" { + return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath) + } + out = append(out, providerSuggestion{ + FieldPath: fieldPath, + Slot: strings.TrimSpace(anyToString(itemMap["slot"])), + Value: value, + Reason: strings.TrimSpace(anyToString(itemMap["reason"])), + }) } - return nil, false + return out, nil } func extractFencedJSON(value string) string { @@ -210,19 +258,20 @@ func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) ( return system, user } -func apiKeyForProvider(provider string, settings domain.AppSettings) string { - switch provider { - case domain.LLMProviderOpenAI: - return strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) - case domain.LLMProviderAnthropic: - return strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) - case domain.LLMProviderGoogle: - return strings.TrimSpace(settings.GoogleAPIKeyEncrypted) - case domain.LLMProviderXAI: - return strings.TrimSpace(settings.XAIAPIKeyEncrypted) - case domain.LLMProviderOllama: - return strings.TrimSpace(settings.OllamaAPIKeyEncrypted) - default: +func anyToString(raw any) string { + switch value := raw.(type) { + case string: + return value + case float64: + return fmt.Sprintf("%.0f", value) + case bool: + if value { + return "true" + } + return "false" + case nil: return "" + default: + return fmt.Sprintf("%v", value) } } diff --git a/internal/mapping/provider_suggestion_generator_test.go b/internal/mapping/provider_suggestion_generator_test.go index 28c499c..d0c9440 100644 --- a/internal/mapping/provider_suggestion_generator_test.go +++ b/internal/mapping/provider_suggestion_generator_test.go @@ -29,9 +29,11 @@ func TestProviderAwareSuggestionGenerator_UsesActiveProviderModelAndKey(t *testi t.Parallel() var ( - gotPath string - gotAuth string - gotModel string + gotPath string + gotAuth string + gotModel string + gotTemperature float64 + gotMaxTokens float64 ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path @@ -39,6 +41,8 @@ func TestProviderAwareSuggestionGenerator_UsesActiveProviderModelAndKey(t *testi var payload map[string]any _ = json.NewDecoder(r.Body).Decode(&payload) gotModel, _ = payload["model"].(string) + gotTemperature, _ = payload["temperature"].(float64) + gotMaxTokens, _ = payload["max_tokens"].(float64) _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"suggestions\":[{\"fieldPath\":\"text.textTitle_m1710_1\",\"value\":\"Provider Hero\",\"reason\":\"focused hero\"}]}"}}]}`)) })) defer server.Close() @@ -47,6 +51,8 @@ func TestProviderAwareSuggestionGenerator_UsesActiveProviderModelAndKey(t *testi LLMActiveProvider: domain.LLMProviderOpenAI, LLMActiveModel: "gpt-5.4", LLMBaseURL: server.URL, + LLMTemperature: 0.65, + LLMMaxTokens: 333, OpenAIAPIKeyEncrypted: "openai-key", }}, llmruntime.NewFactory(5*time.Second)) @@ -75,6 +81,12 @@ func TestProviderAwareSuggestionGenerator_UsesActiveProviderModelAndKey(t *testi if gotModel != "gpt-5.4" { t.Fatalf("unexpected model: %q", gotModel) } + if gotTemperature != 0.65 { + t.Fatalf("unexpected temperature: %v", gotTemperature) + } + if gotMaxTokens != 333 { + t.Fatalf("unexpected max_tokens: %v", gotMaxTokens) + } } func TestProviderAwareSuggestionGenerator_RequiresAPIKeyForNonOllama(t *testing.T) { @@ -104,3 +116,12 @@ func TestParseProviderSuggestions_AcceptsFencedJSON(t *testing.T) { t.Fatalf("unexpected parsed result: %+v", items) } } + +func TestParseProviderSuggestions_RejectsEmptyValue(t *testing.T) { + t.Parallel() + + _, err := parseProviderSuggestions(`{"suggestions":[{"fieldPath":"a","value":""}]}`) + if err == nil || !strings.Contains(err.Error(), "empty value") { + t.Fatalf("expected empty value error, got: %v", err) + } +} diff --git a/internal/store/sqlite/migrations/007_add_llm_runtime_tuning.sql b/internal/store/sqlite/migrations/007_add_llm_runtime_tuning.sql new file mode 100644 index 0000000..e7d718e --- /dev/null +++ b/internal/store/sqlite/migrations/007_add_llm_runtime_tuning.sql @@ -0,0 +1,5 @@ +ALTER TABLE app_settings +ADD COLUMN llm_temperature REAL NOT NULL DEFAULT 0.2; + +ALTER TABLE app_settings +ADD COLUMN llm_max_tokens INTEGER NOT NULL DEFAULT 1200; diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index 1e3f384..9f7b0e0 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -415,10 +415,10 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) _, err = s.db.ExecContext(ctx, ` INSERT INTO app_settings ( id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, - llm_active_provider, llm_active_model, llm_base_url, + llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, master_prompt, prompt_blocks_json, updated_at - ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET qc_base_url = excluded.qc_base_url, qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, @@ -428,6 +428,8 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) llm_active_provider = excluded.llm_active_provider, llm_active_model = excluded.llm_active_model, llm_base_url = excluded.llm_base_url, + llm_temperature = excluded.llm_temperature, + llm_max_tokens = excluded.llm_max_tokens, openai_api_key_encrypted = excluded.openai_api_key_encrypted, anthropic_api_key_encrypted = excluded.anthropic_api_key_encrypted, google_api_key_encrypted = excluded.google_api_key_encrypted, @@ -444,6 +446,8 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) provider, model, strings.TrimSpace(settings.LLMBaseURL), + domain.NormalizeLLMTemperature(settings.LLMTemperature), + domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens), strings.TrimSpace(settings.OpenAIAPIKeyEncrypted), strings.TrimSpace(settings.AnthropicAPIKeyEncrypted), strings.TrimSpace(settings.GoogleAPIKeyEncrypted), @@ -459,7 +463,7 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { row := s.db.QueryRowContext(ctx, ` SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, - llm_active_provider, llm_active_model, llm_base_url, + llm_active_provider, llm_active_model, llm_base_url, llm_temperature, llm_max_tokens, openai_api_key_encrypted, anthropic_api_key_encrypted, google_api_key_encrypted, xai_api_key_encrypted, ollama_api_key_encrypted, master_prompt, prompt_blocks_json FROM app_settings @@ -475,6 +479,8 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { &settings.LLMActiveProvider, &settings.LLMActiveModel, &settings.LLMBaseURL, + &settings.LLMTemperature, + &settings.LLMMaxTokens, &settings.OpenAIAPIKeyEncrypted, &settings.AnthropicAPIKeyEncrypted, &settings.GoogleAPIKeyEncrypted, @@ -495,6 +501,8 @@ func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { settings.LLMActiveProvider = domain.NormalizeLLMProvider(settings.LLMActiveProvider) settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, settings.LLMActiveModel) settings.LLMBaseURL = strings.TrimSpace(settings.LLMBaseURL) + settings.LLMTemperature = domain.NormalizeLLMTemperature(settings.LLMTemperature) + settings.LLMMaxTokens = domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens) settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) settings.GoogleAPIKeyEncrypted = strings.TrimSpace(settings.GoogleAPIKeyEncrypted) diff --git a/web/templates/settings.gohtml b/web/templates/settings.gohtml index 499e8c8..33c4bcb 100644 --- a/web/templates/settings.gohtml +++ b/web/templates/settings.gohtml @@ -21,7 +21,7 @@
Phase-A-Grundlage: Provider, Modell, optionale Base URL (Ollama/kompatibel) und provider-spezifische API-Keys.
+Provider-/Modellwahl mit statischem provider-aware Katalog (spaeter erweiterbar um dynamisches Refresh), Runtime-Tuning und provider-spezifischen Keys.
@@ -100,12 +111,44 @@