diff --git a/README.md b/README.md index fe2a76f..1b5436f 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,14 @@ 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. - 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). -- Rule-based Autofill-Vorschlaege getrennt von Feldwerten verwalten (Preview), inkl. `Generate all`, `Regenerate all`, `Apply all to empty` sowie per-Feld `Apply`/`Regenerate` im Draft-/Build-UI. +- LLM-first Autofill-Vorschlaege (ueber den bestehenden QC-Providerpfad), mit 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. +- 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. Wichtig: - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. -- LLM-Autofill ist noch nicht fertig; aktuell gibt es einen expliziten, rule-based Vorschlags-Workflow (separat gespeichert, manuell anwendbar) sowie vorbereitete Kontextfelder, globale Prompt-Steuerung in Settings und semantische Slot-Mappings als Bruecke zu `fieldValues`. +- LLM-Autofill bleibt Assistenz im Review-Flow: Vorschlaege werden separat gespeichert und manuell angewendet; bei LLM-Ausfall greift deterministischer Rule-based Fallback. ## Lokaler Start diff --git a/docs/AUTOFILL_PREVIEW_PLAN.md b/docs/AUTOFILL_PREVIEW_PLAN.md index eed83e7..9292853 100644 --- a/docs/AUTOFILL_PREVIEW_PLAN.md +++ b/docs/AUTOFILL_PREVIEW_PLAN.md @@ -7,7 +7,7 @@ Einen echten, reviewbaren Suggestion-Workflow fuer Draft/Build einfuehren: Vorsc - Draft/Review/Build bleibt Kontrollpfad; kein Direkt-Build aus Suggestions. - Persistenz ist Kernbestandteil: Suggestions werden am Draft gespeichert. - Kleine, nachvollziehbare Aenderungen in bestehender Architektur (`mapping`/`draftsvc`/`handlers`/`store`). -- Rule-based Suggestion-Engine bleibt (kein externer LLM-Call). +- LLM-first Suggestion-Engine ueber den bestehenden Providerpfad; Rule-based bleibt als Fallback/Testpfad. ## Umsetzungs-Schritte diff --git a/docs/TARGET_STATE_AND_ROADMAP.md b/docs/TARGET_STATE_AND_ROADMAP.md index 6d0361f..e949f0f 100644 --- a/docs/TARGET_STATE_AND_ROADMAP.md +++ b/docs/TARGET_STATE_AND_ROADMAP.md @@ -41,7 +41,8 @@ Aktueller Stand: - 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. - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. -- Rule-based 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. +- 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. +- 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. @@ -102,11 +103,11 @@ Statusmarker: - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. ### E) LLM-Assistenz -- [-] Feldvorschlaege im Draft als expliziter Preview-/Apply-/Regenerate-Workflow (aktuell rule-based, ohne produktiven LLM-Runner). -- [-] Draft-Autofill mit nachvollziehbarer Herkunft je Feld (Suggestion-State mit Quelle/Status vorhanden, per-section Flow noch offen). -- [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder + Auswahlfelder vorhanden, produktive Vorschlagslogik offen). -- [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings als Vorbereitung fuer spaeteren LLM-Runner; Build-Flow ohne prominente Prompt-Interna. -- [-] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer spaeteren LLM-Autofill vorbereitet (inkl. verbesserter Trennung in Repeated-Bereichen). +- [-] 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). +- [-] 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. +- [x] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer LLM-Autofill aktiv genutzt (inkl. verbesserter Trennung in Repeated-Bereichen). ### 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 89e25a6..16615e8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -71,6 +71,10 @@ func New(cfg config.Config) (*App, error) { draftSvc := draftsvc.New(draftStore, templateStore, manifestStore) mappingSvc := mapping.New() buildSvc := buildsvc.New(qc, templateStore, manifestStore, buildStore, mappingSvc, time.Duration(cfg.PollTimeoutSeconds)*time.Second) + suggestionGenerator := mapping.NewCompositeSuggestionGenerator( + mapping.NewLLMSuggestionGenerator(qc), + mapping.NewRuleBasedSuggestionGenerator(), + ) pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc) @@ -93,7 +97,7 @@ func New(cfg config.Config) (*App, error) { if err != nil { return nil, fmt.Errorf("init renderer: %w", err) } - ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, settingsStore, cfg, renderer) + ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, settingsStore, suggestionGenerator, cfg, renderer) server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { r.Get("/", ui.Home) diff --git a/internal/domain/models.go b/internal/domain/models.go index a47aab5..ff7636d 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -88,7 +88,9 @@ type BuildDraft struct { } const ( - DraftSuggestionSourceRuleBased = "rule-based" + DraftSuggestionSourceLLM = "llm" + DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based" + DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased DraftSuggestionStatusSuggested = "suggested" DraftSuggestionStatusApplied = "applied" diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index 8b67d27..427eb91 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -26,13 +26,14 @@ import ( ) type UI struct { - templateSvc *templatesvc.Service - onboardSvc *onboarding.Service - draftSvc *draftsvc.Service - buildSvc buildsvc.Service - settings store.SettingsStore - cfg config.Config - render htmlRenderer + templateSvc *templatesvc.Service + onboardSvc *onboarding.Service + draftSvc *draftsvc.Service + buildSvc buildsvc.Service + settings store.SettingsStore + suggestionGenerator mapping.SuggestionGenerator + cfg config.Config + render htmlRenderer } type htmlRenderer interface { @@ -161,6 +162,7 @@ type buildNewPageData struct { EnabledFields []buildFieldView SuggestionStateJSON string AutofillFocusID string + ShowDebug bool Form buildFormInput SemanticSlots []semanticSlotPreviewView } @@ -208,8 +210,17 @@ type buildDetailPageData struct { AutoRefreshSeconds int } -func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, cfg config.Config, render htmlRenderer) *UI { - return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, settings: settings, cfg: cfg, render: render} +func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, suggestionGenerator mapping.SuggestionGenerator, cfg config.Config, render htmlRenderer) *UI { + return &UI{ + templateSvc: templateSvc, + onboardSvc: onboardSvc, + draftSvc: draftSvc, + buildSvc: buildSvc, + settings: settings, + suggestionGenerator: suggestionGenerator, + cfg: cfg, + render: render, + } } func (u *UI) Home(w http.ResponseWriter, r *http.Request) { @@ -555,19 +566,22 @@ func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) { focusFieldPath := targetFieldPath now := time.Now().UTC() req := mapping.SuggestionRequest{ + TemplateID: templateID, Fields: detail.Fields, GlobalData: globalData, DraftContext: draftContext, + MasterPrompt: form.MasterPrompt, + PromptBlocks: form.PromptBlocks, Existing: fieldValues, } msg := "autofill ready" switch action { case "generate_all": - suggestionState = mapping.GenerateAllSuggestions(req, suggestionState, now) + suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now) msg = "suggestions generated" case "regenerate_all": - suggestionState = mapping.RegenerateAllSuggestions(req, suggestionState, now) + suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now) msg = "suggestions regenerated" case "apply_all": fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now) @@ -579,7 +593,7 @@ func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) { fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now) msg = "field suggestion applied" case "regenerate_field": - suggestionState = mapping.RegenerateFieldSuggestion(req, suggestionState, targetFieldPath, now) + suggestionState = mapping.RegenerateFieldSuggestion(r.Context(), u.suggestionGenerator, req, suggestionState, targetFieldPath, now) msg = "field suggestion regenerated" default: msg = "unknown autofill action" @@ -712,6 +726,7 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI SelectedDraftID: selectedDraftID, SelectedTemplateID: selectedTemplateID, SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState), + ShowDebug: parseDebugMode(r), Form: form, } if selectedTemplateID <= 0 { @@ -1417,6 +1432,19 @@ func parseAutofillAction(raw string) (string, string) { return action, strings.TrimSpace(parts[1]) } +func parseDebugMode(r *http.Request) bool { + if r == nil { + return false + } + value := strings.ToLower(strings.TrimSpace(r.FormValue("debug"))) + switch value { + case "1", "true", "on", "yes": + return true + default: + return false + } +} + func fieldAnchorID(fieldPath string) string { path := strings.TrimSpace(strings.ToLower(fieldPath)) if path == "" { diff --git a/internal/mapping/suggestion_generator.go b/internal/mapping/suggestion_generator.go new file mode 100644 index 0000000..c18b89b --- /dev/null +++ b/internal/mapping/suggestion_generator.go @@ -0,0 +1,318 @@ +package mapping + +import ( + "context" + "fmt" + "sort" + "strings" + + "qctextbuilder/internal/domain" + "qctextbuilder/internal/qcclient" +) + +type SuggestionGenerator interface { + Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) +} + +type RuleBasedSuggestionGenerator struct{} + +func NewRuleBasedSuggestionGenerator() *RuleBasedSuggestionGenerator { + return &RuleBasedSuggestionGenerator{} +} + +func (g *RuleBasedSuggestionGenerator) Generate(_ context.Context, req SuggestionRequest) (SuggestionResult, error) { + result := SuggestFieldValuesRuleBased(req) + return result, nil +} + +type LLMSuggestionGenerator struct { + qc qcclient.Client +} + +func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator { + return &LLMSuggestionGenerator{qc: qc} +} + +func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { + if g == nil || g.qc == nil { + return SuggestionResult{}, fmt.Errorf("llm generator is not configured") + } + if req.TemplateID <= 0 { + return SuggestionResult{}, fmt.Errorf("template id is required for llm suggestions") + } + + fieldByPath := make(map[string]domain.TemplateField, len(req.Fields)) + for _, field := range req.Fields { + if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") { + continue + } + fieldByPath[field.Path] = field + } + + targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled) + customTemplateData := map[string]any{ + "_autofill": map[string]any{ + "masterPrompt": strings.TrimSpace(req.MasterPrompt), + "promptBlocks": enabledPromptBlocks(req.PromptBlocks), + "draftContext": llmDraftContextMap(req.DraftContext), + "semanticSlots": func() map[string]string { + out := make(map[string]string, len(targets)) + for _, target := range targets { + out[target.FieldPath] = target.Slot + } + return out + }(), + }, + } + resp, _, err := g.qc.GenerateContent(ctx, qcclient.GenerateContentRequest{ + TemplateID: req.TemplateID, + GlobalData: req.GlobalData, + Empty: true, + ToneOfVoice: contentTone(req.DraftContext), + TargetAudience: targetAudience(req), + CustomTemplateData: customTemplateData, + }) + if err != nil { + return SuggestionResult{}, err + } + + out := SuggestionResult{ + Suggestions: make([]Suggestion, 0, len(targets)), + ByFieldPath: map[string]Suggestion{}, + } + for _, target := range targets { + field, ok := fieldByPath[target.FieldPath] + if !ok { + continue + } + sectionData := resp[field.Section] + if sectionData == nil { + continue + } + raw, ok := sectionData[field.KeyName] + if !ok { + continue + } + value := normalizeLLMValue(raw) + if value == "" { + continue + } + suggestion := Suggestion{ + FieldPath: target.FieldPath, + Slot: target.Slot, + Value: value, + Reason: "llm suggestion from template content generation", + Source: domain.DraftSuggestionSourceLLM, + } + out.Suggestions = append(out.Suggestions, suggestion) + out.ByFieldPath[target.FieldPath] = suggestion + } + return out, nil +} + +type CompositeSuggestionGenerator struct { + Primary SuggestionGenerator + Fallback SuggestionGenerator +} + +func NewCompositeSuggestionGenerator(primary, fallback SuggestionGenerator) *CompositeSuggestionGenerator { + return &CompositeSuggestionGenerator{ + Primary: primary, + Fallback: fallback, + } +} + +func (g *CompositeSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { + if g == nil { + return SuggestionResult{}, fmt.Errorf("suggestion generator is not configured") + } + if g.Primary == nil { + return generateFallback(ctx, g.Fallback, req) + } + + primaryResult, err := g.Primary.Generate(ctx, req) + if err != nil { + return generateFallback(ctx, g.Fallback, req) + } + primaryResult = normalizeSuggestionResult(primaryResult, req.Fields, req.Existing, req.IncludeFilled) + if g.Fallback == nil { + return primaryResult, nil + } + + fallbackResult, fbErr := g.Fallback.Generate(ctx, req) + if fbErr != nil { + return primaryResult, nil + } + fallbackResult = normalizeSuggestionResult(fallbackResult, req.Fields, req.Existing, req.IncludeFilled) + merged := primaryResult + if merged.ByFieldPath == nil { + merged.ByFieldPath = map[string]Suggestion{} + } + for _, suggestion := range fallbackResult.Suggestions { + if _, exists := merged.ByFieldPath[suggestion.FieldPath]; exists { + continue + } + merged.Suggestions = append(merged.Suggestions, suggestion) + merged.ByFieldPath[suggestion.FieldPath] = suggestion + } + sort.SliceStable(merged.Suggestions, func(i, j int) bool { + return merged.Suggestions[i].FieldPath < merged.Suggestions[j].FieldPath + }) + return merged, nil +} + +func generateFallback(ctx context.Context, fallback SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) { + if fallback == nil { + return SuggestionResult{}, fmt.Errorf("fallback suggestion generator is not configured") + } + result, err := fallback.Generate(ctx, req) + if err != nil { + return SuggestionResult{}, err + } + return normalizeSuggestionResult(result, req.Fields, req.Existing, req.IncludeFilled), nil +} + +func normalizeSuggestionResult(result SuggestionResult, fields []domain.TemplateField, existing map[string]string, includeFilled bool) SuggestionResult { + allowed := make(map[string]SemanticSlotTarget) + for _, target := range collectSuggestionTargets(fields, existing, includeFilled) { + if _, exists := allowed[target.FieldPath]; exists { + continue + } + allowed[target.FieldPath] = target + } + out := SuggestionResult{ + Suggestions: make([]Suggestion, 0, len(result.Suggestions)), + ByFieldPath: map[string]Suggestion{}, + } + for _, suggestion := range result.Suggestions { + fieldPath := strings.TrimSpace(suggestion.FieldPath) + if fieldPath == "" { + continue + } + target, ok := allowed[fieldPath] + if !ok { + continue + } + value := strings.TrimSpace(suggestion.Value) + if value == "" { + continue + } + normalized := suggestion + normalized.FieldPath = fieldPath + if strings.TrimSpace(normalized.Slot) == "" { + normalized.Slot = target.Slot + } + normalized.Value = value + if strings.TrimSpace(normalized.Source) == "" { + normalized.Source = domain.DraftSuggestionSourceFallbackRuleBased + } + if _, exists := out.ByFieldPath[fieldPath]; exists { + continue + } + out.Suggestions = append(out.Suggestions, normalized) + out.ByFieldPath[fieldPath] = normalized + } + sort.SliceStable(out.Suggestions, func(i, j int) bool { + return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath + }) + return out +} + +func collectSuggestionTargets(fields []domain.TemplateField, existing map[string]string, includeFilled bool) []SemanticSlotTarget { + normalizedExisting := existing + if normalizedExisting == nil { + normalizedExisting = map[string]string{} + } + mappingResult := MapTemplateFieldsToSemanticSlots(fields) + targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...) + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].FieldPath == targets[j].FieldPath { + return targets[i].Slot < targets[j].Slot + } + return targets[i].FieldPath < targets[j].FieldPath + }) + + out := make([]SemanticSlotTarget, 0, len(targets)) + seen := map[string]struct{}{} + for _, target := range targets { + if _, exists := seen[target.FieldPath]; exists { + continue + } + if !includeFilled && strings.TrimSpace(normalizedExisting[target.FieldPath]) != "" { + continue + } + out = append(out, target) + seen[target.FieldPath] = struct{}{} + } + return out +} + +func enabledPromptBlocks(blocks []domain.PromptBlockConfig) []map[string]string { + out := make([]map[string]string, 0, len(blocks)) + for _, block := range blocks { + if !block.Enabled { + continue + } + entry := map[string]string{"id": strings.TrimSpace(block.ID)} + if label := strings.TrimSpace(block.Label); label != "" { + entry["label"] = label + } + if instruction := strings.TrimSpace(block.Instruction); instruction != "" { + entry["instruction"] = instruction + } + out = append(out, entry) + } + return out +} + +func llmDraftContextMap(ctx *domain.DraftContext) map[string]any { + if ctx == nil { + return map[string]any{} + } + return map[string]any{ + "businessType": strings.TrimSpace(ctx.LLM.BusinessType), + "websiteUrl": strings.TrimSpace(ctx.LLM.WebsiteURL), + "websiteSummary": strings.TrimSpace(ctx.LLM.WebsiteSummary), + "styleProfile": map[string]string{ + "localeStyle": strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle), + "marketStyle": strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle), + "addressMode": strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode), + "contentTone": strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone), + "promptInstructions": strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions), + }, + } +} + +func contentTone(ctx *domain.DraftContext) string { + if ctx == nil { + return "" + } + return strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone) +} + +func targetAudience(req SuggestionRequest) string { + ctx := suggestionContextFrom(req.GlobalData, req.DraftContext) + parts := make([]string, 0, 4) + if ctx.BusinessType != "" { + parts = append(parts, "businessType="+ctx.BusinessType) + } + if ctx.LocaleStyle != "" { + parts = append(parts, "locale="+ctx.LocaleStyle) + } + if ctx.MarketStyle != "" { + parts = append(parts, "market="+ctx.MarketStyle) + } + if ctx.AddressMode != "" { + parts = append(parts, "addressMode="+ctx.AddressMode) + } + return strings.Join(parts, ", ") +} + +func normalizeLLMValue(raw any) string { + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + return "" + } +} diff --git a/internal/mapping/suggestions.go b/internal/mapping/suggestions.go index 858c648..2f0816a 100644 --- a/internal/mapping/suggestions.go +++ b/internal/mapping/suggestions.go @@ -1,6 +1,7 @@ package mapping import ( + "context" "fmt" "sort" "strings" @@ -10,9 +11,12 @@ import ( ) type SuggestionRequest struct { + TemplateID int64 Fields []domain.TemplateField GlobalData map[string]any DraftContext *domain.DraftContext + MasterPrompt string + PromptBlocks []domain.PromptBlockConfig Existing map[string]string IncludeFilled bool } @@ -22,6 +26,7 @@ type Suggestion struct { Slot string `json:"slot,omitempty"` Value string `json:"value"` Reason string `json:"reason,omitempty"` + Source string `json:"source,omitempty"` } type SuggestionResult struct { @@ -30,6 +35,10 @@ type SuggestionResult struct { } func SuggestFieldValues(req SuggestionRequest) SuggestionResult { + return SuggestFieldValuesRuleBased(req) +} + +func SuggestFieldValuesRuleBased(req SuggestionRequest) SuggestionResult { existing := req.Existing if existing == nil { existing = map[string]string{} @@ -67,6 +76,7 @@ func SuggestFieldValues(req SuggestionRequest) SuggestionResult { Slot: target.Slot, Value: value, Reason: reason, + Source: domain.DraftSuggestionSourceFallbackRuleBased, } out.Suggestions = append(out.Suggestions, suggestion) out.ByFieldPath[target.FieldPath] = suggestion @@ -275,18 +285,24 @@ func testimonialLead(ctx suggestionContext) string { return shortenSentence(ctx.WebsiteSummary, 80) } -func GenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { +func GenerateAllSuggestions(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { next := cloneSuggestionState(current) if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } - generated := SuggestFieldValues(SuggestionRequest{ + generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ + TemplateID: req.TemplateID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, + MasterPrompt: req.MasterPrompt, + PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) + if err != nil { + return next + } for _, s := range generated.Suggestions { if _, exists := next.ByFieldPath[s.FieldPath]; exists { continue @@ -297,16 +313,22 @@ func GenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestio return next } -func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { +func RegenerateAllSuggestions(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { next := cloneSuggestionState(current) next.ByFieldPath = map[string]domain.DraftSuggestion{} - generated := SuggestFieldValues(SuggestionRequest{ + generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ + TemplateID: req.TemplateID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, + MasterPrompt: req.MasterPrompt, + PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) + if err != nil { + return next + } for _, s := range generated.Suggestions { next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now) } @@ -314,7 +336,7 @@ func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggest return next } -func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState { +func RegenerateFieldSuggestion(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState { target := strings.TrimSpace(fieldPath) if target == "" { return cloneSuggestionState(current) @@ -323,13 +345,19 @@ func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSugges if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } - generated := SuggestFieldValues(SuggestionRequest{ + generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ + TemplateID: req.TemplateID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, + MasterPrompt: req.MasterPrompt, + PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) + if err != nil { + return next + } if suggestion, ok := generated.ByFieldPath[target]; ok { next.ByFieldPath[target] = toDraftSuggestion(suggestion, now) next.UpdatedAt = now.UTC() @@ -421,14 +449,25 @@ func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggest func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion { ts := now.UTC() + source := strings.TrimSpace(s.Source) + if source == "" { + source = domain.DraftSuggestionSourceFallbackRuleBased + } return domain.DraftSuggestion{ FieldPath: strings.TrimSpace(s.FieldPath), Slot: strings.TrimSpace(s.Slot), Value: strings.TrimSpace(s.Value), Reason: strings.TrimSpace(s.Reason), - Source: domain.DraftSuggestionSourceRuleBased, + Source: source, Status: domain.DraftSuggestionStatusSuggested, GeneratedAt: ts, UpdatedAt: ts, } } + +func suggestionResultWithFallback(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) { + if generator == nil { + return NewRuleBasedSuggestionGenerator().Generate(ctx, req) + } + return generator.Generate(ctx, req) +} diff --git a/internal/mapping/suggestions_test.go b/internal/mapping/suggestions_test.go index b635a82..8fe28fe 100644 --- a/internal/mapping/suggestions_test.go +++ b/internal/mapping/suggestions_test.go @@ -1,10 +1,13 @@ package mapping import ( + "context" + "encoding/json" "testing" "time" "qctextbuilder/internal/domain" + "qctextbuilder/internal/qcclient" ) func TestSuggestFieldValues_FillsEmptyMappedFields(t *testing.T) { @@ -73,7 +76,7 @@ func TestGenerateAllSuggestions_IncludesFilledFields(t *testing.T) { fields := []domain.TemplateField{ {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}, } - state := GenerateAllSuggestions(SuggestionRequest{ + state := GenerateAllSuggestions(context.Background(), nil, SuggestionRequest{ Fields: fields, GlobalData: map[string]any{ "companyName": "Muster AG", @@ -172,7 +175,7 @@ func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) { }, } - updated := RegenerateFieldSuggestion(SuggestionRequest{ + updated := RegenerateFieldSuggestion(context.Background(), nil, SuggestionRequest{ Fields: fields, GlobalData: map[string]any{ "companyName": "Muster AG", @@ -186,3 +189,109 @@ func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) { t.Fatalf("expected target field regenerated, got %q", got) } } + +func TestGenerateAllSuggestions_UsesGeneratorFallbackWhenPrimaryPartial(t *testing.T) { + t.Parallel() + + fields := []domain.TemplateField{ + {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}, + {Path: "text.buttonText_c1165_1", Section: "text", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA}, + } + generator := NewCompositeSuggestionGenerator( + NewLLMSuggestionGenerator(&stubQCClient{ + generateContent: qcclient.GenerateContentData{ + "text": { + "textTitle_m1710_1": "LLM Hero", + }, + }, + }), + NewRuleBasedSuggestionGenerator(), + ) + + state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{ + TemplateID: 101, + Fields: fields, + GlobalData: map[string]any{"companyName": "Muster AG"}, + Existing: map[string]string{}, + }, domain.DraftSuggestionState{}, time.Now().UTC()) + + hero := state.ByFieldPath["text.textTitle_m1710_1"] + if hero.Source != domain.DraftSuggestionSourceLLM { + t.Fatalf("expected hero source llm, got %q", hero.Source) + } + cta := state.ByFieldPath["text.buttonText_c1165_1"] + if cta.Source != domain.DraftSuggestionSourceFallbackRuleBased { + t.Fatalf("expected cta source fallback rule-based, got %q", cta.Source) + } +} + +func TestGenerateAllSuggestions_FallsBackWhenLLMReturnsInvalidValueType(t *testing.T) { + t.Parallel() + + fields := []domain.TemplateField{ + {Path: "text.textTitle_m1710_1", Section: "text", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}, + } + generator := NewCompositeSuggestionGenerator( + NewLLMSuggestionGenerator(&stubQCClient{ + generateContent: qcclient.GenerateContentData{ + "text": { + "textTitle_m1710_1": true, + }, + }, + }), + NewRuleBasedSuggestionGenerator(), + ) + + state := GenerateAllSuggestions(context.Background(), generator, SuggestionRequest{ + TemplateID: 202, + Fields: fields, + GlobalData: map[string]any{"companyName": "Muster AG"}, + Existing: map[string]string{}, + }, domain.DraftSuggestionState{}, time.Now().UTC()) + + hero := state.ByFieldPath["text.textTitle_m1710_1"] + if hero.Value == "" { + t.Fatalf("expected fallback suggestion value") + } + if hero.Source != domain.DraftSuggestionSourceFallbackRuleBased { + t.Fatalf("expected fallback source, got %q", hero.Source) + } +} + +type stubQCClient struct { + generateContent qcclient.GenerateContentData + generateErr error +} + +func (s *stubQCClient) Health(context.Context) error { return nil } + +func (s *stubQCClient) ListAITemplates(context.Context) ([]qcclient.Template, error) { + return nil, nil +} + +func (s *stubQCClient) GetTemplate(context.Context, int64) (*qcclient.Template, error) { + return nil, nil +} + +func (s *stubQCClient) GetTemplateSchema(context.Context, int64) (json.RawMessage, error) { + return nil, nil +} + +func (s *stubQCClient) GenerateContent(context.Context, qcclient.GenerateContentRequest) (qcclient.GenerateContentData, json.RawMessage, error) { + if s.generateErr != nil { + return nil, nil, s.generateErr + } + return s.generateContent, nil, nil +} + +func (s *stubQCClient) CreateSite(context.Context, qcclient.CreateSiteRequest) (*qcclient.CreateSiteResponseData, json.RawMessage, error) { + return nil, nil, nil +} + +func (s *stubQCClient) GetJob(context.Context, int64) (*qcclient.JobStatusData, json.RawMessage, error) { + return nil, nil, nil +} + +func (s *stubQCClient) GetEditorURL(context.Context, int64) (*qcclient.SiteEditorLoginData, json.RawMessage, error) { + return nil, nil, nil +} diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index 99626ac..13e77c4 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -12,6 +12,7 @@

