| @@ -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 | |||
| @@ -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. | |||
| @@ -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). | |||
| @@ -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) | |||
| @@ -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 { | |||
| @@ -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)) | |||
| } | |||
| @@ -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()}) | |||
| @@ -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 == "" { | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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, | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -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 { | |||
| @@ -0,0 +1 @@ | |||
| ALTER TABLE build_drafts ADD COLUMN suggestion_state_json BLOB; | |||
| @@ -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 | |||
| @@ -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> | |||