| @@ -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. | - 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). | ||||
| - 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. | - 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 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 | ## 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. | - Draft/Review/Build bleibt Kontrollpfad; kein Direkt-Build aus Suggestions. | ||||
| - Persistenz ist Kernbestandteil: Suggestions werden am Draft gespeichert. | - Persistenz ist Kernbestandteil: Suggestions werden am Draft gespeichert. | ||||
| - Kleine, nachvollziehbare Aenderungen in bestehender Architektur (`mapping`/`draftsvc`/`handlers`/`store`). | - 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 | ## 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. | - 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. | ||||
| - 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`. | - 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. | ||||
| @@ -102,11 +103,11 @@ 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 (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 | ### F) Security und Betriebsreife | ||||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | - [ ] 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) | 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) | ||||
| suggestionGenerator := mapping.NewCompositeSuggestionGenerator( | |||||
| mapping.NewLLMSuggestionGenerator(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) | ||||
| @@ -93,7 +97,7 @@ func New(cfg config.Config) (*App, error) { | |||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("init renderer: %w", err) | 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) { | server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | ||||
| r.Get("/", ui.Home) | r.Get("/", ui.Home) | ||||
| @@ -88,7 +88,9 @@ type BuildDraft struct { | |||||
| } | } | ||||
| const ( | const ( | ||||
| DraftSuggestionSourceRuleBased = "rule-based" | |||||
| DraftSuggestionSourceLLM = "llm" | |||||
| DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based" | |||||
| DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased | |||||
| DraftSuggestionStatusSuggested = "suggested" | DraftSuggestionStatusSuggested = "suggested" | ||||
| DraftSuggestionStatusApplied = "applied" | DraftSuggestionStatusApplied = "applied" | ||||
| @@ -26,13 +26,14 @@ import ( | |||||
| ) | ) | ||||
| type UI struct { | 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 { | type htmlRenderer interface { | ||||
| @@ -161,6 +162,7 @@ type buildNewPageData struct { | |||||
| EnabledFields []buildFieldView | EnabledFields []buildFieldView | ||||
| SuggestionStateJSON string | SuggestionStateJSON string | ||||
| AutofillFocusID string | AutofillFocusID string | ||||
| ShowDebug bool | |||||
| Form buildFormInput | Form buildFormInput | ||||
| SemanticSlots []semanticSlotPreviewView | SemanticSlots []semanticSlotPreviewView | ||||
| } | } | ||||
| @@ -208,8 +210,17 @@ type buildDetailPageData struct { | |||||
| AutoRefreshSeconds int | 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) { | 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 | focusFieldPath := targetFieldPath | ||||
| now := time.Now().UTC() | now := time.Now().UTC() | ||||
| req := mapping.SuggestionRequest{ | req := mapping.SuggestionRequest{ | ||||
| TemplateID: templateID, | |||||
| Fields: detail.Fields, | Fields: detail.Fields, | ||||
| GlobalData: globalData, | GlobalData: globalData, | ||||
| DraftContext: draftContext, | DraftContext: draftContext, | ||||
| MasterPrompt: form.MasterPrompt, | |||||
| PromptBlocks: form.PromptBlocks, | |||||
| Existing: fieldValues, | Existing: fieldValues, | ||||
| } | } | ||||
| msg := "autofill ready" | msg := "autofill ready" | ||||
| switch action { | switch action { | ||||
| case "generate_all": | case "generate_all": | ||||
| suggestionState = mapping.GenerateAllSuggestions(req, suggestionState, now) | |||||
| suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now) | |||||
| msg = "suggestions generated" | msg = "suggestions generated" | ||||
| case "regenerate_all": | case "regenerate_all": | ||||
| suggestionState = mapping.RegenerateAllSuggestions(req, suggestionState, now) | |||||
| suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now) | |||||
| msg = "suggestions regenerated" | msg = "suggestions regenerated" | ||||
| case "apply_all": | case "apply_all": | ||||
| fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now) | 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) | fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now) | ||||
| msg = "field suggestion applied" | msg = "field suggestion applied" | ||||
| case "regenerate_field": | 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" | msg = "field suggestion regenerated" | ||||
| default: | default: | ||||
| msg = "unknown autofill action" | msg = "unknown autofill action" | ||||
| @@ -712,6 +726,7 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||||
| SelectedDraftID: selectedDraftID, | SelectedDraftID: selectedDraftID, | ||||
| SelectedTemplateID: selectedTemplateID, | SelectedTemplateID: selectedTemplateID, | ||||
| SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState), | SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState), | ||||
| ShowDebug: parseDebugMode(r), | |||||
| Form: form, | Form: form, | ||||
| } | } | ||||
| if selectedTemplateID <= 0 { | if selectedTemplateID <= 0 { | ||||
| @@ -1417,6 +1432,19 @@ func parseAutofillAction(raw string) (string, string) { | |||||
| return action, strings.TrimSpace(parts[1]) | 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 { | func fieldAnchorID(fieldPath string) string { | ||||
| path := strings.TrimSpace(strings.ToLower(fieldPath)) | path := strings.TrimSpace(strings.ToLower(fieldPath)) | ||||
| if path == "" { | 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 | package mapping | ||||
| import ( | import ( | ||||
| "context" | |||||
| "fmt" | "fmt" | ||||
| "sort" | "sort" | ||||
| "strings" | "strings" | ||||
| @@ -10,9 +11,12 @@ import ( | |||||
| ) | ) | ||||
| type SuggestionRequest struct { | type SuggestionRequest struct { | ||||
| TemplateID int64 | |||||
| Fields []domain.TemplateField | Fields []domain.TemplateField | ||||
| GlobalData map[string]any | GlobalData map[string]any | ||||
| DraftContext *domain.DraftContext | DraftContext *domain.DraftContext | ||||
| MasterPrompt string | |||||
| PromptBlocks []domain.PromptBlockConfig | |||||
| Existing map[string]string | Existing map[string]string | ||||
| IncludeFilled bool | IncludeFilled bool | ||||
| } | } | ||||
| @@ -22,6 +26,7 @@ type Suggestion struct { | |||||
| Slot string `json:"slot,omitempty"` | Slot string `json:"slot,omitempty"` | ||||
| Value string `json:"value"` | Value string `json:"value"` | ||||
| Reason string `json:"reason,omitempty"` | Reason string `json:"reason,omitempty"` | ||||
| Source string `json:"source,omitempty"` | |||||
| } | } | ||||
| type SuggestionResult struct { | type SuggestionResult struct { | ||||
| @@ -30,6 +35,10 @@ type SuggestionResult struct { | |||||
| } | } | ||||
| func SuggestFieldValues(req SuggestionRequest) SuggestionResult { | func SuggestFieldValues(req SuggestionRequest) SuggestionResult { | ||||
| return SuggestFieldValuesRuleBased(req) | |||||
| } | |||||
| func SuggestFieldValuesRuleBased(req SuggestionRequest) SuggestionResult { | |||||
| existing := req.Existing | existing := req.Existing | ||||
| if existing == nil { | if existing == nil { | ||||
| existing = map[string]string{} | existing = map[string]string{} | ||||
| @@ -67,6 +76,7 @@ func SuggestFieldValues(req SuggestionRequest) SuggestionResult { | |||||
| Slot: target.Slot, | Slot: target.Slot, | ||||
| Value: value, | Value: value, | ||||
| Reason: reason, | Reason: reason, | ||||
| Source: domain.DraftSuggestionSourceFallbackRuleBased, | |||||
| } | } | ||||
| out.Suggestions = append(out.Suggestions, suggestion) | out.Suggestions = append(out.Suggestions, suggestion) | ||||
| out.ByFieldPath[target.FieldPath] = suggestion | out.ByFieldPath[target.FieldPath] = suggestion | ||||
| @@ -275,18 +285,24 @@ func testimonialLead(ctx suggestionContext) string { | |||||
| return shortenSentence(ctx.WebsiteSummary, 80) | 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) | next := cloneSuggestionState(current) | ||||
| if next.ByFieldPath == nil { | if next.ByFieldPath == nil { | ||||
| next.ByFieldPath = map[string]domain.DraftSuggestion{} | next.ByFieldPath = map[string]domain.DraftSuggestion{} | ||||
| } | } | ||||
| generated := SuggestFieldValues(SuggestionRequest{ | |||||
| generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ | |||||
| TemplateID: req.TemplateID, | |||||
| Fields: req.Fields, | Fields: req.Fields, | ||||
| GlobalData: req.GlobalData, | GlobalData: req.GlobalData, | ||||
| DraftContext: req.DraftContext, | DraftContext: req.DraftContext, | ||||
| MasterPrompt: req.MasterPrompt, | |||||
| PromptBlocks: req.PromptBlocks, | |||||
| Existing: req.Existing, | Existing: req.Existing, | ||||
| IncludeFilled: true, | IncludeFilled: true, | ||||
| }) | }) | ||||
| if err != nil { | |||||
| return next | |||||
| } | |||||
| for _, s := range generated.Suggestions { | for _, s := range generated.Suggestions { | ||||
| if _, exists := next.ByFieldPath[s.FieldPath]; exists { | if _, exists := next.ByFieldPath[s.FieldPath]; exists { | ||||
| continue | continue | ||||
| @@ -297,16 +313,22 @@ func GenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestio | |||||
| return next | 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 := cloneSuggestionState(current) | ||||
| next.ByFieldPath = map[string]domain.DraftSuggestion{} | next.ByFieldPath = map[string]domain.DraftSuggestion{} | ||||
| generated := SuggestFieldValues(SuggestionRequest{ | |||||
| generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ | |||||
| TemplateID: req.TemplateID, | |||||
| Fields: req.Fields, | Fields: req.Fields, | ||||
| GlobalData: req.GlobalData, | GlobalData: req.GlobalData, | ||||
| DraftContext: req.DraftContext, | DraftContext: req.DraftContext, | ||||
| MasterPrompt: req.MasterPrompt, | |||||
| PromptBlocks: req.PromptBlocks, | |||||
| Existing: req.Existing, | Existing: req.Existing, | ||||
| IncludeFilled: true, | IncludeFilled: true, | ||||
| }) | }) | ||||
| if err != nil { | |||||
| return next | |||||
| } | |||||
| for _, s := range generated.Suggestions { | for _, s := range generated.Suggestions { | ||||
| next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now) | next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now) | ||||
| } | } | ||||
| @@ -314,7 +336,7 @@ func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggest | |||||
| return next | 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) | target := strings.TrimSpace(fieldPath) | ||||
| if target == "" { | if target == "" { | ||||
| return cloneSuggestionState(current) | return cloneSuggestionState(current) | ||||
| @@ -323,13 +345,19 @@ func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSugges | |||||
| if next.ByFieldPath == nil { | if next.ByFieldPath == nil { | ||||
| next.ByFieldPath = map[string]domain.DraftSuggestion{} | next.ByFieldPath = map[string]domain.DraftSuggestion{} | ||||
| } | } | ||||
| generated := SuggestFieldValues(SuggestionRequest{ | |||||
| generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ | |||||
| TemplateID: req.TemplateID, | |||||
| Fields: req.Fields, | Fields: req.Fields, | ||||
| GlobalData: req.GlobalData, | GlobalData: req.GlobalData, | ||||
| DraftContext: req.DraftContext, | DraftContext: req.DraftContext, | ||||
| MasterPrompt: req.MasterPrompt, | |||||
| PromptBlocks: req.PromptBlocks, | |||||
| Existing: req.Existing, | Existing: req.Existing, | ||||
| IncludeFilled: true, | IncludeFilled: true, | ||||
| }) | }) | ||||
| if err != nil { | |||||
| return next | |||||
| } | |||||
| if suggestion, ok := generated.ByFieldPath[target]; ok { | if suggestion, ok := generated.ByFieldPath[target]; ok { | ||||
| next.ByFieldPath[target] = toDraftSuggestion(suggestion, now) | next.ByFieldPath[target] = toDraftSuggestion(suggestion, now) | ||||
| next.UpdatedAt = now.UTC() | next.UpdatedAt = now.UTC() | ||||
| @@ -421,14 +449,25 @@ func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggest | |||||
| func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion { | func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion { | ||||
| ts := now.UTC() | ts := now.UTC() | ||||
| source := strings.TrimSpace(s.Source) | |||||
| if source == "" { | |||||
| source = domain.DraftSuggestionSourceFallbackRuleBased | |||||
| } | |||||
| return domain.DraftSuggestion{ | return domain.DraftSuggestion{ | ||||
| FieldPath: strings.TrimSpace(s.FieldPath), | FieldPath: strings.TrimSpace(s.FieldPath), | ||||
| Slot: strings.TrimSpace(s.Slot), | Slot: strings.TrimSpace(s.Slot), | ||||
| Value: strings.TrimSpace(s.Value), | Value: strings.TrimSpace(s.Value), | ||||
| Reason: strings.TrimSpace(s.Reason), | Reason: strings.TrimSpace(s.Reason), | ||||
| Source: domain.DraftSuggestionSourceRuleBased, | |||||
| Source: source, | |||||
| Status: domain.DraftSuggestionStatusSuggested, | Status: domain.DraftSuggestionStatusSuggested, | ||||
| GeneratedAt: ts, | GeneratedAt: ts, | ||||
| UpdatedAt: 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 | package mapping | ||||
| import ( | import ( | ||||
| "context" | |||||
| "encoding/json" | |||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| "qctextbuilder/internal/domain" | "qctextbuilder/internal/domain" | ||||
| "qctextbuilder/internal/qcclient" | |||||
| ) | ) | ||||
| func TestSuggestFieldValues_FillsEmptyMappedFields(t *testing.T) { | func TestSuggestFieldValues_FillsEmptyMappedFields(t *testing.T) { | ||||
| @@ -73,7 +76,7 @@ func TestGenerateAllSuggestions_IncludesFilledFields(t *testing.T) { | |||||
| fields := []domain.TemplateField{ | fields := []domain.TemplateField{ | ||||
| {Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero}, | {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, | Fields: fields, | ||||
| GlobalData: map[string]any{ | GlobalData: map[string]any{ | ||||
| "companyName": "Muster AG", | "companyName": "Muster AG", | ||||
| @@ -172,7 +175,7 @@ func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) { | |||||
| }, | }, | ||||
| } | } | ||||
| updated := RegenerateFieldSuggestion(SuggestionRequest{ | |||||
| updated := RegenerateFieldSuggestion(context.Background(), nil, SuggestionRequest{ | |||||
| Fields: fields, | Fields: fields, | ||||
| GlobalData: map[string]any{ | GlobalData: map[string]any{ | ||||
| "companyName": "Muster AG", | "companyName": "Muster AG", | ||||
| @@ -186,3 +189,109 @@ func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) { | |||||
| t.Fatalf("expected target field regenerated, got %q", got) | 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> | <h1>New Build</h1> | ||||
| <form method="get" action="/builds/new"> | <form method="get" action="/builds/new"> | ||||
| {{if .ShowDebug}}<input type="hidden" name="debug" value="1">{{end}} | |||||
| <label for="draft_id">Draft laden</label> | <label for="draft_id">Draft laden</label> | ||||
| <select id="draft_id" name="draft_id"> | <select id="draft_id" name="draft_id"> | ||||
| <option value="">Kein Draft</option> | <option value="">Kein Draft</option> | ||||
| @@ -23,6 +24,7 @@ | |||||
| </form> | </form> | ||||
| <form method="get" action="/builds/new"> | <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}} | {{if .SelectedDraftID}}<input type="hidden" name="draft_id" value="{{.SelectedDraftID}}">{{end}} | ||||
| <label for="template_id">Template</label> | <label for="template_id">Template</label> | ||||
| <select id="template_id" name="template_id"> | <select id="template_id" name="template_id"> | ||||
| @@ -142,13 +144,16 @@ | |||||
| </div> | </div> | ||||
| <h2>Template-Felder</h2> | <h2>Template-Felder</h2> | ||||
| <div> | |||||
| <label><input type="checkbox" name="debug" value="1" {{if .ShowDebug}}checked{{end}}> Debug: technische Felddetails anzeigen</label> | |||||
| </div> | |||||
| <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="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="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">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> | <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="apply_all_empty">Apply all suggestions to empty fields (safe)</button> | ||||
| </div> | </div> | ||||
| {{if .SemanticSlots}} | |||||
| {{if and .ShowDebug .SemanticSlots}} | |||||
| <details> | <details> | ||||
| <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | ||||
| <table> | <table> | ||||
| @@ -183,15 +188,16 @@ | |||||
| <tr id="{{.AnchorID}}"> | <tr id="{{.AnchorID}}"> | ||||
| <td> | <td> | ||||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | <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> | ||||
| <td> | <td> | ||||
| <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | ||||
| {{if .SuggestedValue}} | {{if .SuggestedValue}} | ||||
| <div><small>Vorschlag: {{.SuggestedValue}}</small></div> | <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> | <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="apply_field::{{.Path}}">Apply</button> | ||||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | ||||
| @@ -219,15 +225,16 @@ | |||||
| <tr id="{{.AnchorID}}"> | <tr id="{{.AnchorID}}"> | ||||
| <td> | <td> | ||||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | <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> | ||||
| <td> | <td> | ||||
| <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | <textarea name="field_value_{{.Index}}">{{.Value}}</textarea> | ||||
| {{if .SuggestedValue}} | {{if .SuggestedValue}} | ||||
| <div><small>Vorschlag: {{.SuggestedValue}}</small></div> | <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> | <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="apply_field::{{.Path}}">Apply</button> | ||||
| <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | <button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button> | ||||
| @@ -253,7 +260,7 @@ | |||||
| <tbody> | <tbody> | ||||
| {{range .DisabledFields}} | {{range .DisabledFields}} | ||||
| <tr> | <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>Erkannt, deaktiviert (MVP ohne Bildlogik)</td> | ||||
| <td class="mono">{{.SampleValue}}</td> | <td class="mono">{{.SampleValue}}</td> | ||||
| </tr> | </tr> | ||||