| @@ -14,15 +14,15 @@ Die App kann heute: | |||||
| - Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. | - 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. | - 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). | - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). | ||||
| - LLM-first Autofill-Vorschlaege (ueber den bestehenden QC-Providerpfad), mit strukturierter Feldzuordnung auf `fieldPath`/Slot und Rule-based Fallback fuer Ausfall-/Testfaelle. | |||||
| - LLM-first Autofill-Vorschlaege ueber provider-aware Runtime (OpenAI, Anthropic, Google, xAI, Ollama/kompatibel) mit aktiver Provider-/Modell-Auswahl aus Settings, strukturierter Feldzuordnung auf `fieldPath`/Slot und Rule-based Fallback fuer Ausfall-/Testfaelle. | |||||
| - Suggestion-Workflow getrennt von Feldwerten (Preview), inkl. `Generate all`, `Regenerate all`, `Apply all to empty` sowie per-Feld `Apply`/`Regenerate` im Draft-/Build-UI. | - Suggestion-Workflow getrennt von Feldwerten (Preview), inkl. `Generate all`, `Regenerate all`, `Apply all to empty` sowie per-Feld `Apply`/`Regenerate` im Draft-/Build-UI. | ||||
| - Technische Felddetails (z. B. `fieldPath`, Suggestion-Metadaten, Slot-Preview) sind im UI standardmaessig ausgeblendet und nur per Debug-Toggle sichtbar. | - Technische Felddetails (z. B. `fieldPath`, Suggestion-Metadaten, Slot-Preview) sind im UI standardmaessig ausgeblendet und nur per Debug-Toggle sichtbar. | ||||
| - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | ||||
| Wichtig: | Wichtig: | ||||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | - 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 LLM-Ausfall greift deterministischer Rule-based Fallback. | |||||
| - Die neue Provider-/Modell-Konfiguration ist Phase-A-Grundlage fuer spaeteres Routing; der bestehende LLM-Suggestions-Runtimepfad bleibt in diesem Schritt unveraendert. | |||||
| - 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. | |||||
| ## Lokaler Start | ## Lokaler Start | ||||
| @@ -41,8 +41,8 @@ Aktueller Stand: | |||||
| - Prompt-/Systemsteuerung liegt global in Settings; der normale Build-/Review-Flow bleibt auf Inhalte und Feldbearbeitung fokussiert. | - Prompt-/Systemsteuerung liegt global in Settings; der normale Build-/Review-Flow bleibt auf Inhalte und Feldbearbeitung fokussiert. | ||||
| - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. | - 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. | - 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 Fallback/Testpfad aktiv. | |||||
| - Settings-Grundlage fuer spaetere Providerwahl ist vorhanden: aktiver LLM-Provider, aktives Modell, Base URL fuer Ollama/kompatible Endpoints sowie getrennte API-Key-Felder je Provider (OpenAI, Anthropic, Google, xAI, Ollama) sind persistent in `app_settings`. | |||||
| - 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. | |||||
| - Technische Felddetails (z. B. Feldpfade/Slots/Suggestion-Metadaten) sind im UI per Debug-Toggle optional einblendbar. | - 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`. | - 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. | - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | ||||
| @@ -104,12 +104,12 @@ Statusmarker: | |||||
| - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. | - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. | ||||
| ### E) LLM-Assistenz | ### E) LLM-Assistenz | ||||
| - [-] Feldvorschlaege im Draft als expliziter Preview-/Apply-/Regenerate-Workflow (LLM-first ueber bestehenden Providerpfad; Rule-based nur Fallback/Test). | |||||
| - [x] Draft-Autofill mit nachvollziehbarer Herkunft je Feld (`llm` vs `fallback-rule-based` im Suggestion-State). | |||||
| - [x] Feldvorschlaege im Draft als expliziter Preview-/Apply-/Regenerate-Workflow (LLM-first ueber provider-aware Runtime; Rule-based nur Fallback/Test). | |||||
| - [x] Draft-Autofill mit nachvollziehbarer Herkunft je Feld (provider-label wie `openai`/`anthropic`/`google`/`xai`/`ollama`, `qc-llm` als Kompatibilitaetsfallback, `fallback-rule-based` als letzter Fallback). | |||||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontext wird in den LLM-Pfad uebergeben; Qualitaets-/Governance-Feinschliff offen). | - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontext wird in den LLM-Pfad uebergeben; Qualitaets-/Governance-Feinschliff offen). | ||||
| - [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings in den LLM-Suggestionspfad eingebunden; Build-Flow ohne prominente Prompt-Interna. | - [-] 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] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer LLM-Autofill aktiv genutzt (inkl. verbesserter Trennung in Repeated-Bereichen). | ||||
| - [-] Phase A Provider-/Modell-Settings-Fundament in Settings/UI/Persistenz umgesetzt (inkl. provider-spezifischer Key-Speicherung); produktive Runtime-Umschaltung pro Provider/Modell folgt in spaeteren Phasen. | |||||
| - [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). | |||||
| ### F) Security und Betriebsreife | ### F) Security und Betriebsreife | ||||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | ||||
| @@ -17,6 +17,7 @@ import ( | |||||
| "qctextbuilder/internal/httpserver" | "qctextbuilder/internal/httpserver" | ||||
| "qctextbuilder/internal/httpserver/handlers" | "qctextbuilder/internal/httpserver/handlers" | ||||
| "qctextbuilder/internal/httpserver/views" | "qctextbuilder/internal/httpserver/views" | ||||
| "qctextbuilder/internal/llmruntime" | |||||
| "qctextbuilder/internal/logging" | "qctextbuilder/internal/logging" | ||||
| "qctextbuilder/internal/mapping" | "qctextbuilder/internal/mapping" | ||||
| "qctextbuilder/internal/onboarding" | "qctextbuilder/internal/onboarding" | ||||
| @@ -71,9 +72,13 @@ func New(cfg config.Config) (*App, error) { | |||||
| draftSvc := draftsvc.New(draftStore, templateStore, manifestStore) | draftSvc := draftsvc.New(draftStore, templateStore, manifestStore) | ||||
| mappingSvc := mapping.New() | mappingSvc := mapping.New() | ||||
| buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) | buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) | ||||
| providerRuntime := llmruntime.NewFactory(45 * time.Second) | |||||
| suggestionGenerator := mapping.NewCompositeSuggestionGenerator( | suggestionGenerator := mapping.NewCompositeSuggestionGenerator( | ||||
| mapping.NewLLMSuggestionGenerator(qc), | |||||
| mapping.NewRuleBasedSuggestionGenerator(), | |||||
| mapping.NewProviderAwareSuggestionGenerator(settingsStore, providerRuntime), | |||||
| mapping.NewCompositeSuggestionGenerator( | |||||
| mapping.NewQCLLMSuggestionGenerator(qc), | |||||
| mapping.NewRuleBasedSuggestionGenerator(), | |||||
| ), | |||||
| ) | ) | ||||
| pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) | pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) | ||||
| api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc) | api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc) | ||||
| @@ -89,6 +89,7 @@ type BuildDraft struct { | |||||
| const ( | const ( | ||||
| DraftSuggestionSourceLLM = "llm" | DraftSuggestionSourceLLM = "llm" | ||||
| DraftSuggestionSourceQCLLM = "qc-llm" | |||||
| DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based" | DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based" | ||||
| DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased | DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased | ||||
| @@ -0,0 +1,249 @@ | |||||
| package llmruntime | |||||
| import ( | |||||
| "bytes" | |||||
| "context" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "io" | |||||
| "net/http" | |||||
| "net/url" | |||||
| "strings" | |||||
| "time" | |||||
| ) | |||||
| type Request struct { | |||||
| Provider string | |||||
| Model string | |||||
| BaseURL string | |||||
| APIKey string | |||||
| SystemPrompt string | |||||
| UserPrompt string | |||||
| } | |||||
| type Client interface { | |||||
| Generate(ctx context.Context, req Request) (string, error) | |||||
| } | |||||
| type Factory struct { | |||||
| httpClient *http.Client | |||||
| } | |||||
| func NewFactory(timeout time.Duration) *Factory { | |||||
| if timeout <= 0 { | |||||
| timeout = 45 * time.Second | |||||
| } | |||||
| return &Factory{ | |||||
| httpClient: &http.Client{Timeout: timeout}, | |||||
| } | |||||
| } | |||||
| func (f *Factory) ClientFor(provider string) (Client, error) { | |||||
| normalized := strings.ToLower(strings.TrimSpace(provider)) | |||||
| switch normalized { | |||||
| case "openai", "xai", "ollama": | |||||
| return &openAICompatibleClient{httpClient: f.httpClient}, nil | |||||
| case "anthropic": | |||||
| return &anthropicClient{httpClient: f.httpClient}, nil | |||||
| case "google": | |||||
| return &googleClient{httpClient: f.httpClient}, nil | |||||
| default: | |||||
| return nil, fmt.Errorf("unsupported llm provider: %s", normalized) | |||||
| } | |||||
| } | |||||
| type openAICompatibleClient struct { | |||||
| httpClient *http.Client | |||||
| } | |||||
| func (c *openAICompatibleClient) Generate(ctx context.Context, req Request) (string, error) { | |||||
| baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") | |||||
| if baseURL == "" { | |||||
| switch strings.ToLower(strings.TrimSpace(req.Provider)) { | |||||
| case "xai": | |||||
| baseURL = "https://api.x.ai" | |||||
| case "ollama": | |||||
| baseURL = "http://localhost:11434" | |||||
| default: | |||||
| baseURL = "https://api.openai.com" | |||||
| } | |||||
| } | |||||
| payload := map[string]any{ | |||||
| "model": strings.TrimSpace(req.Model), | |||||
| "temperature": 0, | |||||
| "messages": []map[string]string{ | |||||
| {"role": "system", "content": strings.TrimSpace(req.SystemPrompt)}, | |||||
| {"role": "user", "content": strings.TrimSpace(req.UserPrompt)}, | |||||
| }, | |||||
| } | |||||
| body, err := doJSON(ctx, c.httpClient, http.MethodPost, baseURL+"/v1/chat/completions", req.APIKey, nil, payload) | |||||
| if err != nil { | |||||
| return "", err | |||||
| } | |||||
| var response struct { | |||||
| Choices []struct { | |||||
| Message struct { | |||||
| Content string `json:"content"` | |||||
| } `json:"message"` | |||||
| } `json:"choices"` | |||||
| } | |||||
| if err := json.Unmarshal(body, &response); err != nil { | |||||
| return "", fmt.Errorf("decode openai-compatible response: %w", err) | |||||
| } | |||||
| if len(response.Choices) == 0 { | |||||
| return "", fmt.Errorf("empty openai-compatible response") | |||||
| } | |||||
| return strings.TrimSpace(response.Choices[0].Message.Content), nil | |||||
| } | |||||
| type anthropicClient struct { | |||||
| httpClient *http.Client | |||||
| } | |||||
| func (c *anthropicClient) Generate(ctx context.Context, req Request) (string, error) { | |||||
| baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") | |||||
| if baseURL == "" { | |||||
| baseURL = "https://api.anthropic.com" | |||||
| } | |||||
| payload := map[string]any{ | |||||
| "model": strings.TrimSpace(req.Model), | |||||
| "max_tokens": 1200, | |||||
| "temperature": 0, | |||||
| "system": strings.TrimSpace(req.SystemPrompt), | |||||
| "messages": []map[string]any{ | |||||
| {"role": "user", "content": strings.TrimSpace(req.UserPrompt)}, | |||||
| }, | |||||
| } | |||||
| headers := map[string]string{"anthropic-version": "2023-06-01"} | |||||
| body, err := doJSON(ctx, c.httpClient, http.MethodPost, baseURL+"/v1/messages", req.APIKey, headers, payload) | |||||
| if err != nil { | |||||
| return "", err | |||||
| } | |||||
| var response struct { | |||||
| Content []struct { | |||||
| Type string `json:"type"` | |||||
| Text string `json:"text"` | |||||
| } `json:"content"` | |||||
| } | |||||
| if err := json.Unmarshal(body, &response); err != nil { | |||||
| return "", fmt.Errorf("decode anthropic response: %w", err) | |||||
| } | |||||
| for _, item := range response.Content { | |||||
| if strings.EqualFold(strings.TrimSpace(item.Type), "text") && strings.TrimSpace(item.Text) != "" { | |||||
| return strings.TrimSpace(item.Text), nil | |||||
| } | |||||
| } | |||||
| return "", fmt.Errorf("empty anthropic response") | |||||
| } | |||||
| type googleClient struct { | |||||
| httpClient *http.Client | |||||
| } | |||||
| func (c *googleClient) Generate(ctx context.Context, req Request) (string, error) { | |||||
| baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") | |||||
| if baseURL == "" { | |||||
| baseURL = "https://generativelanguage.googleapis.com" | |||||
| } | |||||
| model := strings.TrimSpace(req.Model) | |||||
| if model == "" { | |||||
| return "", fmt.Errorf("google model is required") | |||||
| } | |||||
| apiKey := strings.TrimSpace(req.APIKey) | |||||
| if apiKey == "" { | |||||
| return "", fmt.Errorf("google api key is required") | |||||
| } | |||||
| endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", baseURL, url.PathEscape(model), url.QueryEscape(apiKey)) | |||||
| payload := map[string]any{ | |||||
| "contents": []map[string]any{ | |||||
| {"parts": []map[string]string{{"text": strings.TrimSpace(req.UserPrompt)}}}, | |||||
| }, | |||||
| "generationConfig": map[string]any{ | |||||
| "temperature": 0, | |||||
| }, | |||||
| } | |||||
| if strings.TrimSpace(req.SystemPrompt) != "" { | |||||
| payload["systemInstruction"] = map[string]any{ | |||||
| "parts": []map[string]string{{"text": strings.TrimSpace(req.SystemPrompt)}}, | |||||
| } | |||||
| } | |||||
| body, err := doJSON(ctx, c.httpClient, http.MethodPost, endpoint, "", nil, payload) | |||||
| if err != nil { | |||||
| return "", err | |||||
| } | |||||
| var response struct { | |||||
| Candidates []struct { | |||||
| Content struct { | |||||
| Parts []struct { | |||||
| Text string `json:"text"` | |||||
| } `json:"parts"` | |||||
| } `json:"content"` | |||||
| } `json:"candidates"` | |||||
| } | |||||
| if err := json.Unmarshal(body, &response); err != nil { | |||||
| return "", fmt.Errorf("decode google response: %w", err) | |||||
| } | |||||
| if len(response.Candidates) == 0 { | |||||
| return "", fmt.Errorf("empty google response") | |||||
| } | |||||
| parts := make([]string, 0, len(response.Candidates[0].Content.Parts)) | |||||
| for _, part := range response.Candidates[0].Content.Parts { | |||||
| if text := strings.TrimSpace(part.Text); text != "" { | |||||
| parts = append(parts, text) | |||||
| } | |||||
| } | |||||
| if len(parts) == 0 { | |||||
| return "", fmt.Errorf("google response has no text parts") | |||||
| } | |||||
| return strings.Join(parts, "\n"), nil | |||||
| } | |||||
| func doJSON(ctx context.Context, httpClient *http.Client, method, endpoint, apiKey string, headers map[string]string, payload any) ([]byte, error) { | |||||
| body, err := json.Marshal(payload) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("marshal request: %w", err) | |||||
| } | |||||
| req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body)) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("build request: %w", err) | |||||
| } | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| req.Header.Set("Accept", "application/json") | |||||
| if strings.TrimSpace(apiKey) != "" { | |||||
| req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKey)) | |||||
| req.Header.Set("x-api-key", strings.TrimSpace(apiKey)) | |||||
| } | |||||
| for key, value := range headers { | |||||
| if strings.TrimSpace(key) == "" { | |||||
| continue | |||||
| } | |||||
| req.Header.Set(key, value) | |||||
| } | |||||
| resp, err := httpClient.Do(req) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("do request: %w", err) | |||||
| } | |||||
| defer resp.Body.Close() | |||||
| respBody, err := io.ReadAll(resp.Body) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("read response: %w", err) | |||||
| } | |||||
| if resp.StatusCode >= 400 { | |||||
| message := strings.TrimSpace(string(respBody)) | |||||
| if len(message) > 500 { | |||||
| message = message[:500] | |||||
| } | |||||
| return nil, fmt.Errorf("provider http %d: %s", resp.StatusCode, message) | |||||
| } | |||||
| return respBody, nil | |||||
| } | |||||
| @@ -0,0 +1,228 @@ | |||||
| package mapping | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "fmt" | |||||
| "strings" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/llmruntime" | |||||
| ) | |||||
| type SettingsReader interface { | |||||
| GetSettings(ctx context.Context) (*domain.AppSettings, error) | |||||
| } | |||||
| type ProviderAwareSuggestionGenerator struct { | |||||
| settings SettingsReader | |||||
| runtimeFactory *llmruntime.Factory | |||||
| } | |||||
| func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator { | |||||
| return &ProviderAwareSuggestionGenerator{ | |||||
| settings: settings, | |||||
| runtimeFactory: runtimeFactory, | |||||
| } | |||||
| } | |||||
| func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { | |||||
| if g == nil || g.settings == nil || g.runtimeFactory == nil { | |||||
| return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured") | |||||
| } | |||||
| settings, err := g.settings.GetSettings(ctx) | |||||
| if err != nil || settings == nil { | |||||
| return SuggestionResult{}, fmt.Errorf("llm settings are not available") | |||||
| } | |||||
| provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) | |||||
| model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel) | |||||
| if strings.TrimSpace(model) == "" { | |||||
| return SuggestionResult{}, fmt.Errorf("no active model configured") | |||||
| } | |||||
| apiKey := apiKeyForProvider(provider, *settings) | |||||
| if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" { | |||||
| return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured", provider) | |||||
| } | |||||
| targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled) | |||||
| if len(targets) == 0 { | |||||
| return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil | |||||
| } | |||||
| allowed := make(map[string]SemanticSlotTarget, len(targets)) | |||||
| for _, target := range targets { | |||||
| allowed[target.FieldPath] = target | |||||
| } | |||||
| providerClient, err := g.runtimeFactory.ClientFor(provider) | |||||
| if err != nil { | |||||
| return SuggestionResult{}, err | |||||
| } | |||||
| systemPrompt, userPrompt := buildProviderPrompts(req, targets) | |||||
| raw, err := providerClient.Generate(ctx, llmruntime.Request{ | |||||
| Provider: provider, | |||||
| Model: model, | |||||
| BaseURL: strings.TrimSpace(settings.LLMBaseURL), | |||||
| APIKey: strings.TrimSpace(apiKey), | |||||
| SystemPrompt: systemPrompt, | |||||
| UserPrompt: userPrompt, | |||||
| }) | |||||
| if err != nil { | |||||
| return SuggestionResult{}, err | |||||
| } | |||||
| parsed, err := parseProviderSuggestions(raw) | |||||
| if err != nil { | |||||
| return SuggestionResult{}, err | |||||
| } | |||||
| out := SuggestionResult{ | |||||
| Suggestions: make([]Suggestion, 0, len(parsed)), | |||||
| ByFieldPath: map[string]Suggestion{}, | |||||
| } | |||||
| for _, item := range parsed { | |||||
| fieldPath := strings.TrimSpace(item.FieldPath) | |||||
| target, ok := allowed[fieldPath] | |||||
| if !ok { | |||||
| continue | |||||
| } | |||||
| value := strings.TrimSpace(item.Value) | |||||
| if value == "" { | |||||
| continue | |||||
| } | |||||
| suggestion := Suggestion{ | |||||
| FieldPath: fieldPath, | |||||
| Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot), | |||||
| Value: value, | |||||
| Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"), | |||||
| Source: provider, | |||||
| } | |||||
| if _, exists := out.ByFieldPath[fieldPath]; exists { | |||||
| continue | |||||
| } | |||||
| out.Suggestions = append(out.Suggestions, suggestion) | |||||
| out.ByFieldPath[fieldPath] = suggestion | |||||
| } | |||||
| return out, nil | |||||
| } | |||||
| type providerSuggestion struct { | |||||
| FieldPath string `json:"fieldPath"` | |||||
| Slot string `json:"slot,omitempty"` | |||||
| Value string `json:"value"` | |||||
| Reason string `json:"reason,omitempty"` | |||||
| } | |||||
| func parseProviderSuggestions(raw string) ([]providerSuggestion, error) { | |||||
| content := strings.TrimSpace(raw) | |||||
| if content == "" { | |||||
| return nil, fmt.Errorf("empty provider response") | |||||
| } | |||||
| candidates := []string{content} | |||||
| if fence := extractFencedJSON(content); fence != "" { | |||||
| candidates = append([]string{fence}, candidates...) | |||||
| } | |||||
| if object := extractJSONObject(content); object != "" { | |||||
| candidates = append(candidates, object) | |||||
| } | |||||
| for _, candidate := range candidates { | |||||
| items, ok := parseSuggestionsCandidate(candidate) | |||||
| if ok { | |||||
| return items, nil | |||||
| } | |||||
| } | |||||
| return nil, fmt.Errorf("provider response is not valid suggestions json") | |||||
| } | |||||
| func parseSuggestionsCandidate(raw string) ([]providerSuggestion, bool) { | |||||
| var objectPayload struct { | |||||
| Suggestions []providerSuggestion `json:"suggestions"` | |||||
| } | |||||
| if err := json.Unmarshal([]byte(raw), &objectPayload); err == nil && len(objectPayload.Suggestions) > 0 { | |||||
| return objectPayload.Suggestions, true | |||||
| } | |||||
| var listPayload []providerSuggestion | |||||
| if err := json.Unmarshal([]byte(raw), &listPayload); err == nil && len(listPayload) > 0 { | |||||
| return listPayload, true | |||||
| } | |||||
| return nil, false | |||||
| } | |||||
| func extractFencedJSON(value string) string { | |||||
| const fence = "```" | |||||
| start := strings.Index(value, fence) | |||||
| for start >= 0 { | |||||
| rest := value[start+len(fence):] | |||||
| end := strings.Index(rest, fence) | |||||
| if end < 0 { | |||||
| return "" | |||||
| } | |||||
| block := strings.TrimSpace(rest[:end]) | |||||
| block = strings.TrimPrefix(block, "json") | |||||
| block = strings.TrimPrefix(block, "JSON") | |||||
| block = strings.TrimSpace(block) | |||||
| if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") { | |||||
| return block | |||||
| } | |||||
| nextOffset := start + len(fence) + end + len(fence) | |||||
| nextStart := strings.Index(value[nextOffset:], fence) | |||||
| if nextStart < 0 { | |||||
| break | |||||
| } | |||||
| start = nextOffset + nextStart | |||||
| } | |||||
| return "" | |||||
| } | |||||
| func extractJSONObject(value string) string { | |||||
| start := strings.IndexAny(value, "{[") | |||||
| if start < 0 { | |||||
| return "" | |||||
| } | |||||
| end := strings.LastIndexAny(value, "}]") | |||||
| if end <= start { | |||||
| return "" | |||||
| } | |||||
| return strings.TrimSpace(value[start : end+1]) | |||||
| } | |||||
| func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) { | |||||
| targetPayload := make([]map[string]string, 0, len(targets)) | |||||
| for _, target := range targets { | |||||
| targetPayload = append(targetPayload, map[string]string{ | |||||
| "fieldPath": strings.TrimSpace(target.FieldPath), | |||||
| "slot": strings.TrimSpace(target.Slot), | |||||
| }) | |||||
| } | |||||
| contextPayload := map[string]any{ | |||||
| "globalData": req.GlobalData, | |||||
| "draftContext": llmDraftContextMap(req.DraftContext), | |||||
| "masterPrompt": strings.TrimSpace(req.MasterPrompt), | |||||
| "promptBlocks": enabledPromptBlocks(req.PromptBlocks), | |||||
| "targets": targetPayload, | |||||
| } | |||||
| contextJSON, _ := json.MarshalIndent(contextPayload, "", " ") | |||||
| system := "You generate website text suggestions. Return JSON only. Format: {\"suggestions\":[{\"fieldPath\":\"...\",\"slot\":\"...\",\"value\":\"...\",\"reason\":\"...\"}]}. Use only provided field paths. Keep values concise and in input language." | |||||
| user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON) | |||||
| 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: | |||||
| return "" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,106 @@ | |||||
| package mapping | |||||
| import ( | |||||
| "context" | |||||
| "encoding/json" | |||||
| "net/http" | |||||
| "net/http/httptest" | |||||
| "strings" | |||||
| "testing" | |||||
| "time" | |||||
| "qctextbuilder/internal/domain" | |||||
| "qctextbuilder/internal/llmruntime" | |||||
| ) | |||||
| type stubSettingsReader struct { | |||||
| settings *domain.AppSettings | |||||
| err error | |||||
| } | |||||
| func (s *stubSettingsReader) GetSettings(context.Context) (*domain.AppSettings, error) { | |||||
| if s.err != nil { | |||||
| return nil, s.err | |||||
| } | |||||
| return s.settings, nil | |||||
| } | |||||
| func TestProviderAwareSuggestionGenerator_UsesActiveProviderModelAndKey(t *testing.T) { | |||||
| t.Parallel() | |||||
| var ( | |||||
| gotPath string | |||||
| gotAuth string | |||||
| gotModel string | |||||
| ) | |||||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||||
| gotPath = r.URL.Path | |||||
| gotAuth = r.Header.Get("Authorization") | |||||
| var payload map[string]any | |||||
| _ = json.NewDecoder(r.Body).Decode(&payload) | |||||
| gotModel, _ = payload["model"].(string) | |||||
| _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"suggestions\":[{\"fieldPath\":\"text.textTitle_m1710_1\",\"value\":\"Provider Hero\",\"reason\":\"focused hero\"}]}"}}]}`)) | |||||
| })) | |||||
| defer server.Close() | |||||
| generator := NewProviderAwareSuggestionGenerator(&stubSettingsReader{settings: &domain.AppSettings{ | |||||
| LLMActiveProvider: domain.LLMProviderOpenAI, | |||||
| LLMActiveModel: "gpt-5.4", | |||||
| LLMBaseURL: server.URL, | |||||
| OpenAIAPIKeyEncrypted: "openai-key", | |||||
| }}, llmruntime.NewFactory(5*time.Second)) | |||||
| result, err := generator.Generate(context.Background(), SuggestionRequest{ | |||||
| Fields: []domain.TemplateField{ | |||||
| {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}, | |||||
| }, | |||||
| GlobalData: map[string]any{"companyName": "Muster AG"}, | |||||
| Existing: map[string]string{}, | |||||
| }) | |||||
| if err != nil { | |||||
| t.Fatalf("generate failed: %v", err) | |||||
| } | |||||
| if got := result.ByFieldPath["text.textTitle_m1710_1"].Value; got != "Provider Hero" { | |||||
| t.Fatalf("unexpected value: %q", got) | |||||
| } | |||||
| if got := result.ByFieldPath["text.textTitle_m1710_1"].Source; got != domain.LLMProviderOpenAI { | |||||
| t.Fatalf("unexpected source: %q", got) | |||||
| } | |||||
| if gotPath != "/v1/chat/completions" { | |||||
| t.Fatalf("unexpected path: %s", gotPath) | |||||
| } | |||||
| if gotAuth != "Bearer openai-key" { | |||||
| t.Fatalf("unexpected auth header: %q", gotAuth) | |||||
| } | |||||
| if gotModel != "gpt-5.4" { | |||||
| t.Fatalf("unexpected model: %q", gotModel) | |||||
| } | |||||
| } | |||||
| func TestProviderAwareSuggestionGenerator_RequiresAPIKeyForNonOllama(t *testing.T) { | |||||
| t.Parallel() | |||||
| generator := NewProviderAwareSuggestionGenerator(&stubSettingsReader{settings: &domain.AppSettings{ | |||||
| LLMActiveProvider: domain.LLMProviderAnthropic, | |||||
| LLMActiveModel: "claude-sonnet-4-5", | |||||
| }}, llmruntime.NewFactory(5*time.Second)) | |||||
| _, err := generator.Generate(context.Background(), SuggestionRequest{ | |||||
| Fields: []domain.TemplateField{{Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}}, | |||||
| }) | |||||
| if err == nil || !strings.Contains(err.Error(), "api key") { | |||||
| t.Fatalf("expected api key error, got: %v", err) | |||||
| } | |||||
| } | |||||
| func TestParseProviderSuggestions_AcceptsFencedJSON(t *testing.T) { | |||||
| t.Parallel() | |||||
| items, err := parseProviderSuggestions("```json\n{\"suggestions\":[{\"fieldPath\":\"a\",\"value\":\"b\"}]}\n```") | |||||
| if err != nil { | |||||
| t.Fatalf("parse failed: %v", err) | |||||
| } | |||||
| if len(items) != 1 || items[0].FieldPath != "a" || items[0].Value != "b" { | |||||
| t.Fatalf("unexpected parsed result: %+v", items) | |||||
| } | |||||
| } | |||||
| @@ -26,11 +26,22 @@ func (g *RuleBasedSuggestionGenerator) Generate(_ context.Context, req Suggestio | |||||
| } | } | ||||
| type LLMSuggestionGenerator struct { | type LLMSuggestionGenerator struct { | ||||
| qc qcclient.Client | |||||
| qc qcclient.Client | |||||
| source string | |||||
| } | } | ||||
| func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator { | func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator { | ||||
| return &LLMSuggestionGenerator{qc: qc} | |||||
| return &LLMSuggestionGenerator{ | |||||
| qc: qc, | |||||
| source: domain.DraftSuggestionSourceLLM, | |||||
| } | |||||
| } | |||||
| func NewQCLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator { | |||||
| return &LLMSuggestionGenerator{ | |||||
| qc: qc, | |||||
| source: domain.DraftSuggestionSourceQCLLM, | |||||
| } | |||||
| } | } | ||||
| func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { | func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { | ||||
| @@ -102,7 +113,7 @@ func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionReq | |||||
| Slot: target.Slot, | Slot: target.Slot, | ||||
| Value: value, | Value: value, | ||||
| Reason: "llm suggestion from template content generation", | Reason: "llm suggestion from template content generation", | ||||
| Source: domain.DraftSuggestionSourceLLM, | |||||
| Source: firstNonEmpty(strings.TrimSpace(g.source), domain.DraftSuggestionSourceLLM), | |||||
| } | } | ||||
| out.Suggestions = append(out.Suggestions, suggestion) | out.Suggestions = append(out.Suggestions, suggestion) | ||||
| out.ByFieldPath[target.FieldPath] = suggestion | out.ByFieldPath[target.FieldPath] = suggestion | ||||