Procházet zdrojové kódy

feat: wire llm-first autofill suggestions

master
Jan Svabenik před 1 měsícem
rodič
revize
d620438deb
10 změnil soubory, kde provedl 552 přidání a 42 odebrání
  1. +4
    -2
      README.md
  2. +1
    -1
      docs/AUTOFILL_PREVIEW_PLAN.md
  3. +7
    -6
      docs/TARGET_STATE_AND_ROADMAP.md
  4. +5
    -1
      internal/app/app.go
  5. +3
    -1
      internal/domain/models.go
  6. +40
    -12
      internal/httpserver/handlers/ui.go
  7. +318
    -0
      internal/mapping/suggestion_generator.go
  8. +46
    -7
      internal/mapping/suggestions.go
  9. +111
    -2
      internal/mapping/suggestions_test.go
  10. +17
    -10
      web/templates/build_new.gohtml

+ 4
- 2
README.md Zobrazit soubor

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



+ 1
- 1
docs/AUTOFILL_PREVIEW_PLAN.md Zobrazit soubor

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



+ 7
- 6
docs/TARGET_STATE_AND_ROADMAP.md Zobrazit soubor

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


+ 5
- 1
internal/app/app.go Zobrazit soubor

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


+ 3
- 1
internal/domain/models.go Zobrazit soubor

@@ -88,7 +88,9 @@ type BuildDraft struct {
}

const (
DraftSuggestionSourceRuleBased = "rule-based"
DraftSuggestionSourceLLM = "llm"
DraftSuggestionSourceFallbackRuleBased = "fallback-rule-based"
DraftSuggestionSourceRuleBased = DraftSuggestionSourceFallbackRuleBased

DraftSuggestionStatusSuggested = "suggested"
DraftSuggestionStatusApplied = "applied"


+ 40
- 12
internal/httpserver/handlers/ui.go Zobrazit soubor

@@ -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 == "" {


+ 318
- 0
internal/mapping/suggestion_generator.go Zobrazit soubor

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

+ 46
- 7
internal/mapping/suggestions.go Zobrazit soubor

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

+ 111
- 2
internal/mapping/suggestions_test.go Zobrazit soubor

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

+ 17
- 10
web/templates/build_new.gohtml Zobrazit soubor

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


Načítá se…
Zrušit
Uložit