diff --git a/README.md b/README.md index e8d269c..fe2a76f 100644 --- a/README.md +++ b/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 diff --git a/docs/AUTOFILL_PREVIEW_PLAN.md b/docs/AUTOFILL_PREVIEW_PLAN.md new file mode 100644 index 0000000..eed83e7 --- /dev/null +++ b/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. diff --git a/docs/TARGET_STATE_AND_ROADMAP.md b/docs/TARGET_STATE_AND_ROADMAP.md index 9b18f7a..6d0361f 100644 --- a/docs/TARGET_STATE_AND_ROADMAP.md +++ b/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). diff --git a/internal/app/app.go b/internal/app/app.go index 6a5be6d..89e25a6 100644 --- a/internal/app/app.go +++ b/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) diff --git a/internal/domain/models.go b/internal/domain/models.go index f015882..a47aab5 100644 --- a/internal/domain/models.go +++ b/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 { diff --git a/internal/draftsvc/service.go b/internal/draftsvc/service.go index 7fccf79..04f2037 100644 --- a/internal/draftsvc/service.go +++ b/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)) } diff --git a/internal/httpserver/handlers/handlers.go b/internal/httpserver/handlers/handlers.go index ce274ed..a2567fb 100644 --- a/internal/httpserver/handlers/handlers.go +++ b/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()}) diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index 4816674..8b67d27 100644 --- a/internal/httpserver/handlers/ui.go +++ b/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 == "" { diff --git a/internal/httpserver/handlers/ui_grouping_test.go b/internal/httpserver/handlers/ui_grouping_test.go index 0f3f3c5..a50b34f 100644 --- a/internal/httpserver/handlers/ui_grouping_test.go +++ b/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) + } +} diff --git a/internal/mapping/suggestions.go b/internal/mapping/suggestions.go new file mode 100644 index 0000000..858c648 --- /dev/null +++ b/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, + } +} diff --git a/internal/mapping/suggestions_test.go b/internal/mapping/suggestions_test.go new file mode 100644 index 0000000..b635a82 --- /dev/null +++ b/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) + } +} diff --git a/internal/store/memory/store.go b/internal/store/memory/store.go index 710b543..df9636e 100644 --- a/internal/store/memory/store.go +++ b/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 ©, 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 { diff --git a/internal/store/sqlite/migrations/005_add_draft_suggestion_state.sql b/internal/store/sqlite/migrations/005_add_draft_suggestion_state.sql new file mode 100644 index 0000000..1a4034d --- /dev/null +++ b/internal/store/sqlite/migrations/005_add_draft_suggestion_state.sql @@ -0,0 +1 @@ +ALTER TABLE build_drafts ADD COLUMN suggestion_state_json BLOB; diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index 5d21115..4a3b67f 100644 --- a/internal/store/sqlite/store.go +++ b/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 diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index 2c2c86e..99626ac 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -40,6 +40,7 @@ +