Bläddra i källkod

feat: add llm runtime tuning and validation

master
Jan Svabenik 1 månad sedan
förälder
incheckning
47c53478d9
12 ändrade filer med 442 tillägg och 68 borttagningar
  1. +4
    -3
      README.md
  2. +4
    -1
      docs/TARGET_STATE_AND_ROADMAP.md
  3. +5
    -0
      internal/app/app.go
  4. +62
    -1
      internal/domain/llm_settings.go
  5. +2
    -0
      internal/domain/models.go
  6. +128
    -17
      internal/httpserver/handlers/ui.go
  7. +73
    -8
      internal/llmruntime/runtime.go
  8. +77
    -28
      internal/mapping/provider_suggestion_generator.go
  9. +24
    -3
      internal/mapping/provider_suggestion_generator_test.go
  10. +5
    -0
      internal/store/sqlite/migrations/007_add_llm_runtime_tuning.sql
  11. +11
    -3
      internal/store/sqlite/store.go
  12. +47
    -4
      web/templates/settings.gohtml

+ 4
- 3
README.md Visa fil

@@ -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



+ 4
- 1
docs/TARGET_STATE_AND_ROADMAP.md Visa fil

@@ -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).


+ 5
- 0
internal/app/app.go Visa fil

@@ -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)


+ 62
- 1
internal/domain/llm_settings.go Visa fil

@@ -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 ""
}
}

+ 2
- 0
internal/domain/models.go Visa fil

@@ -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"`


+ 128
- 17
internal/httpserver/handlers/ui.go Visa fil

@@ -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)


+ 73
- 8
internal/llmruntime/runtime.go Visa fil

@@ -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 ""
}
}

+ 77
- 28
internal/mapping/provider_suggestion_generator.go Visa fil

@@ -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)
}
}

+ 24
- 3
internal/mapping/provider_suggestion_generator_test.go Visa fil

@@ -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)
}
}

+ 5
- 0
internal/store/sqlite/migrations/007_add_llm_runtime_tuning.sql Visa fil

@@ -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;

+ 11
- 3
internal/store/sqlite/store.go Visa fil

@@ -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)


+ 47
- 4
web/templates/settings.gohtml Visa fil

@@ -21,7 +21,7 @@
</table>

<h2>LLM Provider / Modell</h2>
<p><small>Phase-A-Grundlage: Provider, Modell, optionale Base URL (Ollama/kompatibel) und provider-spezifische API-Keys.</small></p>
<p><small>Provider-/Modellwahl mit statischem provider-aware Katalog (spaeter erweiterbar um dynamisches Refresh), Runtime-Tuning und provider-spezifischen Keys.</small></p>
<form method="post" action="/settings/llm">
<div>
<label>Provider
@@ -34,7 +34,7 @@
</div>
<div>
<label>Model
<select name="llm_model">
<select id="llm-model" name="llm_model" data-selected="{{.LLMActiveModel}}">
{{range .LLMModelOptions}}
<option value="{{.Value}}" {{if eq $.LLMActiveModel .Value}}selected{{end}}>{{.Label}}</option>
{{end}}
@@ -46,6 +46,16 @@
<input type="url" name="llm_base_url" placeholder="http://localhost:11434/v1" value="{{.LLMBaseURL}}">
</label>
</div>
<div>
<label>Temperature (0.0 - 2.0)
<input type="number" name="llm_temperature" min="0" max="2" step="0.01" value="{{printf "%.2f" .LLMTemperature}}">
</label>
</div>
<div>
<label>Max Tokens (64 - 8192)
<input type="number" name="llm_max_tokens" min="64" max="8192" step="1" value="{{.LLMMaxTokens}}">
</label>
</div>
<div>
<label>OpenAI API Key ({{if .OpenAIKeyConfigured}}configured{{else}}not configured{{end}})
<input type="password" name="llm_api_key_openai" placeholder="leer lassen = unveraendert">
@@ -71,6 +81,7 @@
<input type="password" name="llm_api_key_ollama" placeholder="leer lassen = unveraendert">
</label>
</div>
<button type="submit" formaction="/settings/llm/validate">Validate provider config</button>
<button type="submit">LLM-Settings speichern</button>
</form>

@@ -100,12 +111,44 @@
<script>
(function () {
var provider = document.getElementById('llm-provider');
var model = document.getElementById('llm-model');
var baseUrlWrap = document.getElementById('llm-base-url-wrap');
if (!provider || !baseUrlWrap) return;
if (!provider || !baseUrlWrap || !model) return;
var modelCatalog = {
{{range $provider := .LLMProviderOptions}}
"{{$provider.Value}}": [
{{range $idx, $model := $provider.Models}}{{if $idx}},{{end}}{"value":"{{$model.Value}}","label":"{{$model.Label}}"}{{end}}
],
{{end}}
};
var selectedByProvider = {};
selectedByProvider[provider.value] = model.dataset.selected || model.value;
var syncModelOptions = function () {
var providerValue = provider.value;
var options = modelCatalog[providerValue] || [];
var preferred = selectedByProvider[providerValue] || model.value;
model.innerHTML = "";
options.forEach(function (entry, idx) {
var option = document.createElement('option');
option.value = entry.value;
option.textContent = entry.label;
if (entry.value === preferred || (!preferred && idx === 0)) {
option.selected = true;
}
model.appendChild(option);
});
};
var syncBaseURLVisibility = function () {
baseUrlWrap.style.display = provider.value === 'ollama' ? '' : 'none';
};
provider.addEventListener('change', syncBaseURLVisibility);
provider.addEventListener('change', function () {
syncModelOptions();
syncBaseURLVisibility();
});
model.addEventListener('change', function () {
selectedByProvider[provider.value] = model.value;
});
syncModelOptions();
syncBaseURLVisibility();
})();
</script>


Laddar…
Avbryt
Spara