瀏覽代碼

feat: add autofill preview and apply workflow

master
Jan Svabenik 1 月之前
父節點
當前提交
c7ce79398b
共有 15 個文件被更改,包括 1140 次插入118 次删除
  1. +2
    -1
      README.md
  2. +71
    -0
      docs/AUTOFILL_PREVIEW_PLAN.md
  3. +3
    -2
      docs/TARGET_STATE_AND_ROADMAP.md
  4. +1
    -0
      internal/app/app.go
  5. +37
    -12
      internal/domain/models.go
  6. +41
    -21
      internal/draftsvc/service.go
  7. +21
    -19
      internal/httpserver/handlers/handlers.go
  8. +255
    -51
      internal/httpserver/handlers/ui.go
  9. +21
    -0
      internal/httpserver/handlers/ui_grouping_test.go
  10. +434
    -0
      internal/mapping/suggestions.go
  11. +188
    -0
      internal/mapping/suggestions_test.go
  12. +2
    -0
      internal/store/memory/store.go
  13. +1
    -0
      internal/store/sqlite/migrations/005_add_draft_suggestion_state.sql
  14. +10
    -8
      internal/store/sqlite/store.go
  15. +53
    -4
      web/templates/build_new.gohtml

+ 2
- 1
README.md 查看文件

@@ -13,11 +13,12 @@ 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.
- 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; vorbereitet sind Kontextfelder, globale Prompt-Steuerung in Settings und semantische Slot-Mappings als Bruecke zu `fieldValues`.
- 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`.

## Lokaler Start



+ 71
- 0
docs/AUTOFILL_PREVIEW_PLAN.md 查看文件

@@ -0,0 +1,71 @@
# Autofill Preview/Apply/Regenerate Plan

## Ziel
Einen echten, reviewbaren Suggestion-Workflow fuer Draft/Build einfuehren: Vorschlaege separat persistieren, im UI als Preview anzeigen und nur explizit anwenden (global/per-field), inklusive Regenerate (global/per-field) ohne stilles Ueberschreiben von Feldwerten.

## Leitplanken
- 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).

## Umsetzungs-Schritte

1. Domain + Persistenz vorbereiten
- `domain.BuildDraft` um `SuggestionStateJSON` erweitern.
- Neue Domain-Typen fuer Suggestionen einfuehren:
- Suggestion Item (fieldPath, slot, value, reason, source, status, generatedAt, updatedAt)
- Suggestion Status (`suggested`, `applied`, `dismissed` als Basis)
- Suggestion State (Map nach FieldPath plus optionaler Metadatenraum fuer spaeteres Section-Scoping).
- SQLite-Migration `005_*` fuer neue Spalte `suggestion_state_json` in `build_drafts`.
- SQLite + Memory Store (create/update/select/scan/clone) auf neue Spalte aktualisieren.

2. Suggestion-Service in `mapping` erweitern
- Bestehende Suggestion-Generierung so erweitern, dass sie auch fuer bereits befuellte Felder laufen kann (UI-Preview fuer alle Felder moeglich).
- Neue Helper einbauen:
- GenerateAllSuggestions (aus fields + globalData + draftContext + optional bestehendem State)
- RegenerateAllSuggestions
- RegenerateFieldSuggestion
- ApplySuggestionToField
- ApplySuggestionsToEmptyFields
- Stabilitaetsregel: Apply schreibt nur bei explizitem Apply in `fieldValues`; kein implizites Ueberschreiben.

3. Draft-Service fuer Suggestion-State erweitern
- `draftsvc.UpsertDraftRequest` um `SuggestionState` erweitern.
- `SaveDraft` soll Suggestion-State JSON persistieren und bei fehlender Angabe bestehenden State behalten (analog DraftContext).

4. UI-Flow/Handler implementieren
- Neue UI-Action fuer Autofill einbauen (z. B. `POST /builds/drafts/autofill`).
- Handler verarbeitet Aktionen:
- `generate_all`
- `regenerate_all`
- `apply_all_empty`
- `regenerate_field` (mit `target_field_path`)
- `apply_field` (mit `target_field_path`)
- Handler liest aktuelles Form + fieldValues + suggestionState aus Request, berechnet neuen Zustand, speichert Draft (wenn Draft-ID vorhanden) und rendert dieselbe Build-Seite mit aktualisiertem Vorschauzustand.
- Struktur so halten, dass spaeter `target_section` ohne Redesign ergaenzbar ist.

5. UI/Template anpassen
- Feldtextareas zeigen nur echte Feldwerte, nicht mehr auto-eingefuellt aus Suggestion.
- Suggestion separat unterhalb des Feldes anzeigen (Value + Reason + Status).
- Controls ergaenzen:
- Global: Generate all, Regenerate all, Apply all to empty
- Pro Feld: Apply, Regenerate
- Hidden Inputs fuer Suggestion-State + Ziel-Feldpfad/Action integrieren.

6. Tests
- `internal/mapping/suggestions_test.go` erweitern:
- Vorschlaege auch bei befuellten Feldern generierbar
- Apply-all-empty ueberschreibt keine bestehenden Werte
- Field-Regenerate aktualisiert nur Ziel-Feld
- Handler-Tests in `internal/httpserver/handlers` fuer Autofill-Action-Parsing/Anwendungslogik.
- Store-/Draftsvc-Tests dort erweitern, wo Suggestion-JSON durchgereicht wird.

7. Doku aktualisieren
- README: neuen Autofill Preview/Apply/Regenerate Stand kurz beschreiben.
- `docs/TARGET_STATE_AND_ROADMAP.md`: Status in Abschnitt LLM-Assistenz auf teilweise umgesetzt aktualisieren, ohne LLM-Fertigstellung vorzutaeuschen.

## Abgrenzung fuer diesen Schritt
- Keine per-section Buttons in diesem Commit, aber Daten- und Action-Struktur vorbereitet (`target_scope`/`target_field_path`), damit per-section spaeter additiv hinzufuegbar ist.
- Keine Security- oder Auth-Aenderungen.
- Kein Build-Flow-Bypass.

+ 3
- 2
docs/TARGET_STATE_AND_ROADMAP.md 查看文件

@@ -41,6 +41,7 @@ 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.
- 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.

@@ -101,8 +102,8 @@ Statusmarker:
- [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote.

### E) LLM-Assistenz
- [ ] Feldvorschlaege fuer fehlende Inhalte im Draft.
- [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld.
- [-] 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).


+ 1
- 0
internal/app/app.go 查看文件

@@ -106,6 +106,7 @@ func New(cfg config.Config) (*App, error) {
r.Post("/templates/{id}/fields", ui.UpdateTemplateFields)
r.Get("/builds/new", ui.BuildNew)
r.Post("/builds/drafts", ui.SaveDraft)
r.Post("/builds/drafts/autofill", ui.AutofillDraft)
r.Post("/builds", ui.CreateBuild)
r.Get("/builds/{id}", ui.BuildDetail)
r.Post("/builds/{id}/poll", ui.PollBuild)


+ 37
- 12
internal/domain/models.go 查看文件

@@ -72,18 +72,43 @@ type SiteBuild struct {
}

type BuildDraft struct {
ID string `json:"id"`
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalDataJSON json.RawMessage `json:"globalDataJson"`
FieldValuesJSON json.RawMessage `json:"fieldValuesJson"`
DraftContextJSON json.RawMessage `json:"draftContextJson"`
Status string `json:"status"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
TemplateID int64 `json:"templateId"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalDataJSON json.RawMessage `json:"globalDataJson"`
FieldValuesJSON json.RawMessage `json:"fieldValuesJson"`
DraftContextJSON json.RawMessage `json:"draftContextJson"`
SuggestionStateJSON json.RawMessage `json:"suggestionStateJson"`
Status string `json:"status"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

