package handlers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "time" "unicode" "github.com/go-chi/chi/v5" "qctextbuilder/internal/buildsvc" "qctextbuilder/internal/config" "qctextbuilder/internal/domain" "qctextbuilder/internal/draftsvc" "qctextbuilder/internal/mapping" "qctextbuilder/internal/onboarding" "qctextbuilder/internal/store" "qctextbuilder/internal/templatesvc" ) type UI struct { templateSvc *templatesvc.Service onboardSvc *onboarding.Service draftSvc *draftsvc.Service buildSvc buildsvc.Service settings store.SettingsStore suggestionGenerator mapping.SuggestionGenerator cfg config.Config render htmlRenderer } type htmlRenderer interface { Render(w http.ResponseWriter, name string, data any) } type pageData struct { Title string Msg string Err string Current string } type homePageData struct { pageData TemplateCount int } type settingsPageData struct { pageData QCBaseURL string PollIntervalSeconds int PollTimeoutSeconds int PollMaxConcurrent int TokenConfigured bool LanguageOutputMode string LLMProviderOptions []domain.LLMProviderOption LLMModelOptions []domain.LLMModelOption LLMActiveProvider string LLMActiveModel string LLMBaseURL string OpenAIKeyConfigured bool AnthropicKeyConfigured bool GoogleKeyConfigured bool XAIKeyConfigured bool OllamaKeyConfigured bool MasterPrompt string PromptBlocks []domain.PromptBlockConfig } type templatesPageData struct { pageData Templates []domain.Template } type templateFieldView struct { Path string FieldKind string IsEnabled bool IsRequiredByUs bool DisplayLabel string DisplayOrder int WebsiteSection string Notes string SampleValue string } type websiteSectionOptionView struct { Value string Label string } type templateDetailPageData struct { pageData Detail *templatesvc.TemplateDetail Fields []templateFieldView WebsiteSectionOptions []websiteSectionOptionView } type buildFieldView struct { Index int AnchorID string Path string DisplayLabel string SampleValue string Value string SuggestedValue string SuggestionReason string SuggestionStatus string SuggestionSource string } type buildFieldGroupView struct { Title string Fields []buildFieldView } type buildFieldSectionView struct { Key string Title string Description string EditableGroups []buildFieldGroupView EditableFields []buildFieldView DisabledFields []buildFieldView } type semanticSlotPreviewView struct { Slot string Count int Examples string } type pendingField struct { Field domain.TemplateField View buildFieldView } type fieldRole struct { Label string Order int } var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`) var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`) var knownBlockAreas = map[string]string{ "m1710": "Hero / Haupttitel", "c7886": "Intro / Einleitung", "r4830": "Services", "m4178": "Gallery / Medien", "c2929": "Ueber uns / About", "r4748": "Team", "r1508": "Testimonials", "c1165": "CTA / Highlight / Banner", } type buildNewPageData struct { pageData Templates []domain.Template Drafts []domain.BuildDraft SelectedDraftID string SelectedTemplateID int64 SelectedManifestID string FieldSections []buildFieldSectionView EditableFields []buildFieldView EnabledFields []buildFieldView SuggestionStateJSON string AutofillFocusID string ShowDebug bool Form buildFormInput SemanticSlots []semanticSlotPreviewView } type buildFormInput struct { DraftID string DraftSource string DraftStatus string DraftNotes string RequestName string CompanyName string BusinessType string Username string Email string Phone string OrgNumber string StartDate string Mission string DescriptionShort string DescriptionLong string SiteLanguage string AddressLine1 string AddressLine2 string AddressCity string AddressRegion string AddressZIP string AddressCountry string WebsiteURL string WebsiteSummary string LocaleStyle string MarketStyle string AddressMode string ContentTone string PromptInstructions string MasterPrompt string PromptBlocks []domain.PromptBlockConfig } type buildDetailPageData struct { pageData Build *domain.SiteBuild EffectiveGlobal []byte CanPoll bool CanFetchEditorURL bool AutoRefreshSeconds int } func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, suggestionGenerator mapping.SuggestionGenerator, cfg config.Config, render htmlRenderer) *UI { return &UI{ templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, settings: settings, suggestionGenerator: suggestionGenerator, cfg: cfg, render: render, } } func (u *UI) Home(w http.ResponseWriter, r *http.Request) { templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0}) return } u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)}) } func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { settings := u.loadPromptSettings(r.Context()) activeProvider := domain.NormalizeLLMProvider(settings.LLMActiveProvider) modelOptions := domain.LLMModelsByProvider(activeProvider) u.render.Render(w, "settings", settingsPageData{ pageData: basePageData(r, "Settings", "/settings"), QCBaseURL: u.cfg.QCBaseURL, PollIntervalSeconds: u.cfg.PollIntervalSeconds, PollTimeoutSeconds: u.cfg.PollTimeoutSeconds, PollMaxConcurrent: u.cfg.PollMaxConcurrent, TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", LanguageOutputMode: "EN", LLMProviderOptions: domain.LLMProviderOptions(), LLMModelOptions: modelOptions, LLMActiveProvider: activeProvider, LLMActiveModel: domain.NormalizeLLMModel(activeProvider, settings.LLMActiveModel), LLMBaseURL: strings.TrimSpace(settings.LLMBaseURL), OpenAIKeyConfigured: strings.TrimSpace(settings.OpenAIAPIKeyEncrypted) != "", AnthropicKeyConfigured: strings.TrimSpace(settings.AnthropicAPIKeyEncrypted) != "", GoogleKeyConfigured: strings.TrimSpace(settings.GoogleAPIKeyEncrypted) != "", XAIKeyConfigured: strings.TrimSpace(settings.XAIAPIKeyEncrypted) != "", OllamaKeyConfigured: strings.TrimSpace(settings.OllamaAPIKeyEncrypted) != "", MasterPrompt: settings.MasterPrompt, PromptBlocks: settings.PromptBlocks, }) } func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) return } settings := u.loadPromptSettings(r.Context()) settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt")) settings.PromptBlocks = parsePromptBlocksFromRequest(r) if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther) } func (u *UI) SaveLLMSettings(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) return } settings := u.loadPromptSettings(r.Context()) settings.LLMActiveProvider = domain.NormalizeLLMProvider(r.FormValue("llm_provider")) settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, r.FormValue("llm_model")) settings.LLMBaseURL = strings.TrimSpace(r.FormValue("llm_base_url")) if value := strings.TrimSpace(r.FormValue("llm_api_key_openai")); value != "" { settings.OpenAIAPIKeyEncrypted = value } if value := strings.TrimSpace(r.FormValue("llm_api_key_anthropic")); value != "" { settings.AnthropicAPIKeyEncrypted = value } if value := strings.TrimSpace(r.FormValue("llm_api_key_google")); value != "" { settings.GoogleAPIKeyEncrypted = value } if value := strings.TrimSpace(r.FormValue("llm_api_key_xai")); value != "" { settings.XAIAPIKeyEncrypted = value } if value := strings.TrimSpace(r.FormValue("llm_api_key_ollama")); value != "" { settings.OllamaAPIKeyEncrypted = value } if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/settings?msg=llm+settings+saved", http.StatusSeeOther) } func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates}) } func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) { if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil { http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther) return } http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther) } func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } fields := make([]templateFieldView, 0, len(detail.Fields)) for _, f := range detail.Fields { fields = append(fields, templateFieldView{ Path: f.Path, FieldKind: f.FieldKind, IsEnabled: f.IsEnabled, IsRequiredByUs: f.IsRequiredByUs, DisplayLabel: f.DisplayLabel, DisplayOrder: f.DisplayOrder, WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection), Notes: f.Notes, SampleValue: f.SampleValue, }) } u.render.Render(w, "template_detail", templateDetailPageData{ pageData: basePageData(r, "Template Detail", "/templates"), Detail: detail, Fields: fields, WebsiteSectionOptions: websiteSectionOptions(), }) } func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther) } func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { templateID, ok := parseTemplateID(w, r) if !ok { return } if err := r.ParseForm(); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther) return } count, _ := strconv.Atoi(r.FormValue("field_count")) patches := make([]onboarding.FieldPatch, 0, count) for i := 0; i < count; i++ { path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) if path == "" { continue } enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on" required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on" label := r.FormValue(fmt.Sprintf("field_label_%d", i)) notes := r.FormValue(fmt.Sprintf("field_notes_%d", i)) websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i)) order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i)))) if err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther) return } patches = append(patches, onboarding.FieldPatch{ Path: path, IsEnabled: boolPtr(enabled), IsRequiredByUs: boolPtr(required), DisplayLabel: strPtr(label), DisplayOrder: intPtr(order), WebsiteSection: strPtr(websiteSection), Notes: strPtr(notes), }) } manifestID := r.FormValue("manifest_id") if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil { http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther) } func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { settings := u.loadPromptSettings(r.Context()) selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64) selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id")) form := buildFormInput{ DraftID: selectedDraftID, DraftSource: "ui", DraftStatus: "draft", MasterPrompt: settings.MasterPrompt, 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, suggestionState) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) } func (u *UI) CreateBuild(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) globalData := 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, }) 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 } result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{ TemplateID: templateID, RequestName: form.RequestName, GlobalData: globalData, FieldValues: fieldValues, }) if err != nil { data, loadErr := u.loadBuildNewPageData(r, pageData{ Title: "New Build", Err: err.Error(), Current: "/builds/new", }, form.DraftID, templateID, form, fieldValues, suggestionState) if loadErr != nil { http.Error(w, loadErr.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) return } 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), SuggestionState: &suggestionState, Status: "submitted", Notes: form.DraftNotes, }) } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther) } func (u *UI) SaveDraft(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 } globalData := 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, }) 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), 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, suggestionState) if loadErr != nil { http.Error(w, loadErr.Error(), http.StatusBadRequest) return } u.render.Render(w, "build_new", data) return } 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{ TemplateID: templateID, Fields: detail.Fields, GlobalData: globalData, DraftContext: draftContext, MasterPrompt: form.MasterPrompt, PromptBlocks: form.PromptBlocks, Existing: fieldValues, } msg := "autofill ready" switch action { case "generate_all": suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now) msg = "suggestions generated" case "regenerate_all": suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, 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(r.Context(), u.suggestionGenerator, 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) if err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } status := strings.ToLower(strings.TrimSpace(build.QCStatus)) canPoll := status == "queued" || status == "processing" canFetchEditor := (status == "done" || status == "failed" || status == "timeout") && build.QCSiteID != nil && strings.TrimSpace(build.QCEditorURL) == "" autoRefresh := 0 if canPoll && u.cfg.PollIntervalSeconds > 0 { autoRefresh = u.cfg.PollIntervalSeconds } effectiveGlobal := build.GlobalDataJSON if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 { effectiveGlobal = payloadGlobal } u.render.Render(w, "build_detail", buildDetailPageData{ pageData: basePageData(r, "Build Detail", "/builds"), Build: build, EffectiveGlobal: effectiveGlobal, CanPoll: canPoll, CanFetchEditorURL: canFetchEditor, AutoRefreshSeconds: autoRefresh, }) } func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) { buildID := strings.TrimSpace(chi.URLParam(r, "id")) if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil { http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther) } func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) { buildID := strings.TrimSpace(chi.URLParam(r, "id")) if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil { http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther) return } http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther) } func basePageData(r *http.Request, title, current string) pageData { q := r.URL.Query() return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current} } func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) { rawID := chi.URLParam(r, "id") templateID, err := strconv.ParseInt(rawID, 10, 64) if err != nil { http.Error(w, "invalid template id", http.StatusBadRequest) return 0, false } return templateID, true } func urlQuery(s string) string { return url.QueryEscape(s) } func boolPtr(v bool) *bool { return &v } func intPtr(v int) *int { return &v } func int64Ptr(v int64) *int64 { return &v } 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, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) { if strings.TrimSpace(form.MasterPrompt) == "" { form.MasterPrompt = domain.SeedMasterPrompt } form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks) form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) templates, err := u.templateSvc.ListTemplates(r.Context()) if err != nil { return buildNewPageData{}, err } drafts, err := u.draftSvc.ListDrafts(r.Context(), 50) if err != nil { return buildNewPageData{}, err } data := buildNewPageData{ pageData: page, Templates: templates, Drafts: drafts, SelectedDraftID: selectedDraftID, SelectedTemplateID: selectedTemplateID, SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState), ShowDebug: parseDebugMode(r), Form: form, } if selectedTemplateID <= 0 { return data, nil } detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID) if err != nil || detail.Manifest == nil { return data, nil } data.SelectedManifestID = detail.Manifest.ID data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath) data.EnabledFields = data.EditableFields data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields)) return data, nil } func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput { settings := u.loadPromptSettings(ctx) form.MasterPrompt = settings.MasterPrompt form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks) if strings.TrimSpace(form.DraftID) == "" { return form } draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID)) if err != nil || draft == nil { return form } mergeDraftContextIntoForm(&form, draft.DraftContextJSON) form.MasterPrompt = settings.MasterPrompt form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks) form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) return form } func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) { sectionOrder := []string{ domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionServices, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionTestimonials, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionGallery, domain.WebsiteSectionFooter, domain.WebsiteSectionOther, } sectionDescriptions := map[string]string{ domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.", domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.", domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.", } sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder)) pendingByKey := make(map[string][]pendingField, len(sectionOrder)) for _, key := range sectionOrder { sectionsByKey[key] = buildFieldSectionView{ Key: key, Title: domain.WebsiteSectionLabel(key), Description: sectionDescriptions[key], } } for _, f := range fields { targetSection := preferredBuildSection(f) if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery { labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName) if blockID := extractBlockID(f); blockID != "" { labelFallback = "Media - " + blockGroupTitle(blockID) } media := sectionsByKey[domain.WebsiteSectionGallery] media.DisabledFields = append(media.DisabledFields, buildFieldView{ AnchorID: fieldAnchorID(f.Path), Path: f.Path, DisplayLabel: effectiveLabel(f, labelFallback), SampleValue: f.SampleValue, Value: "", }) sectionsByKey[domain.WebsiteSectionGallery] = media continue } if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") { continue } suggestion := suggestions[f.Path] pf := pendingField{ Field: f, View: buildFieldView{ 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) } for _, key := range sectionOrder { section := sectionsByKey[key] items := pendingByKey[key] switch key { case domain.WebsiteSectionServices: section = applyServicesGrouping(section, items) case domain.WebsiteSectionTestimonials: section = applyTestimonialsGrouping(section, items) case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter: section = applyTextGrouping(section, items) case domain.WebsiteSectionOther: section = applyOtherGrouping(section, items) case domain.WebsiteSectionGallery: // Gallery fields are handled as disabled entries only in this MVP. default: section = applyOtherGrouping(section, items) } sectionsByKey[key] = section } sections := make([]buildFieldSectionView, 0, len(sectionOrder)) for _, key := range sectionOrder { sections = append(sections, sectionsByKey[key]) } return assignEditableIndexes(sections) } func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback) } func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { titles := make([]pendingField, 0) descriptions := make([]pendingField, 0) other := make([]buildFieldView, 0) for _, pf := range fields { key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) switch { case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"): titles = append(titles, pf) case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"): descriptions = append(descriptions, pf) default: pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName)) other = append(other, pf.View) } } maxCount := len(titles) if len(descriptions) > maxCount { maxCount = len(descriptions) } for i := 0; i < maxCount; i++ { block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)} if i < len(titles) { item := titles[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1)) block.Fields = append(block.Fields, item.View) } if i < len(descriptions) { item := descriptions[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1)) block.Fields = append(block.Fields, item.View) } if len(block.Fields) > 0 { section.EditableGroups = append(section.EditableGroups, block) } } section.EditableFields = append(section.EditableFields, other...) return section } func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback) } func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { names := make([]pendingField, 0) titles := make([]pendingField, 0) descriptions := make([]pendingField, 0) other := make([]buildFieldView, 0) for _, pf := range fields { key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) switch { case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"): names = append(names, pf) case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"): titles = append(titles, pf) case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"): descriptions = append(descriptions, pf) default: pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName)) other = append(other, pf.View) } } maxCount := len(names) if len(titles) > maxCount { maxCount = len(titles) } if len(descriptions) > maxCount { maxCount = len(descriptions) } for i := 0; i < maxCount; i++ { block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)} if i < len(names) { item := names[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1)) block.Fields = append(block.Fields, item.View) } if i < len(titles) { item := titles[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1)) block.Fields = append(block.Fields, item.View) } if i < len(descriptions) { item := descriptions[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1)) block.Fields = append(block.Fields, item.View) } if len(block.Fields) > 0 { section.EditableGroups = append(section.EditableGroups, block) } } section.EditableFields = append(section.EditableFields, other...) return section } func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback) } func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { titles := make([]pendingField, 0) descriptions := make([]pendingField, 0) names := make([]pendingField, 0) other := make([]buildFieldView, 0) for _, pf := range fields { key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName)) switch { case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"): titles = append(titles, pf) case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"): descriptions = append(descriptions, pf) case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"): names = append(names, pf) default: pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName)) other = append(other, pf.View) } } maxCount := len(titles) if len(descriptions) > maxCount { maxCount = len(descriptions) } if len(names) > maxCount { maxCount = len(names) } for i := 0; i < maxCount; i++ { block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)} if i < len(titles) { item := titles[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1)) block.Fields = append(block.Fields, item.View) } if i < len(descriptions) { item := descriptions[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1)) block.Fields = append(block.Fields, item.View) } if i < len(names) { item := names[i] item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1)) block.Fields = append(block.Fields, item.View) } if len(block.Fields) > 0 { section.EditableGroups = append(section.EditableGroups, block) } } section.EditableFields = append(section.EditableFields, other...) return section } func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback) } func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView { for _, pf := range fields { pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName)) section.EditableFields = append(section.EditableFields, pf.View) } sort.SliceStable(section.EditableFields, func(i, j int) bool { return section.EditableFields[i].Path < section.EditableFields[j].Path }) return section } func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView { grouped := map[string][]pendingField{} withoutBlockID := make([]pendingField, 0) for _, pf := range fields { blockID := extractBlockID(pf.Field) if blockID == "" { withoutBlockID = append(withoutBlockID, pf) continue } grouped[blockID] = append(grouped[blockID], pf) } if len(grouped) > 0 { blockIDs := make([]string, 0, len(grouped)) for blockID := range grouped { blockIDs = append(blockIDs, blockID) } sort.SliceStable(blockIDs, func(i, j int) bool { li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j]) if li != lj { return li < lj } return blockIDs[i] < blockIDs[j] }) for _, blockID := range blockIDs { items := grouped[blockID] sort.SliceStable(items, func(i, j int) bool { ri := deriveFieldRole(items[i].Field.KeyName) rj := deriveFieldRole(items[j].Field.KeyName) if ri.Order != rj.Order { return ri.Order < rj.Order } return items[i].Field.Path < items[j].Field.Path }) group := buildFieldGroupView{Title: blockGroupTitle(blockID)} for _, item := range items { role := deriveFieldRole(item.Field.KeyName) fallbackLabel := role.Label if fallbackLabel == "" { fallbackLabel = humanizeKey(item.Field.KeyName) } item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel) group.Fields = append(group.Fields, item.View) } if len(group.Fields) > 0 { section.EditableGroups = append(section.EditableGroups, group) } } } if len(withoutBlockID) > 0 { if fallback != nil { return fallback(section, withoutBlockID) } for _, pf := range withoutBlockID { labelPrefix := fallbackPrefix if labelPrefix == "" { labelPrefix = normalizedSectionTitle(pf.Field.Section) } pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName)) section.EditableFields = append(section.EditableFields, pf.View) } } return section } func extractBlockID(f domain.TemplateField) string { candidates := []string{f.KeyName, f.Path} for _, candidate := range candidates { normalized := strings.ToLower(strings.TrimSpace(candidate)) if normalized == "" { continue } if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 { return match[1] } if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 { return match[1] } } return "" } func blockGroupTitle(blockID string) string { blockID = strings.ToLower(strings.TrimSpace(blockID)) if blockID == "" { return "Unbekannter Block" } if known, ok := knownBlockAreas[blockID]; ok { return fmt.Sprintf("%s (%s)", known, blockID) } return "Block " + blockID } func blockSortRank(blockID string) int { switch strings.ToLower(strings.TrimSpace(blockID)) { case "m1710": return 10 case "c7886": return 20 case "r4830": return 30 case "c2929": return 40 case "r4748": return 50 case "r1508": return 60 case "c1165": return 70 case "m4178": return 80 default: return 1000 } } func deriveFieldRole(key string) fieldRole { normalized := strings.ToLower(strings.TrimSpace(key)) switch { case strings.Contains(normalized, "subtitle"): return fieldRole{Label: "Untertitel", Order: 15} case strings.Contains(normalized, "title"): return fieldRole{Label: "Titel", Order: 20} case strings.Contains(normalized, "description"): return fieldRole{Label: "Beschreibung", Order: 30} case strings.Contains(normalized, "name"): return fieldRole{Label: "Name", Order: 40} case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"): return fieldRole{Label: "CTA Text", Order: 50} default: return fieldRole{Label: humanizeKey(key), Order: 100} } } func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) { editable := make([]buildFieldView, 0) nextIndex := 0 for si := range sections { for gi := range sections[si].EditableGroups { for fi := range sections[si].EditableGroups[gi].Fields { sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex editable = append(editable, sections[si].EditableGroups[gi].Fields[fi]) nextIndex++ } } for fi := range sections[si].EditableFields { sections[si].EditableFields[fi].Index = nextIndex editable = append(editable, sections[si].EditableFields[fi]) nextIndex++ } } return editable, sections } func preferredBuildSection(f domain.TemplateField) string { websiteSection := strings.TrimSpace(f.WebsiteSection) if websiteSection != "" { normalized := domain.NormalizeWebsiteSection(websiteSection) if normalized == domain.WebsiteSectionServiceItem { return domain.WebsiteSectionServices } return normalized } return fallbackBuildSection(f) } func fallbackBuildSection(f domain.TemplateField) string { switch normalizedSection(f.Section) { case "services": return domain.WebsiteSectionServices case "testimonials": return domain.WebsiteSectionTestimonials case "text": if isMediaOrGalleryField(f) { return domain.WebsiteSectionGallery } return domain.WebsiteSectionOther default: if isMediaOrGalleryField(f) { return domain.WebsiteSectionGallery } return domain.WebsiteSectionOther } } func normalizedSection(raw string) string { section := strings.ToLower(strings.TrimSpace(raw)) switch section { case "ext": return "text" default: return section } } func normalizedSectionTitle(raw string) string { switch normalizedSection(raw) { case "text": return "Text" case "services": return "Services" case "testimonials": return "Testimonials" case "gallery", "media": return "Media" default: return "Feld" } } func isMediaOrGalleryField(f domain.TemplateField) bool { if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") { return true } section := strings.ToLower(strings.TrimSpace(f.Section)) key := strings.ToLower(strings.TrimSpace(f.KeyName)) path := strings.ToLower(strings.TrimSpace(f.Path)) if section == "gallery" || section == "media" { return true } hints := []string{"gallery", "image", "img", "photo", "picture"} for _, hint := range hints { if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) { return true } } return false } func effectiveLabel(f domain.TemplateField, fallback string) string { if !isRawPathLikeLabel(f.DisplayLabel, f.Path) { return strings.TrimSpace(f.DisplayLabel) } return strings.TrimSpace(fallback) } func isRawPathLikeLabel(label string, path string) bool { l := strings.TrimSpace(label) if l == "" { return true } if strings.EqualFold(l, strings.TrimSpace(path)) { return true } if strings.Contains(l, ".") || strings.Contains(l, "_") { return true } return false } func humanizeKey(key string) string { raw := strings.TrimSpace(key) if raw == "" { return "Inhalt" } base := raw if idx := strings.Index(base, "_"); idx > 0 { base = base[:idx] } runes := make([]rune, 0, len(base)+4) for i, r := range base { if i > 0 && unicode.IsUpper(r) { runes = append(runes, ' ') } runes = append(runes, r) } human := strings.TrimSpace(string(runes)) if human == "" { return "Inhalt" } words := strings.Fields(strings.ToLower(human)) for i := range words { if len(words[i]) > 0 { words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] } } return strings.Join(words, " ") } func buildFormInputFromRequest(r *http.Request) buildFormInput { form := buildFormInput{ DraftID: strings.TrimSpace(r.FormValue("draft_id")), DraftSource: strings.TrimSpace(r.FormValue("draft_source")), DraftStatus: strings.TrimSpace(r.FormValue("draft_status")), DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")), RequestName: strings.TrimSpace(r.FormValue("request_name")), CompanyName: strings.TrimSpace(r.FormValue("company_name")), BusinessType: strings.TrimSpace(r.FormValue("business_type")), Username: strings.TrimSpace(r.FormValue("username")), Email: strings.TrimSpace(r.FormValue("email")), Phone: strings.TrimSpace(r.FormValue("phone")), OrgNumber: strings.TrimSpace(r.FormValue("org_number")), StartDate: strings.TrimSpace(r.FormValue("start_date")), Mission: strings.TrimSpace(r.FormValue("mission")), DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), AddressCity: strings.TrimSpace(r.FormValue("address_city")), AddressRegion: strings.TrimSpace(r.FormValue("address_region")), AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), AddressCountry: strings.TrimSpace(r.FormValue("address_country")), WebsiteURL: strings.TrimSpace(r.FormValue("website_url")), WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")), LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")), MarketStyle: strings.TrimSpace(r.FormValue("market_style")), AddressMode: strings.TrimSpace(r.FormValue("address_mode")), ContentTone: strings.TrimSpace(r.FormValue("content_tone")), } return form } func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput { form := buildFormInput{ DraftID: draft.ID, DraftSource: draft.Source, DraftStatus: draft.Status, DraftNotes: draft.Notes, RequestName: draft.RequestName, } mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON) mergeDraftContextIntoForm(&form, draft.DraftContextJSON) return form } func parseBuildFieldValues(r *http.Request) map[string]string { fieldValues := map[string]string{} count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count"))) for i := 0; i < count; i++ { path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i))) value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i))) if path != "" { fieldValues[path] = value } } 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 parseDebugMode(r *http.Request) bool { if r == nil { return false } value := strings.ToLower(strings.TrimSpace(r.FormValue("debug"))) switch value { case "1", "true", "on", "yes": return true default: return false } } func fieldAnchorID(fieldPath string) string { path := strings.TrimSpace(strings.ToLower(fieldPath)) if path == "" { 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 } var payload struct { GlobalData map[string]any `json:"globalData"` } if err := json.Unmarshal(raw, &payload); err != nil { return nil, err } if len(payload.GlobalData) == 0 { return nil, nil } data, err := json.Marshal(payload.GlobalData) if err != nil { return nil, err } return data, nil } func parseFieldValuesJSON(raw []byte) map[string]string { out := map[string]string{} if len(raw) == 0 { return out } _ = json.Unmarshal(raw, &out) return out } func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) { if form == nil || len(raw) == 0 { return } var global map[string]any if err := json.Unmarshal(raw, &global); err != nil { return } form.CompanyName = getString(global["companyName"]) form.BusinessType = getString(global["businessType"]) form.Username = getString(global["username"]) form.Email = getString(global["email"]) form.Phone = getString(global["phone"]) form.OrgNumber = getString(global["orgNumber"]) form.StartDate = getString(global["startDate"]) form.Mission = getString(global["mission"]) form.DescriptionShort = getString(global["descriptionShort"]) form.DescriptionLong = getString(global["descriptionLong"]) form.SiteLanguage = getString(global["siteLanguage"]) address, _ := global["address"].(map[string]any) form.AddressLine1 = getString(address["line1"]) form.AddressLine2 = getString(address["line2"]) form.AddressCity = getString(address["city"]) form.AddressRegion = getString(address["region"]) form.AddressZIP = getString(address["zip"]) form.AddressCountry = getString(address["country"]) } func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) { if form == nil || len(raw) == 0 { return } var ctx domain.DraftContext if err := json.Unmarshal(raw, &ctx); err != nil { return } if strings.TrimSpace(form.BusinessType) == "" { form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType) } form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL) form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary) form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle) form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle) form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode) form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone) form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions) 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 == "" { businessType = strings.TrimSpace(getString(globalData["businessType"])) } return &domain.DraftContext{ IntakeSource: strings.TrimSpace(form.DraftSource), LLM: domain.DraftLLMContext{ BusinessType: businessType, WebsiteURL: strings.TrimSpace(form.WebsiteURL), WebsiteSummary: strings.TrimSpace(form.WebsiteSummary), StyleProfile: domain.DraftStyleProfile{ LocaleStyle: strings.TrimSpace(form.LocaleStyle), MarketStyle: strings.TrimSpace(form.MarketStyle), AddressMode: strings.TrimSpace(form.AddressMode), ContentTone: strings.TrimSpace(form.ContentTone), PromptInstructions: strings.TrimSpace(form.PromptInstructions), }, Prompt: domain.DraftPromptConfig{ Blocks: clonePromptBlocks(form.PromptBlocks), }, }, } } func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { defaultProvider := domain.DefaultLLMProvider() settings := domain.AppSettings{ QCBaseURL: u.cfg.QCBaseURL, QCBearerTokenEncrypted: u.cfg.QCToken, LanguageOutputMode: "EN", JobPollIntervalSeconds: u.cfg.PollIntervalSeconds, JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds, LLMActiveProvider: defaultProvider, LLMActiveModel: domain.NormalizeLLMModel(defaultProvider, ""), MasterPrompt: domain.SeedMasterPrompt, PromptBlocks: domain.DefaultPromptBlocks(), } if u.settings == nil { return settings } stored, err := u.settings.GetSettings(ctx) if err != nil || stored == nil { return settings } if strings.TrimSpace(stored.QCBaseURL) != "" { settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL) } if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" { settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted) } if strings.TrimSpace(stored.LanguageOutputMode) != "" { settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode) } if stored.JobPollIntervalSeconds > 0 { settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds } if stored.JobPollTimeoutSeconds > 0 { settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds } settings.LLMActiveProvider = domain.NormalizeLLMProvider(stored.LLMActiveProvider) settings.LLMActiveModel = domain.NormalizeLLMModel(settings.LLMActiveProvider, stored.LLMActiveModel) settings.LLMBaseURL = strings.TrimSpace(stored.LLMBaseURL) settings.OpenAIAPIKeyEncrypted = strings.TrimSpace(stored.OpenAIAPIKeyEncrypted) settings.AnthropicAPIKeyEncrypted = strings.TrimSpace(stored.AnthropicAPIKeyEncrypted) settings.GoogleAPIKeyEncrypted = strings.TrimSpace(stored.GoogleAPIKeyEncrypted) settings.XAIAPIKeyEncrypted = strings.TrimSpace(stored.XAIAPIKeyEncrypted) settings.OllamaAPIKeyEncrypted = strings.TrimSpace(stored.OllamaAPIKeyEncrypted) settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) return settings } func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig { count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count"))) if count <= 0 { return domain.DefaultPromptBlocks() } out := make([]domain.PromptBlockConfig, 0, count) for i := 0; i < count; i++ { id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i))) if id == "" { continue } out = append(out, domain.PromptBlockConfig{ ID: id, Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))), Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))), Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on", }) } return domain.NormalizePromptBlocks(out) } func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig { if len(blocks) == 0 { return nil } out := make([]domain.PromptBlockConfig, len(blocks)) copy(out, blocks) return out } func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig { merged := make([]domain.PromptBlockConfig, 0, len(defaults)) merged = append(merged, clonePromptBlocks(defaults)...) overrides := make(map[string]domain.PromptBlockConfig, len(current)) for _, block := range current { id := strings.TrimSpace(block.ID) if id == "" { continue } overrides[id] = block } for i := range merged { if override, ok := overrides[merged[i].ID]; ok { if strings.TrimSpace(override.Label) != "" { merged[i].Label = strings.TrimSpace(override.Label) } if strings.TrimSpace(override.Instruction) != "" { merged[i].Instruction = strings.TrimSpace(override.Instruction) } merged[i].Enabled = override.Enabled delete(overrides, merged[i].ID) } } for _, override := range overrides { merged = append(merged, override) } return domain.NormalizePromptBlocks(merged) } func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig { out := clonePromptBlocks(blocks) if len(out) == 0 { return out } for i := range out { switch out[i].ID { case "business_type": if strings.TrimSpace(form.BusinessType) != "" { out[i].Enabled = true } case "website_summary": if strings.TrimSpace(form.WebsiteSummary) != "" { out[i].Enabled = true } case "address_mode": if strings.TrimSpace(form.AddressMode) != "" { out[i].Enabled = true } case "content_tone": if strings.TrimSpace(form.ContentTone) != "" { out[i].Enabled = true } case "free_instructions": if strings.TrimSpace(form.PromptInstructions) != "" { out[i].Enabled = true } } } return out } func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView { if len(mappingResult.BySlot) == 0 { return nil } slotKeys := make([]string, 0, len(mappingResult.BySlot)) for slot := range mappingResult.BySlot { slotKeys = append(slotKeys, slot) } sort.Strings(slotKeys) out := make([]semanticSlotPreviewView, 0, len(slotKeys)) for _, slot := range slotKeys { targets := mappingResult.BySlot[slot] examples := make([]string, 0, 2) for i := 0; i < len(targets) && i < 2; i++ { examples = append(examples, targets[i].FieldPath) } out = append(out, semanticSlotPreviewView{ Slot: slot, Count: len(targets), Examples: strings.Join(examples, ", "), }) } return out } func getString(v any) string { s, _ := v.(string) return strings.TrimSpace(s) } func defaultDraftStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "reviewed", "submitted": return strings.ToLower(strings.TrimSpace(status)) default: return "draft" } } func websiteSectionOptions() []websiteSectionOptionView { values := domain.WebsiteSectionOptions() out := make([]websiteSectionOptionView, 0, len(values)) for _, value := range values { out = append(out, websiteSectionOptionView{ Value: value, Label: domain.WebsiteSectionLabel(value), }) } return out }