| @@ -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 | |||
| @@ -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 | |||
| @@ -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). | |||
| @@ -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) | |||
| @@ -88,7 +88,9 @@ type BuildDraft struct { | |||
| } | |||
| const ( | |||
| DraftSuggestionSourceRuleBased = "rule-based" | |||
| DraftSuggestionSourceLLM = "llm" | |||
| DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based" | |||
| DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased | |||
| DraftSuggestionStatusSuggested = "suggested" | |||
| DraftSuggestionStatusApplied = "applied" | |||
| @@ -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 == "" { | |||
| @@ -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 "" | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -12,6 +12,7 @@ | |||
| <h1>New Build</h1> | |||
| <form method="get" action="/builds/new"> | |||
| {{if .ShowDebug}}<input type="hidden" name="debug" value="1">{{end}} | |||
| <label for="draft_id">Draft laden</label> | |||
| <select id="draft_id" name="draft_id"> | |||
| <option value="">Kein Draft</option> | |||
| @@ -23,6 +24,7 @@ | |||
| </form> | |||
| <form method="get" action="/builds/new"> | |||
| {{if .ShowDebug}}<input type="hidden" name="debug" value="1">{{end}} | |||
| {{if .SelectedDraftID}}<input type="hidden" name="draft_id" value="{{.SelectedDraftID}}">{{end}} | |||
| <label for="template_id">Template</label> | |||
| <select id="template_id" name="template_id"> | |||
| @@ -142,13 +144,16 @@ | |||
| </div> | |||
| <h2>Template-Felder</h2> | |||
| <div> | |||
| <label><input type="checkbox" name="debug" value="1" {{if .ShowDebug}}checked{{end}}> Debug: technische Felddetails anzeigen</label> | |||
| </div> | |||
| <div> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="generate_all">Generate all suggestions</button> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_all">Regenerate all suggestions</button> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="apply_all">Apply all suggestions</button> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="apply_all_empty">Apply all suggestions to empty fields (safe)</button> | |||
| </div> | |||
| {{if .SemanticSlots}} | |||
| {{if and .ShowDebug .SemanticSlots}} | |||
| <details> | |||
| <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | |||
| <table> | |||
| @@ -183,15 +188,16 @@ | |||
| <tr id="{{.AnchorID}}"> | |||
| <td> | |||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | |||
| {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | |||
| {{.DisplayLabel}} | |||
| {{if $.ShowDebug}}<br><span class="mono">{{.Path}}</span>{{end}} | |||
| </td> | |||
| <td> | |||
| <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | |||
| {{if .SuggestedValue}} | |||
| <div><small>Vorschlag: {{.SuggestedValue}}</small></div> | |||
| {{if .SuggestionReason}}<div><small>Grund: {{.SuggestionReason}}</small></div>{{end}} | |||
| {{if .SuggestionStatus}}<div><small>Status: {{.SuggestionStatus}}</small></div>{{end}} | |||
| {{if .SuggestionSource}}<div><small>Quelle: {{.SuggestionSource}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionReason}}<div><small>Grund: {{.SuggestionReason}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionStatus}}<div><small>Status: {{.SuggestionStatus}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionSource}}<div><small>Quelle: {{.SuggestionSource}}</small></div>{{end}} | |||
| <div> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="apply_field::{{.Path}}">Apply</button> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | |||
| @@ -219,15 +225,16 @@ | |||
| <tr id="{{.AnchorID}}"> | |||
| <td> | |||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | |||
| {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | |||
| {{.DisplayLabel}} | |||
| {{if $.ShowDebug}}<br><span class="mono">{{.Path}}</span>{{end}} | |||
| </td> | |||
| <td> | |||
| <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | |||
| {{if .SuggestedValue}} | |||
| <div><small>Vorschlag: {{.SuggestedValue}}</small></div> | |||
| {{if .SuggestionReason}}<div><small>Grund: {{.SuggestionReason}}</small></div>{{end}} | |||
| {{if .SuggestionStatus}}<div><small>Status: {{.SuggestionStatus}}</small></div>{{end}} | |||
| {{if .SuggestionSource}}<div><small>Quelle: {{.SuggestionSource}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionReason}}<div><small>Grund: {{.SuggestionReason}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionStatus}}<div><small>Status: {{.SuggestionStatus}}</small></div>{{end}} | |||
| {{if and $.ShowDebug .SuggestionSource}}<div><small>Quelle: {{.SuggestionSource}}</small></div>{{end}} | |||
| <div> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="apply_field::{{.Path}}">Apply</button> | |||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | |||
| @@ -253,7 +260,7 @@ | |||
| <tbody> | |||
| {{range .DisabledFields}} | |||
| <tr> | |||
| <td>{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span></td> | |||
| <td>{{.DisplayLabel}}{{if $.ShowDebug}}<br><span class="mono">{{.Path}}</span>{{end}}</td> | |||
| <td>Erkannt, deaktiviert (MVP ohne Bildlogik)</td> | |||
| <td class="mono">{{.SampleValue}}</td> | |||
| </tr> | |||