const (
DraftSuggestionSourceRuleBased = "rule-based"

DraftSuggestionStatusSuggested = "suggested"
DraftSuggestionStatusApplied = "applied"
DraftSuggestionStatusDismissed = "dismissed"
)

type DraftSuggestion struct {
FieldPath string `json:"fieldPath"`
Slot string `json:"slot,omitempty"`
Value string `json:"value"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Status string `json:"status,omitempty"`
GeneratedAt time.Time `json:"generatedAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}

type DraftSuggestionState struct {
ByFieldPath map[string]DraftSuggestion `json:"byFieldPath,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}

type DraftStyleProfile struct {


+ 41
- 21
internal/draftsvc/service.go 查看文件

@@ -14,16 +14,17 @@ import (
)

type UpsertDraftRequest struct {
DraftID string `json:"draftId,omitempty"`
TemplateID *int64 `json:"templateId,omitempty"`
ManifestID string `json:"manifestId,omitempty"`
Source string `json:"source,omitempty"`
RequestName string `json:"requestName,omitempty"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
DraftContext *domain.DraftContext `json:"draftContext,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
DraftID string `json:"draftId,omitempty"`
TemplateID *int64 `json:"templateId,omitempty"`
ManifestID string `json:"manifestId,omitempty"`
Source string `json:"source,omitempty"`
RequestName string `json:"requestName,omitempty"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
DraftContext *domain.DraftContext `json:"draftContext,omitempty"`
SuggestionState *domain.DraftSuggestionState `json:"suggestionState,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}

type Service struct {
@@ -90,6 +91,10 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai
if err != nil {
return nil, err
}
suggestionStateJSON, err := buildSuggestionStateJSON(req, existing)
if err != nil {
return nil, err
}

now := time.Now().UTC()
source := defaultString(req.Source, "ui")
@@ -99,17 +104,18 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai
}

draft := domain.BuildDraft{
ID: strings.TrimSpace(req.DraftID),
TemplateID: templateID,
ManifestID: manifestID,
Source: source,
RequestName: strings.TrimSpace(req.RequestName),
GlobalDataJSON: globalDataJSON,
FieldValuesJSON: fieldValuesJSON,
DraftContextJSON: draftContextJSON,
Status: status,
Notes: strings.TrimSpace(req.Notes),
UpdatedAt: now,
ID: strings.TrimSpace(req.DraftID),
TemplateID: templateID,
ManifestID: manifestID,
Source: source,
RequestName: strings.TrimSpace(req.RequestName),
GlobalDataJSON: globalDataJSON,
FieldValuesJSON: fieldValuesJSON,
DraftContextJSON: draftContextJSON,
SuggestionStateJSON: suggestionStateJSON,
Status: status,
Notes: strings.TrimSpace(req.Notes),
UpdatedAt: now,
}
if draft.ID == "" {
draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10)
@@ -145,6 +151,20 @@ func buildDraftContextJSON(req UpsertDraftRequest, existing *domain.BuildDraft)
return raw, nil
}

func buildSuggestionStateJSON(req UpsertDraftRequest, existing *domain.BuildDraft) (json.RawMessage, error) {
if req.SuggestionState == nil {
if existing != nil {
return existing.SuggestionStateJSON, nil
}
return nil, nil
}
raw, err := json.Marshal(req.SuggestionState)
if err != nil {
return nil, errors.New("suggestionState is invalid JSON")
}
return raw, nil
}

func (s *Service) GetDraft(ctx context.Context, draftID string) (*domain.BuildDraft, error) {
return s.drafts.GetDraftByID(ctx, strings.TrimSpace(draftID))
}


+ 21
- 19
internal/httpserver/handlers/handlers.go 查看文件

@@ -165,15 +165,16 @@ func (a *API) StartBuild(w http.ResponseWriter, r *http.Request) {
}

type upsertDraftRequest struct {
TemplateID *int64 `json:"templateId,omitempty"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
DraftContext *domain.DraftContext `json:"draftContext,omitempty"`
Status string `json:"status"`
Notes string `json:"notes"`
TemplateID *int64 `json:"templateId,omitempty"`
ManifestID string `json:"manifestId"`
Source string `json:"source"`
RequestName string `json:"requestName"`
GlobalData map[string]any `json:"globalData"`
FieldValues map[string]string `json:"fieldValues"`
DraftContext *domain.DraftContext `json:"draftContext,omitempty"`
SuggestionState *domain.DraftSuggestionState `json:"suggestionState,omitempty"`
Status string `json:"status"`
Notes string `json:"notes"`
}

type intakeDraftRequest struct {
@@ -298,16 +299,17 @@ func (a *API) UpdateDraft(w http.ResponseWriter, r *http.Request) {
return
}
draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: draftID,
TemplateID: req.TemplateID,
ManifestID: req.ManifestID,
Source: req.Source,
RequestName: req.RequestName,
GlobalData: req.GlobalData,
FieldValues: req.FieldValues,
DraftContext: req.DraftContext,
Status: req.Status,
Notes: req.Notes,
DraftID: draftID,
TemplateID: req.TemplateID,
ManifestID: req.ManifestID,
Source: req.Source,
RequestName: req.RequestName,
GlobalData: req.GlobalData,
FieldValues: req.FieldValues,
DraftContext: req.DraftContext,
SuggestionState: req.SuggestionState,
Status: req.Status,
Notes: req.Notes,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})