New Build

+ {{if .ShowDebug}}{{end}} {{end}} {{if .SelectedDraftID}}{{end}} Debug: technische Felddetails anzeigen +
- {{if .SemanticSlots}} + {{if and .ShowDebug .SemanticSlots}}
Technik-Preview: Semantische Zielslots (intern) @@ -183,15 +188,16 @@ {{range .DisabledFields}} - +
- {{.DisplayLabel}}
{{.Path}} + {{.DisplayLabel}} + {{if $.ShowDebug}}
{{.Path}}{{end}}
{{if .SuggestedValue}}
Vorschlag: {{.SuggestedValue}}
- {{if .SuggestionReason}}
Grund: {{.SuggestionReason}}
{{end}} - {{if .SuggestionStatus}}
Status: {{.SuggestionStatus}}
{{end}} - {{if .SuggestionSource}}
Quelle: {{.SuggestionSource}}
{{end}} + {{if and $.ShowDebug .SuggestionReason}}
Grund: {{.SuggestionReason}}
{{end}} + {{if and $.ShowDebug .SuggestionStatus}}
Status: {{.SuggestionStatus}}
{{end}} + {{if and $.ShowDebug .SuggestionSource}}
Quelle: {{.SuggestionSource}}
{{end}}
@@ -219,15 +225,16 @@
- {{.DisplayLabel}}
{{.Path}} + {{.DisplayLabel}} + {{if $.ShowDebug}}
{{.Path}}{{end}}
{{if .SuggestedValue}}
Vorschlag: {{.SuggestedValue}}
- {{if .SuggestionReason}}
Grund: {{.SuggestionReason}}
{{end}} - {{if .SuggestionStatus}}
Status: {{.SuggestionStatus}}
{{end}} - {{if .SuggestionSource}}
Quelle: {{.SuggestionSource}}
{{end}} + {{if and $.ShowDebug .SuggestionReason}}
Grund: {{.SuggestionReason}}
{{end}} + {{if and $.ShowDebug .SuggestionStatus}}
Status: {{.SuggestionStatus}}
{{end}} + {{if and $.ShowDebug .SuggestionSource}}
Quelle: {{.SuggestionSource}}
{{end}}
@@ -253,7 +260,7 @@
{{.DisplayLabel}}
{{.Path}}
{{.DisplayLabel}}{{if $.ShowDebug}}
{{.Path}}{{end}}
Erkannt, deaktiviert (MVP ohne Bildlogik) {{.SampleValue}}