| @@ -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. | - Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. | ||||
| - Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. | - Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. | ||||
| - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). | - Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). | ||||
| - Rule-based Autofill-Vorschlaege getrennt von Feldwerten verwalten (Preview), inkl. `Generate all`, `Regenerate all`, `Apply all to empty` sowie per-Feld `Apply`/`Regenerate` im Draft-/Build-UI. | |||||
| - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | ||||
| Wichtig: | Wichtig: | ||||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | ||||
| - LLM-Autofill ist noch nicht fertig; 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 | ## 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. | - Prompt-/Systemsteuerung liegt global in Settings; der normale Build-/Review-Flow bleibt auf Inhalte und Feldbearbeitung fokussiert. | ||||
| - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. | - Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. | ||||
| - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. | - Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. | ||||
| - Rule-based Suggestion-State fuer Draft-/Build-UI ist vorhanden: Vorschlaege werden separat von Feldwerten gespeichert und per Generate/Regenerate/Apply (global und per Feld) explizit gesteuert. | |||||
| - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | ||||
| - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | ||||
| @@ -101,8 +102,8 @@ Statusmarker: | |||||
| - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. | - [ ] Monitoring/Fehlerbild fuer Intake-Qualitaet und Nachbearbeitungsquote. | ||||
| ### E) LLM-Assistenz | ### 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). | - [-] 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. | - [-] 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). | - [-] 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.Post("/templates/{id}/fields", ui.UpdateTemplateFields) | ||||
| r.Get("/builds/new", ui.BuildNew) | r.Get("/builds/new", ui.BuildNew) | ||||
| r.Post("/builds/drafts", ui.SaveDraft) | r.Post("/builds/drafts", ui.SaveDraft) | ||||
| r.Post("/builds/drafts/autofill", ui.AutofillDraft) | |||||
| r.Post("/builds", ui.CreateBuild) | r.Post("/builds", ui.CreateBuild) | ||||
| r.Get("/builds/{id}", ui.BuildDetail) | r.Get("/builds/{id}", ui.BuildDetail) | ||||
| r.Post("/builds/{id}/poll", ui.PollBuild) | r.Post("/builds/{id}/poll", ui.PollBuild) | ||||
| @@ -72,18 +72,43 @@ type SiteBuild struct { | |||||
| } | } | ||||
| type BuildDraft 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 { | type DraftStyleProfile struct { | ||||
| @@ -14,16 +14,17 @@ import ( | |||||
| ) | ) | ||||
| type UpsertDraftRequest struct { | 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 { | type Service struct { | ||||
| @@ -90,6 +91,10 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| suggestionStateJSON, err := buildSuggestionStateJSON(req, existing) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| now := time.Now().UTC() | now := time.Now().UTC() | ||||
| source := defaultString(req.Source, "ui") | source := defaultString(req.Source, "ui") | ||||
| @@ -99,17 +104,18 @@ func (s *Service) SaveDraft(ctx context.Context, req UpsertDraftRequest) (*domai | |||||
| } | } | ||||
| draft := domain.BuildDraft{ | 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 == "" { | if draft.ID == "" { | ||||
| draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10) | draft.ID = strconv.FormatInt(time.Now().UnixNano(), 10) | ||||
| @@ -145,6 +151,20 @@ func buildDraftContextJSON(req UpsertDraftRequest, existing *domain.BuildDraft) | |||||
| return raw, nil | 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) { | func (s *Service) GetDraft(ctx context.Context, draftID string) (*domain.BuildDraft, error) { | ||||
| return s.drafts.GetDraftByID(ctx, strings.TrimSpace(draftID)) | 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 { | 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 { | type intakeDraftRequest struct { | ||||
| @@ -298,16 +299,17 @@ func (a *API) UpdateDraft(w http.ResponseWriter, r *http.Request) { | |||||
| return | return | ||||
| } | } | ||||
| draft, err := a.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{ | 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 { | if err != nil { | ||||
| writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) | ||||
| @@ -10,6 +10,7 @@ import ( | |||||
| "sort" | "sort" | ||||
| "strconv" | "strconv" | ||||
| "strings" | "strings" | ||||
| "time" | |||||
| "unicode" | "unicode" | ||||
| "github.com/go-chi/chi/v5" | "github.com/go-chi/chi/v5" | ||||
| @@ -92,11 +93,16 @@ type templateDetailPageData struct { | |||||
| } | } | ||||
| type buildFieldView 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 { | type buildFieldGroupView struct { | ||||
| @@ -145,16 +151,18 @@ var knownBlockAreas = map[string]string{ | |||||
| type buildNewPageData struct { | type buildNewPageData struct { | ||||
| pageData | 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 { | type buildFormInput struct { | ||||
| @@ -362,18 +370,20 @@ func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { | |||||
| PromptBlocks: clonePromptBlocks(settings.PromptBlocks), | PromptBlocks: clonePromptBlocks(settings.PromptBlocks), | ||||
| } | } | ||||
| fieldValues := map[string]string{} | fieldValues := map[string]string{} | ||||
| suggestionState := domain.DraftSuggestionState{} | |||||
| if selectedDraftID != "" { | if selectedDraftID != "" { | ||||
| draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID) | draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID) | ||||
| if err == nil { | if err == nil { | ||||
| selectedTemplateID = draft.TemplateID | selectedTemplateID = draft.TemplateID | ||||
| form = buildFormInputFromDraft(draft) | form = buildFormInputFromDraft(draft) | ||||
| fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON) | fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON) | ||||
| suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON) | |||||
| form.MasterPrompt = settings.MasterPrompt | form.MasterPrompt = settings.MasterPrompt | ||||
| form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks) | form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks) | ||||
| } | } | ||||
| } | } | ||||
| form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) | 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 { | if err != nil { | ||||
| http.Error(w, err.Error(), http.StatusBadRequest) | http.Error(w, err.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| @@ -390,6 +400,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| form := buildFormInputFromRequest(r) | form := buildFormInputFromRequest(r) | ||||
| form = u.applyPromptConfigForBuildFlow(r.Context(), form) | form = u.applyPromptConfigForBuildFlow(r.Context(), form) | ||||
| fieldValues := parseBuildFieldValues(r) | fieldValues := parseBuildFieldValues(r) | ||||
| suggestionState := parseSuggestionStateFromRequest(r) | |||||
| globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ | globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ | ||||
| CompanyName: form.CompanyName, | CompanyName: form.CompanyName, | ||||
| BusinessType: form.BusinessType, | BusinessType: form.BusinessType, | ||||
| @@ -427,7 +438,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| Title: "New Build", | Title: "New Build", | ||||
| Err: err.Error(), | Err: err.Error(), | ||||
| Current: "/builds/new", | Current: "/builds/new", | ||||
| }, form.DraftID, templateID, form, fieldValues) | |||||
| }, form.DraftID, templateID, form, fieldValues, suggestionState) | |||||
| if loadErr != nil { | if loadErr != nil { | ||||
| http.Error(w, loadErr.Error(), http.StatusBadRequest) | http.Error(w, loadErr.Error(), http.StatusBadRequest) | ||||
| return | return | ||||
| @@ -438,16 +449,17 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| if form.DraftID != "" { | if form.DraftID != "" { | ||||
| _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{ | _, _ = 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 := buildFormInputFromRequest(r) | ||||
| form = u.applyPromptConfigForBuildFlow(r.Context(), form) | form = u.applyPromptConfigForBuildFlow(r.Context(), form) | ||||
| fieldValues := parseBuildFieldValues(r) | fieldValues := parseBuildFieldValues(r) | ||||
| suggestionState := parseSuggestionStateFromRequest(r) | |||||
| templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | ||||
| if err != nil || templateID <= 0 { | if err != nil || templateID <= 0 { | ||||
| http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther) | 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, | AddressCountry: form.AddressCountry, | ||||
| }) | }) | ||||
| draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{ | 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 { | if err != nil { | ||||
| data, loadErr := u.loadBuildNewPageData(r, pageData{ | data, loadErr := u.loadBuildNewPageData(r, pageData{ | ||||
| Title: "New Build", | Title: "New Build", | ||||
| Err: err.Error(), | Err: err.Error(), | ||||
| Current: "/builds/new", | Current: "/builds/new", | ||||
| }, form.DraftID, templateID, form, fieldValues) | |||||
| }, form.DraftID, templateID, form, fieldValues, suggestionState) | |||||
| if loadErr != nil { | if loadErr != nil { | ||||
| http.Error(w, loadErr.Error(), http.StatusBadRequest) | http.Error(w, loadErr.Error(), http.StatusBadRequest) | ||||
| return | 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) | 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) { | func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) { | ||||
| buildID := strings.TrimSpace(chi.URLParam(r, "id")) | buildID := strings.TrimSpace(chi.URLParam(r, "id")) | ||||
| build, err := u.buildSvc.GetBuild(r.Context(), buildID) | build, err := u.buildSvc.GetBuild(r.Context(), buildID) | ||||
| @@ -589,7 +689,7 @@ func strPtr(v string) *string { | |||||
| return &v | 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) == "" { | if strings.TrimSpace(form.MasterPrompt) == "" { | ||||
| form.MasterPrompt = domain.SeedMasterPrompt | form.MasterPrompt = domain.SeedMasterPrompt | ||||
| } | } | ||||
| @@ -606,12 +706,13 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||||
| } | } | ||||
| data := buildNewPageData{ | 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 { | if selectedTemplateID <= 0 { | ||||
| return data, nil | return data, nil | ||||
| @@ -622,7 +723,7 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||||
| return data, nil | return data, nil | ||||
| } | } | ||||
| data.SelectedManifestID = detail.Manifest.ID | 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.EnabledFields = data.EditableFields | ||||
| data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields)) | data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields)) | ||||
| return data, nil | return data, nil | ||||
| @@ -647,7 +748,7 @@ func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormIn | |||||
| return form | 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{ | sectionOrder := []string{ | ||||
| domain.WebsiteSectionHero, | domain.WebsiteSectionHero, | ||||
| domain.WebsiteSectionIntro, | domain.WebsiteSectionIntro, | ||||
| @@ -695,6 +796,7 @@ func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]st | |||||
| } | } | ||||
| media := sectionsByKey[domain.WebsiteSectionGallery] | media := sectionsByKey[domain.WebsiteSectionGallery] | ||||
| media.DisabledFields = append(media.DisabledFields, buildFieldView{ | media.DisabledFields = append(media.DisabledFields, buildFieldView{ | ||||
| AnchorID: fieldAnchorID(f.Path), | |||||
| Path: f.Path, | Path: f.Path, | ||||
| DisplayLabel: effectiveLabel(f, labelFallback), | DisplayLabel: effectiveLabel(f, labelFallback), | ||||
| SampleValue: f.SampleValue, | 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") { | if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") { | ||||
| continue | continue | ||||
| } | } | ||||
| suggestion := suggestions[f.Path] | |||||
| pf := pendingField{ | pf := pendingField{ | ||||
| Field: f, | Field: f, | ||||
| View: buildFieldView{ | 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) | pendingByKey[targetSection] = append(pendingByKey[targetSection], pf) | ||||
| @@ -1262,6 +1370,80 @@ func parseBuildFieldValues(r *http.Request) map[string]string { | |||||
| return fieldValues | 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) { | func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) { | ||||
| if len(raw) == 0 { | if len(raw) == 0 { | ||||
| return nil, nil | return nil, nil | ||||
| @@ -1340,6 +1522,28 @@ func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) { | |||||
| form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks) | 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 { | func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext { | ||||
| businessType := strings.TrimSpace(form.BusinessType) | businessType := strings.TrimSpace(form.BusinessType) | ||||
| if businessType == "" { | if businessType == "" { | ||||
| @@ -115,3 +115,24 @@ func TestPreferredBuildSectionUsesWebsiteSectionFirst(t *testing.T) { | |||||
| t.Fatalf("preferredBuildSection() = %q, want %q", got, domain.WebsiteSectionCTA) | 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.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON) | ||||
| copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | ||||
| copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | ||||
| copy.SuggestionStateJSON = cloneRaw(draft.SuggestionStateJSON) | |||||
| return ©, nil | return ©, nil | ||||
| } | } | ||||
| @@ -301,6 +302,7 @@ func (s *Store) ListDrafts(_ context.Context, limit int) ([]domain.BuildDraft, e | |||||
| copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON) | copy.GlobalDataJSON = cloneRaw(draft.GlobalDataJSON) | ||||
| copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | copy.FieldValuesJSON = cloneRaw(draft.FieldValuesJSON) | ||||
| copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | copy.DraftContextJSON = cloneRaw(draft.DraftContextJSON) | ||||
| copy.SuggestionStateJSON = cloneRaw(draft.SuggestionStateJSON) | |||||
| out = append(out, copy) | out = append(out, copy) | ||||
| } | } | ||||
| sort.Slice(out, func(i, j int) bool { | 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, ` | _, err := s.db.ExecContext(ctx, ` | ||||
| INSERT INTO build_drafts ( | INSERT INTO build_drafts ( | ||||
| id, template_id, manifest_id, source, request_name, global_data_json, | 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), | 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 | 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 { | func (s *Store) UpdateDraft(ctx context.Context, draft domain.BuildDraft) error { | ||||
| res, err := s.db.ExecContext(ctx, ` | res, err := s.db.ExecContext(ctx, ` | ||||
| UPDATE build_drafts | 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 = ? | status = ?, notes = ?, updated_at = ? | ||||
| WHERE id = ?`, | 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, | draft.Status, draft.Notes, draft.UpdatedAt.UTC().Format(time.RFC3339Nano), draft.ID, | ||||
| ) | ) | ||||
| if err != nil { | 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) { | func (s *Store) GetDraftByID(ctx context.Context, id string) (*domain.BuildDraft, error) { | ||||
| row := s.db.QueryRowContext(ctx, ` | 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 | FROM build_drafts | ||||
| WHERE id = ?`, id) | WHERE id = ?`, id) | ||||
| return scanDraft(row.Scan) | 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) { | func (s *Store) ListDrafts(ctx context.Context, limit int) ([]domain.BuildDraft, error) { | ||||
| query := ` | 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 | FROM build_drafts | ||||
| ORDER BY updated_at DESC` | ORDER BY updated_at DESC` | ||||
| args := make([]any, 0, 1) | args := make([]any, 0, 1) | ||||
| @@ -682,10 +682,11 @@ func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) { | |||||
| var globalRaw []byte | var globalRaw []byte | ||||
| var fieldsRaw []byte | var fieldsRaw []byte | ||||
| var draftContextRaw []byte | var draftContextRaw []byte | ||||
| var suggestionStateRaw []byte | |||||
| var createdAtRaw string | var createdAtRaw string | ||||
| var updatedAtRaw string | var updatedAtRaw string | ||||
| if err := scan( | 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 { | ); err != nil { | ||||
| if err == sql.ErrNoRows { | if err == sql.ErrNoRows { | ||||
| return nil, store.ErrNotFound | return nil, store.ErrNotFound | ||||
| @@ -698,6 +699,7 @@ func scanDraft(scan func(dest ...any) error) (*domain.BuildDraft, error) { | |||||
| d.GlobalDataJSON = cloneBytes(globalRaw) | d.GlobalDataJSON = cloneBytes(globalRaw) | ||||
| d.FieldValuesJSON = cloneBytes(fieldsRaw) | d.FieldValuesJSON = cloneBytes(fieldsRaw) | ||||
| d.DraftContextJSON = cloneBytes(draftContextRaw) | d.DraftContextJSON = cloneBytes(draftContextRaw) | ||||
| d.SuggestionStateJSON = cloneBytes(suggestionStateRaw) | |||||
| d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw) | d.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtRaw) | ||||
| d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw) | d.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAtRaw) | ||||
| return &d, nil | return &d, nil | ||||
| @@ -40,6 +40,7 @@ | |||||
| <input type="hidden" name="template_id" value="{{.SelectedTemplateID}}"> | <input type="hidden" name="template_id" value="{{.SelectedTemplateID}}"> | ||||
| <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | ||||
| <input type="hidden" name="field_count" value="{{len .EditableFields}}"> | <input type="hidden" name="field_count" value="{{len .EditableFields}}"> | ||||
| <input type="hidden" name="suggestion_state_json" value="{{.SuggestionStateJSON}}"> | |||||
| <h2>Global Data</h2> | <h2>Global Data</h2> | ||||
| <div class="grid2"> | <div class="grid2"> | ||||
| @@ -141,6 +142,12 @@ | |||||
| </div> | </div> | ||||
| <h2>Template-Felder</h2> | <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}} | {{if .SemanticSlots}} | ||||
| <details> | <details> | ||||
| <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | ||||
| @@ -173,12 +180,28 @@ | |||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {{range .Fields}} | {{range .Fields}} | ||||
| <tr> | |||||
| <tr id="{{.AnchorID}}"> | |||||
| <td> | <td> | ||||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | ||||
| {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | ||||
| </td> | </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> | <td class="mono">{{.SampleValue}}</td> | ||||
| </tr> | </tr> | ||||
| {{end}} | {{end}} | ||||
| @@ -193,12 +216,28 @@ | |||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {{range .EditableFields}} | {{range .EditableFields}} | ||||
| <tr> | |||||
| <tr id="{{.AnchorID}}"> | |||||
| <td> | <td> | ||||
| <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | <input type="hidden" name="field_path_{{.Index}}" value="{{.Path}}"> | ||||
| {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | {{.DisplayLabel}}<br><span class="mono">{{.Path}}</span> | ||||
| </td> | </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> | <td class="mono">{{.SampleValue}}</td> | ||||
| </tr> | </tr> | ||||
| {{end}} | {{end}} | ||||
| @@ -231,6 +270,16 @@ | |||||
| <button type="submit" formaction="/builds/drafts">Save Draft</button> | <button type="submit" formaction="/builds/drafts">Save Draft</button> | ||||
| <button type="submit">Start Build</button> | <button type="submit">Start Build</button> | ||||
| </form> | </form> | ||||
| {{if .AutofillFocusID}} | |||||
| <script> | |||||
| window.addEventListener("load", function () { | |||||
| var target = document.getElementById("{{.AutofillFocusID}}"); | |||||
| if (target) { | |||||
| target.scrollIntoView({ block: "center" }); | |||||
| } | |||||
| }); | |||||
| </script> | |||||
| {{end}} | |||||
| {{end}} | {{end}} | ||||
| </body> | </body> | ||||
| </html> | </html> | ||||