+ 255
- 51
internal/httpserver/handlers/ui.go 查看文件

@@ -10,6 +10,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"unicode"

"github.com/go-chi/chi/v5"
@@ -92,11 +93,16 @@ type templateDetailPageData struct {
}

type buildFieldView struct {
Index int
Path string
DisplayLabel string
SampleValue string
Value string
Index int
AnchorID string
Path string
DisplayLabel string
SampleValue string
Value string
SuggestedValue string
SuggestionReason string
SuggestionStatus string
SuggestionSource string
}

type buildFieldGroupView struct {
@@ -145,16 +151,18 @@ var knownBlockAreas = map[string]string{

type buildNewPageData struct {
pageData
Templates []domain.Template
Drafts []domain.BuildDraft
SelectedDraftID string
SelectedTemplateID int64
SelectedManifestID string
FieldSections []buildFieldSectionView
EditableFields []buildFieldView
EnabledFields []buildFieldView
Form buildFormInput
SemanticSlots []semanticSlotPreviewView
Templates []domain.Template
Drafts []domain.BuildDraft
SelectedDraftID string
SelectedTemplateID int64
SelectedManifestID string
FieldSections []buildFieldSectionView
EditableFields []buildFieldView
EnabledFields []buildFieldView
SuggestionStateJSON string
AutofillFocusID string
Form buildFormInput
SemanticSlots []semanticSlotPreviewView
}

type buildFormInput struct {
@@ -362,18 +370,20 @@ func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
PromptBlocks: clonePromptBlocks(settings.PromptBlocks),
}
fieldValues := map[string]string{}
suggestionState := domain.DraftSuggestionState{}
if selectedDraftID != "" {
draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
if err == nil {
selectedTemplateID = draft.TemplateID
form = buildFormInputFromDraft(draft)
fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON)
form.MasterPrompt = settings.MasterPrompt
form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
}
}
form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues)
data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues, suggestionState)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -390,6 +400,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
form := buildFormInputFromRequest(r)
form = u.applyPromptConfigForBuildFlow(r.Context(), form)
fieldValues := parseBuildFieldValues(r)
suggestionState := parseSuggestionStateFromRequest(r)
globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
CompanyName: form.CompanyName,
BusinessType: form.BusinessType,
@@ -427,7 +438,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
Title: "New Build",
Err: err.Error(),
Current: "/builds/new",
}, form.DraftID, templateID, form, fieldValues)
}, form.DraftID, templateID, form, fieldValues, suggestionState)
if loadErr != nil {
http.Error(w, loadErr.Error(), http.StatusBadRequest)
return
@@ -438,16 +449,17 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {

if form.DraftID != "" {
_, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: form.DraftID,
TemplateID: int64Ptr(templateID),
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
DraftContext: buildDraftContextFromForm(form, globalData),
Status: "submitted",
Notes: form.DraftNotes,
DraftID: form.DraftID,
TemplateID: int64Ptr(templateID),
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
DraftContext: buildDraftContextFromForm(form, globalData),
SuggestionState: &suggestionState,
Status: "submitted",
Notes: form.DraftNotes,
})
}

@@ -462,6 +474,7 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
form := buildFormInputFromRequest(r)
form = u.applyPromptConfigForBuildFlow(r.Context(), form)
fieldValues := parseBuildFieldValues(r)
suggestionState := parseSuggestionStateFromRequest(r)
templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
if err != nil || templateID <= 0 {
http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
@@ -487,23 +500,24 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
AddressCountry: form.AddressCountry,
})
draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: form.DraftID,
TemplateID: int64Ptr(templateID),
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
DraftContext: buildDraftContextFromForm(form, globalData),
Status: defaultDraftStatus(form.DraftStatus),
Notes: form.DraftNotes,
DraftID: form.DraftID,
TemplateID: int64Ptr(templateID),
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
DraftContext: buildDraftContextFromForm(form, globalData),
SuggestionState: &suggestionState,
Status: defaultDraftStatus(form.DraftStatus),
Notes: form.DraftNotes,
})
if err != nil {
data, loadErr := u.loadBuildNewPageData(r, pageData{
Title: "New Build",
Err: err.Error(),
Current: "/builds/new",
}, form.DraftID, templateID, form, fieldValues)
}, form.DraftID, templateID, form, fieldValues, suggestionState)
if loadErr != nil {
http.Error(w, loadErr.Error(), http.StatusBadRequest)
return
@@ -514,6 +528,92 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
}

func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
return
}
form := buildFormInputFromRequest(r)
form = u.applyPromptConfigForBuildFlow(r.Context(), form)
fieldValues := parseBuildFieldValues(r)
suggestionState := parseSuggestionStateFromRequest(r)
templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
if err != nil || templateID <= 0 {
http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
return
}

detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
if err != nil || detail.Manifest == nil {
http.Redirect(w, r, "/builds/new?err=template+detail+missing", http.StatusSeeOther)
return
}

globalData := buildGlobalDataFromForm(form)
draftContext := buildDraftContextFromForm(form, globalData)
action, targetFieldPath := parseAutofillAction(strings.TrimSpace(r.FormValue("autofill_action")))
focusFieldPath := targetFieldPath
now := time.Now().UTC()
req := mapping.SuggestionRequest{
Fields: detail.Fields,
GlobalData: globalData,
DraftContext: draftContext,
Existing: fieldValues,
}

msg := "autofill ready"
switch action {
case "generate_all":
suggestionState = mapping.GenerateAllSuggestions(req, suggestionState, now)
msg = "suggestions generated"
case "regenerate_all":
suggestionState = mapping.RegenerateAllSuggestions(req, suggestionState, now)
msg = "suggestions regenerated"
case "apply_all":
fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now)
msg = "all suggestions applied"
case "apply_all_empty":
fieldValues, suggestionState = mapping.ApplySuggestionsToEmptyFields(fieldValues, suggestionState, now)
msg = "suggestions applied to empty fields"
case "apply_field":
fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now)
msg = "field suggestion applied"
case "regenerate_field":
suggestionState = mapping.RegenerateFieldSuggestion(req, suggestionState, targetFieldPath, now)
msg = "field suggestion regenerated"
default:
msg = "unknown autofill action"
}

if strings.TrimSpace(form.DraftID) != "" {
_, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
DraftID: form.DraftID,
TemplateID: int64Ptr(templateID),
ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
Source: form.DraftSource,
RequestName: form.RequestName,
GlobalData: globalData,
FieldValues: fieldValues,
DraftContext: draftContext,
SuggestionState: &suggestionState,
Status: defaultDraftStatus(form.DraftStatus),
Notes: form.DraftNotes,
})
}

data, loadErr := u.loadBuildNewPageData(r, pageData{
Title: "New Build",
Msg: msg,
Current: "/builds/new",
}, form.DraftID, templateID, form, fieldValues, suggestionState)
if loadErr != nil {
http.Error(w, loadErr.Error(), http.StatusBadRequest)
return
}
data.AutofillFocusID = fieldAnchorID(focusFieldPath)
u.render.Render(w, "build_new", data)
}

func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
buildID := strings.TrimSpace(chi.URLParam(r, "id"))
build, err := u.buildSvc.GetBuild(r.Context(), buildID)
@@ -589,7 +689,7 @@ func strPtr(v string) *string {
return &v
}

func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) {
func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) {
if strings.TrimSpace(form.MasterPrompt) == "" {
form.MasterPrompt = domain.SeedMasterPrompt
}
@@ -606,12 +706,13 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI
}

data := buildNewPageData{
pageData: page,
Templates: templates,
Drafts: drafts,
SelectedDraftID: selectedDraftID,
SelectedTemplateID: selectedTemplateID,
Form: form,
pageData: page,
Templates: templates,
Drafts: drafts,
SelectedDraftID: selectedDraftID,
SelectedTemplateID: selectedTemplateID,
SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState),
Form: form,
}
if selectedTemplateID <= 0 {
return data, nil
@@ -622,7 +723,7 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI
return data, nil
}
data.SelectedManifestID = detail.Manifest.ID
data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues)
data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath)
data.EnabledFields = data.EditableFields
data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
return data, nil
@@ -647,7 +748,7 @@ func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormIn
return form
}

func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) {
func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) {
sectionOrder := []string{
domain.WebsiteSectionHero,
domain.WebsiteSectionIntro,
@@ -695,6 +796,7 @@ func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]st
}
media := sectionsByKey[domain.WebsiteSectionGallery]
media.DisabledFields = append(media.DisabledFields, buildFieldView{
AnchorID: fieldAnchorID(f.Path),
Path: f.Path,
DisplayLabel: effectiveLabel(f, labelFallback),
SampleValue: f.SampleValue,
@@ -706,13 +808,19 @@ func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]st
if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
continue
}
suggestion := suggestions[f.Path]
pf := pendingField{
Field: f,
View: buildFieldView{
Path: f.Path,
DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
SampleValue: f.SampleValue,
Value: strings.TrimSpace(fieldValues[f.Path]),
AnchorID: fieldAnchorID(f.Path),
Path: f.Path,
DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
SampleValue: f.SampleValue,
Value: strings.TrimSpace(fieldValues[f.Path]),
SuggestedValue: strings.TrimSpace(suggestion.Value),
SuggestionReason: strings.TrimSpace(suggestion.Reason),
SuggestionStatus: strings.TrimSpace(suggestion.Status),
SuggestionSource: strings.TrimSpace(suggestion.Source),
},
}
pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
@@ -1262,6 +1370,80 @@ func parseBuildFieldValues(r *http.Request) map[string]string {
return fieldValues
}

func parseSuggestionStateFromRequest(r *http.Request) domain.DraftSuggestionState {
return parseSuggestionStateRaw(strings.TrimSpace(r.FormValue("suggestion_state_json")))
}

func parseSuggestionStateJSON(raw []byte) domain.DraftSuggestionState {
return parseSuggestionStateRaw(strings.TrimSpace(string(raw)))
}

func parseSuggestionStateRaw(raw string) domain.DraftSuggestionState {
if strings.TrimSpace(raw) == "" {
return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
}
var state domain.DraftSuggestionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
}
if state.ByFieldPath == nil {
state.ByFieldPath = map[string]domain.DraftSuggestion{}
}
return state
}

func encodeSuggestionStateJSON(state domain.DraftSuggestionState) string {
normalized := state
if normalized.ByFieldPath == nil {
normalized.ByFieldPath = map[string]domain.DraftSuggestion{}
}
raw, err := json.Marshal(normalized)
if err != nil {
return `{"byFieldPath":{}}`
}
return string(raw)
}

func parseAutofillAction(raw string) (string, string) {
value := strings.TrimSpace(raw)
if value == "" {
return "", ""
}
parts := strings.SplitN(value, "::", 2)
action := strings.TrimSpace(parts[0])
if len(parts) == 1 {
return action, ""
}
return action, strings.TrimSpace(parts[1])
}

func fieldAnchorID(fieldPath string) string {
path := strings.TrimSpace(strings.ToLower(fieldPath))
if path == "" {
return ""
}
var b strings.Builder
b.Grow(len(path) + len("field-"))
b.WriteString("field-")
lastDash := false
for _, r := range path {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), "-")
if out == "" || out == "field" {
return "field-anchor"
}
return out
}

func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
if len(raw) == 0 {
return nil, nil
@@ -1340,6 +1522,28 @@ func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks)
}

func buildGlobalDataFromForm(form buildFormInput) map[string]any {
return buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
CompanyName: form.CompanyName,
BusinessType: form.BusinessType,
Username: form.Username,
Email: form.Email,
Phone: form.Phone,
OrgNumber: form.OrgNumber,
StartDate: form.StartDate,
Mission: form.Mission,
DescriptionShort: form.DescriptionShort,
DescriptionLong: form.DescriptionLong,
SiteLanguage: form.SiteLanguage,
AddressLine1: form.AddressLine1,
AddressLine2: form.AddressLine2,
AddressCity: form.AddressCity,
AddressRegion: form.AddressRegion,
AddressZIP: form.AddressZIP,
AddressCountry: form.AddressCountry,
})
}

func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
businessType := strings.TrimSpace(form.BusinessType)
if businessType == "" {


+ 21
- 0
internal/httpserver/handlers/ui_grouping_test.go 查看文件

@@ -115,3 +115,24 @@ func TestPreferredBuildSectionUsesWebsiteSectionFirst(t *testing.T) {
t.Fatalf("preferredBuildSection() = %q, want %q", got, domain.WebsiteSectionCTA)
}
}

func TestParseAutofillAction(t *testing.T) {
t.Parallel()

action, field := parseAutofillAction("apply_field::text.textTitle_m1710_1")
if action != "apply_field" {
t.Fatalf("expected action apply_field, got %q", action)
}
if field != "text.textTitle_m1710_1" {
t.Fatalf("expected field path parsed, got %q", field)
}
}

func TestFieldAnchorID(t *testing.T) {
t.Parallel()

got := fieldAnchorID("text.textTitle_m1710_1")
if got != "field-text-texttitle-m1710-1" {
t.Fatalf("unexpected anchor id: %q", got)
}
}

+ 434
- 0
internal/mapping/suggestions.go 查看文件

@@ -0,0 +1,434 @@
package mapping

import (
"fmt"
"sort"
"strings"
"time"

"qctextbuilder/internal/domain"
)

type SuggestionRequest struct {
Fields []domain.TemplateField
GlobalData map[string]any
DraftContext *domain.DraftContext
Existing map[string]string
IncludeFilled bool
}

type Suggestion struct {
FieldPath string `json:"fieldPath"`
Slot string `json:"slot,omitempty"`
Value string `json:"value"`
Reason string `json:"reason,omitempty"`
}

type SuggestionResult struct {
Suggestions []Suggestion `json:"suggestions"`
ByFieldPath map[string]Suggestion `json:"byFieldPath"`
}

func SuggestFieldValues(req SuggestionRequest) SuggestionResult {
existing := req.Existing
if existing == nil {
existing = map[string]string{}
}
mappingResult := MapTemplateFieldsToSemanticSlots(req.Fields)
ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)

out := SuggestionResult{
Suggestions: make([]Suggestion, 0),
ByFieldPath: map[string]Suggestion{},
}
seen := map[string]struct{}{}

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

for _, target := range targets {
if _, ok := seen[target.FieldPath]; ok {
continue
}
if !req.IncludeFilled && strings.TrimSpace(existing[target.FieldPath]) != "" {
continue
}
value, reason, ok := suggestValueForSlot(target.Slot, ctx)
if !ok || strings.TrimSpace(value) == "" {
continue
}
suggestion := Suggestion{
FieldPath: target.FieldPath,
Slot: target.Slot,
Value: value,
Reason: reason,
}
out.Suggestions = append(out.Suggestions, suggestion)
out.ByFieldPath[target.FieldPath] = suggestion
seen[target.FieldPath] = struct{}{}
}

return out
}

type suggestionContext struct {
CompanyName string
BusinessType string
WebsiteSummary string
LocaleStyle string
MarketStyle string
AddressMode string
ContentTone string
PromptNote string
DescriptionShort string
DescriptionLong string
Mission string
}

func suggestionContextFrom(globalData map[string]any, draftContext *domain.DraftContext) suggestionContext {
ctx := suggestionContext{
CompanyName: getMapString(globalData, "companyName"),
BusinessType: getMapString(globalData, "businessType"),
DescriptionShort: getMapString(globalData, "descriptionShort"),
DescriptionLong: getMapString(globalData, "descriptionLong"),
Mission: getMapString(globalData, "mission"),
}
if draftContext == nil {
return ctx
}
if strings.TrimSpace(ctx.BusinessType) == "" {
ctx.BusinessType = strings.TrimSpace(draftContext.LLM.BusinessType)
}
ctx.WebsiteSummary = strings.TrimSpace(draftContext.LLM.WebsiteSummary)
ctx.LocaleStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.LocaleStyle)
ctx.MarketStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.MarketStyle)
ctx.AddressMode = strings.TrimSpace(draftContext.LLM.StyleProfile.AddressMode)
ctx.ContentTone = strings.TrimSpace(draftContext.LLM.StyleProfile.ContentTone)
ctx.PromptNote = strings.TrimSpace(draftContext.LLM.StyleProfile.PromptInstructions)
return ctx
}

func suggestValueForSlot(slot string, ctx suggestionContext) (string, string, bool) {
company := fallback(ctx.CompanyName, "Ihr Unternehmen")
business := fallback(ctx.BusinessType, "Angebot")
toneAdj := toneAdjective(ctx.ContentTone)
audienceLine := audienceFlavor(ctx)

switch {
case slot == "hero.title":
return strings.TrimSpace(fmt.Sprintf("%s fuer %s mit %s Klarheit", company, business, toneAdj)), "slot-based hero headline", true
case slot == "intro.title":
return strings.TrimSpace(fmt.Sprintf("Was %s fuer Sie einfacher macht", company)), "slot-based intro title", true
case slot == "intro.description":
return firstNonEmpty(
shortenSentence(ctx.WebsiteSummary, 180),
fmt.Sprintf("%s unterstuetzt Kunden mit %s Leistungen, klarer Kommunikation und einem %s Auftritt.", company, business, toneAdj),
), "slot-based intro description", true
case slot == "about.description":
return firstNonEmpty(
shortenSentence(ctx.DescriptionLong, 260),
shortenSentence(ctx.Mission, 220),
fmt.Sprintf("%s steht fuer %s, verlaessliche Zusammenarbeit und einen %s Anspruch in Beratung und Umsetzung.", company, business, toneAdj),
), "slot-based about description", true
case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].title"):
idx := repeatedSlotIndex(slot)
return fmt.Sprintf("%s Leistung %d", titleCaseBusiness(business), idx+1), "slot-based service title", true
case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].description"):
idx := repeatedSlotIndex(slot)
return fmt.Sprintf("Praezise Umsetzung von %s mit Fokus auf Nutzen, Verstaendlichkeit und %s Wirkung%s.", business, toneAdj, audienceLineForIndex(audienceLine, idx)), "slot-based service description", true
case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].name"):
idx := repeatedSlotIndex(slot)
return fmt.Sprintf("Ansprechperson %d", idx+1), "slot-based team placeholder", true
case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].description"):
idx := repeatedSlotIndex(slot)
return fmt.Sprintf("Begleitet Projekte bei %s mit fachlicher Sicherheit, klarer Kommunikation und %s Auftreten.", business, toneAdjForIndex(toneAdj, idx)), "slot-based team description", true
case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].name"):
idx := repeatedSlotIndex(slot)
return fmt.Sprintf("Kundin/Kunde %d", idx+1), "slot-based testimonial placeholder", true
case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].title"):
return firstNonEmpty(
testimonialLead(ctx),
"Vertrauen durch saubere Zusammenarbeit",
), "slot-based testimonial title", true
case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].description"):
return fmt.Sprintf("%s ueberzeugt mit %s Prozessen, klaren Ergebnissen und einer Zusammenarbeit, die angenehm effizient bleibt.", company, toneAdj), "slot-based testimonial description", true
case slot == "cta.text":
if ctx.AddressMode == "du" {
return "Jetzt unverbindlich anfragen", "slot-based cta", true
}
return "Jetzt unverbindlich anfragen", "slot-based cta", true
default:
return "", "", false
}
}

func getMapString(values map[string]any, key string) string {
if values == nil {
return ""
}
raw, _ := values[key].(string)
return strings.TrimSpace(raw)
}

func fallback(value, alt string) string {
if strings.TrimSpace(value) == "" {
return alt
}
return strings.TrimSpace(value)
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

func shortenSentence(value string, max int) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || max <= 0 {
return ""
}
if len([]rune(trimmed)) <= max {
return trimmed
}
runes := []rune(trimmed)
return strings.TrimSpace(string(runes[:max])) + "..."
}

func repeatedSlotIndex(slot string) int {
start := strings.Index(slot, "[")
end := strings.Index(slot, "]")
if start < 0 || end <= start+1 {
return 0
}
value := strings.TrimSpace(slot[start+1 : end])
var idx int
_, _ = fmt.Sscanf(value, "%d", &idx)
if idx < 0 {
return 0
}
return idx
}

func toneAdjective(tone string) string {
switch strings.ToLower(strings.TrimSpace(tone)) {
case "locker":
return "lockerer"
case "modern":
return "moderner"
case "premium":
return "hochwertiger"
case "freundlich":
return "freundlicher"
case "professionell":
return "professioneller"
default:
return "klarer"
}
}

func toneAdjForIndex(adj string, idx int) string {
if idx%2 == 0 {
return adj
}
return adj
}

func audienceFlavor(ctx suggestionContext) string {
parts := make([]string, 0, 2)
if strings.TrimSpace(ctx.LocaleStyle) != "" {
parts = append(parts, ctx.LocaleStyle)
}
if strings.TrimSpace(ctx.MarketStyle) != "" {
parts = append(parts, ctx.MarketStyle)
}
return strings.Join(parts, " / ")
}

func audienceLineForIndex(value string, idx int) string {
if strings.TrimSpace(value) == "" || idx != 0 {
return ""
}
return " fuer " + value
}

func titleCaseBusiness(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "Service"
}
return strings.ToUpper(string([]rune(trimmed)[0])) + string([]rune(trimmed)[1:])
}

func testimonialLead(ctx suggestionContext) string {
if strings.TrimSpace(ctx.WebsiteSummary) == "" {
return ""
}
return shortenSentence(ctx.WebsiteSummary, 80)
}

func GenerateAllSuggestions(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{
Fields: req.Fields,
GlobalData: req.GlobalData,
DraftContext: req.DraftContext,
Existing: req.Existing,
IncludeFilled: true,
})
for _, s := range generated.Suggestions {
if _, exists := next.ByFieldPath[s.FieldPath]; exists {
continue
}
next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
}
next.UpdatedAt = now.UTC()
return next
}

func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState {
next := cloneSuggestionState(current)
next.ByFieldPath = map[string]domain.DraftSuggestion{}
generated := SuggestFieldValues(SuggestionRequest{
Fields: req.Fields,
GlobalData: req.GlobalData,
DraftContext: req.DraftContext,
Existing: req.Existing,
IncludeFilled: true,
})
for _, s := range generated.Suggestions {
next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
}
next.UpdatedAt = now.UTC()
return next
}

func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState {
target := strings.TrimSpace(fieldPath)
if target == "" {
return cloneSuggestionState(current)
}
next := cloneSuggestionState(current)
if next.ByFieldPath == nil {
next.ByFieldPath = map[string]domain.DraftSuggestion{}
}
generated := SuggestFieldValues(SuggestionRequest{
Fields: req.Fields,
GlobalData: req.GlobalData,
DraftContext: req.DraftContext,
Existing: req.Existing,
IncludeFilled: true,
})
if suggestion, ok := generated.ByFieldPath[target]; ok {
next.ByFieldPath[target] = toDraftSuggestion(suggestion, now)
next.UpdatedAt = now.UTC()
}
return next
}

func ApplySuggestionsToEmptyFields(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
values := cloneFieldValues(fieldValues)
next := cloneSuggestionState(state)
if next.ByFieldPath == nil {
next.ByFieldPath = map[string]domain.DraftSuggestion{}
}
for path, suggestion := range next.ByFieldPath {
if strings.TrimSpace(values[path]) != "" {
continue
}
if strings.TrimSpace(suggestion.Value) == "" {
continue
}
values[path] = strings.TrimSpace(suggestion.Value)
suggestion.Status = domain.DraftSuggestionStatusApplied
suggestion.UpdatedAt = now.UTC()
next.ByFieldPath[path] = suggestion
}
next.UpdatedAt = now.UTC()
return values, next
}

func ApplyAllSuggestions(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
values := cloneFieldValues(fieldValues)
next := cloneSuggestionState(state)
if next.ByFieldPath == nil {
next.ByFieldPath = map[string]domain.DraftSuggestion{}
}
for path, suggestion := range next.ByFieldPath {
if strings.TrimSpace(suggestion.Value) == "" {
continue
}
values[path] = strings.TrimSpace(suggestion.Value)
suggestion.Status = domain.DraftSuggestionStatusApplied
suggestion.UpdatedAt = now.UTC()
next.ByFieldPath[path] = suggestion
}
next.UpdatedAt = now.UTC()
return values, next
}

func ApplySuggestionToField(fieldValues map[string]string, state domain.DraftSuggestionState, fieldPath string, now time.Time) (map[string]string, domain.DraftSuggestionState) {
target := strings.TrimSpace(fieldPath)
values := cloneFieldValues(fieldValues)
next := cloneSuggestionState(state)
if target == "" || next.ByFieldPath == nil {
return values, next
}
suggestion, ok := next.ByFieldPath[target]
if !ok || strings.TrimSpace(suggestion.Value) == "" {
return values, next
}
values[target] = strings.TrimSpace(suggestion.Value)
suggestion.Status = domain.DraftSuggestionStatusApplied
suggestion.UpdatedAt = now.UTC()
next.ByFieldPath[target] = suggestion
next.UpdatedAt = now.UTC()
return values, next
}

func cloneFieldValues(values map[string]string) map[string]string {
if values == nil {
return map[string]string{}
}
out := make(map[string]string, len(values))
for k, v := range values {
out[k] = v
}
return out
}

func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggestionState {
out := domain.DraftSuggestionState{
ByFieldPath: map[string]domain.DraftSuggestion{},
UpdatedAt: state.UpdatedAt,
}
for path, suggestion := range state.ByFieldPath {
out.ByFieldPath[path] = suggestion
}
return out
}

func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion {
ts := now.UTC()
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,
Status: domain.DraftSuggestionStatusSuggested,
GeneratedAt: ts,
UpdatedAt: ts,
}
}

+ 188
- 0
internal/mapping/suggestions_test.go 查看文件

@@ -0,0 +1,188 @@
package mapping

import (
"testing"
"time"

"qctextbuilder/internal/domain"
)

func TestSuggestFieldValues_FillsEmptyMappedFields(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
{Path: "services.servicesTitle_r4830_8", KeyName: "servicesTitle_r4830_8", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionServices},
{Path: "services.servicesDescription_r4830_9", KeyName: "servicesDescription_r4830_9", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionServices},
{Path: "text.buttonText_c1165_1", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
}

result := SuggestFieldValues(SuggestionRequest{
Fields: fields,
GlobalData: map[string]any{
"companyName": "Muster AG",
"businessType": "Solar",
},
DraftContext: &domain.DraftContext{
LLM: domain.DraftLLMContext{
WebsiteSummary: "Wir planen und installieren Solaranlagen fuer KMU und Privatkunden.",
StyleProfile: domain.DraftStyleProfile{
ContentTone: "professionell",
},
},
},
Existing: map[string]string{},
})

if _, ok := result.ByFieldPath["text.textTitle_m1710_1"]; !ok {
t.Fatalf("expected hero title suggestion")
}
if got := result.ByFieldPath["text.buttonText_c1165_1"].Value; got == "" {
t.Fatalf("expected cta suggestion")
}
if got := result.ByFieldPath["services.servicesDescription_r4830_9"].Slot; got != "service_items[0].description" {
t.Fatalf("unexpected slot: %q", got)
}
}

func TestSuggestFieldValues_RespectsExistingValues(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
}

result := SuggestFieldValues(SuggestionRequest{
Fields: fields,
GlobalData: map[string]any{
"companyName": "Muster AG",
},
Existing: map[string]string{
"text.textTitle_m1710_1": "Schon gesetzt",
},
})

if len(result.Suggestions) != 0 {
t.Fatalf("expected no suggestions, got %d", len(result.Suggestions))
}
}

func TestGenerateAllSuggestions_IncludesFilledFields(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
}
state := GenerateAllSuggestions(SuggestionRequest{
Fields: fields,
GlobalData: map[string]any{
"companyName": "Muster AG",
},
Existing: map[string]string{
"text.textTitle_m1710_1": "Bereits gesetzt",
},
}, domain.DraftSuggestionState{}, time.Now().UTC())

if _, ok := state.ByFieldPath["text.textTitle_m1710_1"]; !ok {
t.Fatalf("expected suggestion for filled field")
}
}

func TestApplySuggestionsToEmptyFields_DoesNotOverwriteExisting(t *testing.T) {
t.Parallel()

now := time.Now().UTC()
values, state := ApplySuggestionsToEmptyFields(map[string]string{
"field.hero": "Custom",
}, domain.DraftSuggestionState{
ByFieldPath: map[string]domain.DraftSuggestion{
"field.hero": {
FieldPath: "field.hero",
Value: "Suggestion",
Status: domain.DraftSuggestionStatusSuggested,
},
"field.cta": {
FieldPath: "field.cta",
Value: "Jetzt anfragen",
Status: domain.DraftSuggestionStatusSuggested,
},
},
}, now)

if got := values["field.hero"]; got != "Custom" {
t.Fatalf("expected existing value unchanged, got %q", got)
}
if got := values["field.cta"]; got != "Jetzt anfragen" {
t.Fatalf("expected empty value filled, got %q", got)
}
if got := state.ByFieldPath["field.hero"].Status; got != domain.DraftSuggestionStatusSuggested {
t.Fatalf("expected hero status unchanged, got %q", got)
}
if got := state.ByFieldPath["field.cta"].Status; got != domain.DraftSuggestionStatusApplied {
t.Fatalf("expected cta status applied, got %q", got)
}
}

func TestApplyAllSuggestions_OverwritesExisting(t *testing.T) {
t.Parallel()

now := time.Now().UTC()
values, state := ApplyAllSuggestions(map[string]string{
"field.hero": "Custom",
}, domain.DraftSuggestionState{
ByFieldPath: map[string]domain.DraftSuggestion{
"field.hero": {
FieldPath: "field.hero",
Value: "Suggestion",
Status: domain.DraftSuggestionStatusSuggested,
},
"field.cta": {
FieldPath: "field.cta",
Value: "Jetzt anfragen",
Status: domain.DraftSuggestionStatusSuggested,
},
},
}, now)

if got := values["field.hero"]; got != "Suggestion" {
t.Fatalf("expected existing value overwritten, got %q", got)
}
if got := values["field.cta"]; got != "Jetzt anfragen" {
t.Fatalf("expected cta applied, got %q", got)
}
if got := state.ByFieldPath["field.hero"].Status; got != domain.DraftSuggestionStatusApplied {
t.Fatalf("expected hero status applied, got %q", got)
}
if got := state.ByFieldPath["field.cta"].Status; got != domain.DraftSuggestionStatusApplied {
t.Fatalf("expected cta status applied, got %q", got)
}
}

func TestRegenerateFieldSuggestion_OnlyChangesTargetField(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{Path: "text.textTitle_m1710_1", KeyName: "textTitle_m1710_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionHero},
{Path: "text.buttonText_c1165_1", KeyName: "buttonText_c1165_1", FieldKind: "text", IsEnabled: true, WebsiteSection: domain.WebsiteSectionCTA},
}
current := domain.DraftSuggestionState{
ByFieldPath: map[string]domain.DraftSuggestion{
"text.textTitle_m1710_1": {FieldPath: "text.textTitle_m1710_1", Value: "Old Hero"},
"text.buttonText_c1165_1": {FieldPath: "text.buttonText_c1165_1", Value: "Old CTA"},
},
}

updated := RegenerateFieldSuggestion(SuggestionRequest{
Fields: fields,
GlobalData: map[string]any{
"companyName": "Muster AG",
},
}, current, "text.buttonText_c1165_1", time.Now().UTC())

if got := updated.ByFieldPath["text.textTitle_m1710_1"].Value; got != "Old Hero" {
t.Fatalf("expected untargeted field unchanged, got %q", got)
}
if got := updated.ByFieldPath["text.buttonText_c1165_1"].Value; got == "" || got == "Old CTA" {
t.Fatalf("expected target field regenerated, got %q", got)
}
}

+ 2
- 0
internal/store/memory/store.go 查看文件

@@ -289,6 +289,7 @@ func (s *Store) GetDraftByID(_ context.Context, id string) (*domain.BuildDraft,
copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON)
copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON)
copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON)
copy.SuggestionStateJSON = cloneRaw(draft.SuggestionStateJSON)
return &copy, nil
}

@@ -301,6 +302,7 @@ func (s *Store) ListDrafts(_ context.Context, limit int) ([]domain.BuildDraft, e
copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON)
copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON)
copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON)
copy.SuggestionStateJSON = cloneRaw(draft.SuggestionStateJSON)
out = append(out, copy)
}
sort.Slice(out, func(i, j int) bool {


+ 1
- 0
internal/store/sqlite/migrations/005_add_draft_suggestion_state.sql 查看文件

@@ -0,0 +1 @@
ALTER TABLE build_drafts ADD COLUMN suggestion_state_json BLOB;

+ 10
- 8
internal/store/sqlite/store.go 查看文件

@@ -468,10 +468,10 @@ func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error
_, err := s.db.ExecContext(ctx, `
INSERT INTO build_drafts (
id, template_id, manifest_id, source, request_name, global_data_json,
field_values_json, draft_context_json, status, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
draft.ID, nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON),
asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON), draft.Status, draft.Notes, draft.CreatedAt.UTC().Format(time.RFC3339Nano), draft.UpdatedAt.UTC().Format(time.RFC3339Nano),
)
return err
}
@@ -479,10 +479,10 @@ func (s *Store) CreateDraft(ctx context.Context, draft domain.BuildDraft) error
func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error {
res, err := s.db.ExecContext(ctx, `
UPDATE build_drafts
SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?,
SET template_id = ?, manifest_id = ?, source = ?, request_name = ?, global_data_json = ?, field_values_json = ?, draft_context_json = ?, suggestion_state_json = ?,
status = ?, notes = ?, updated_at = ?
WHERE id = ?`,
nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON),
nullableInt64(draft.TemplateID), draft.ManifestID, draft.Source, draft.RequestName, asRaw(draft.GlobalDataJSON), asRaw(draft.FieldValuesJSON), asRaw(draft.DraftContextJSON), asRaw(draft.SuggestionStateJSON),
draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID,
)
if err != nil {
@@ -497,7 +497,7 @@ func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error

func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, status, notes, created_at, updated_at
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
FROM build_drafts
WHERE id = ?`, id)
return scanDraft(row.Scan)
@@ -505,7 +505,7 @@ func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft

func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) {
query := `
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, status, notes, created_at, updated_at
SELECT id, template_id, manifest_id, source, request_name, global_data_json, field_values_json, draft_context_json, suggestion_state_json, status, notes, created_at, updated_at
FROM build_drafts
ORDER BY updated_at DESC`
args := make([]any, 0, 1)
@@ -682,10 +682,11 @@ func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
var globalRaw []byte
var fieldsRaw []byte
var draftContextRaw []byte
var suggestionStateRaw []byte
var createdAtRaw string
var updatedAtRaw string
if err := scan(
&d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
&d.ID, &templateID, &d.ManifestID, &d.Source, &d.RequestName, &globalRaw, &fieldsRaw, &draftContextRaw, &suggestionStateRaw, &d.Status, &d.Notes, &createdAtRaw, &updatedAtRaw,
); err != nil {
if err == sql.ErrNoRows {
return nil, store.ErrNotFound
@@ -698,6 +699,7 @@ func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) {
d.GlobalDataJSON = cloneBytes(globalRaw)
d.FieldValuesJSON = cloneBytes(fieldsRaw)
d.DraftContextJSON = cloneBytes(draftContextRaw)
d.SuggestionStateJSON = cloneBytes(suggestionStateRaw)
d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw)
d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw)
return &d, nil


+ 53
- 4
web/templates/build_new.gohtml 查看文件

@@ -40,6 +40,7 @@
<input type="hidden" name="template_id" value="{{.SelectedTemplateID}}">
<input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}">
<input type="hidden" name="field_count" value="{{len .EditableFields}}">
<input type="hidden" name="suggestion_state_json" value="{{.SuggestionStateJSON}}">

<h2>Global Data</h2>
<div class="grid2">
@@ -141,6 +142,12 @@
</div>

<h2>Template-Felder</h2>
<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}}
<details>
<summary>Technik-Preview: Semantische Zielslots (intern)</summary>
@@ -173,12 +180,28 @@
</thead>
<tbody>
{{range .Fields}}
<tr>
<tr id="{{.AnchorID}}">
<td>
<input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}">
{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span>
</td>
<td><textarea name="field_value_{{.Index}}">{{.Value}}</textarea></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}}
<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>
</div>
{{else}}
<div>
<button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button>
</div>
{{end}}
</td>
<td class="mono">{{.SampleValue}}</td>
</tr>
{{end}}
@@ -193,12 +216,28 @@
</thead>
<tbody>
{{range .EditableFields}}
<tr>
<tr id="{{.AnchorID}}">
<td>
<input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}">
{{.DisplayLabel}}<br><span class="mono">{{.Path}}</span>
</td>
<td><textarea name="field_value_{{.Index}}">{{.Value}}</textarea></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}}
<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>
</div>
{{else}}
<div>
<button type="submit" formaction="/builds/drafts/autofill" name="autofill_action" value="regenerate_field::{{.Path}}">Regenerate</button>
</div>
{{end}}
</td>
<td class="mono">{{.SampleValue}}</td>
</tr>
{{end}}
@@ -231,6 +270,16 @@
<button type="submit" formaction="/builds/drafts">Save Draft</button>
<button type="submit">Start Build</button>
</form>
{{if .AutofillFocusID}}
<script>
window.addEventListener("load", function () {
var target = document.getElementById("{{.AutofillFocusID}}");
if (target) {
target.scrollIntoView({ block: "center" });
}
});
</script>
{{end}}
{{end}}
</body>
</html>


Loading…
取消
儲存