Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

1779 lignes
58KB

  1. package handlers
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/url"
  8. "regexp"
  9. "sort"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "unicode"
  14. "github.com/go-chi/chi/v5"
  15. "qctextbuilder/internal/buildsvc"
  16. "qctextbuilder/internal/config"
  17. "qctextbuilder/internal/domain"
  18. "qctextbuilder/internal/draftsvc"
  19. "qctextbuilder/internal/mapping"
  20. "qctextbuilder/internal/onboarding"
  21. "qctextbuilder/internal/store"
  22. "qctextbuilder/internal/templatesvc"
  23. )
  24. type UI struct {
  25. templateSvc *templatesvc.Service
  26. onboardSvc *onboarding.Service
  27. draftSvc *draftsvc.Service
  28. buildSvc buildsvc.Service
  29. settings store.SettingsStore
  30. suggestionGenerator mapping.SuggestionGenerator
  31. cfg config.Config
  32. render htmlRenderer
  33. }
  34. type htmlRenderer interface {
  35. Render(w http.ResponseWriter, name string, data any)
  36. }
  37. type pageData struct {
  38. Title string
  39. Msg string
  40. Err string
  41. Current string
  42. }
  43. type homePageData struct {
  44. pageData
  45. TemplateCount int
  46. }
  47. type settingsPageData struct {
  48. pageData
  49. QCBaseURL string
  50. PollIntervalSeconds int
  51. PollTimeoutSeconds int
  52. PollMaxConcurrent int
  53. TokenConfigured bool
  54. LanguageOutputMode string
  55. MasterPrompt string
  56. PromptBlocks []domain.PromptBlockConfig
  57. }
  58. type templatesPageData struct {
  59. pageData
  60. Templates []domain.Template
  61. }
  62. type templateFieldView struct {
  63. Path string
  64. FieldKind string
  65. IsEnabled bool
  66. IsRequiredByUs bool
  67. DisplayLabel string
  68. DisplayOrder int
  69. WebsiteSection string
  70. Notes string
  71. SampleValue string
  72. }
  73. type websiteSectionOptionView struct {
  74. Value string
  75. Label string
  76. }
  77. type templateDetailPageData struct {
  78. pageData
  79. Detail *templatesvc.TemplateDetail
  80. Fields []templateFieldView
  81. WebsiteSectionOptions []websiteSectionOptionView
  82. }
  83. type buildFieldView struct {
  84. Index int
  85. AnchorID string
  86. Path string
  87. DisplayLabel string
  88. SampleValue string
  89. Value string
  90. SuggestedValue string
  91. SuggestionReason string
  92. SuggestionStatus string
  93. SuggestionSource string
  94. }
  95. type buildFieldGroupView struct {
  96. Title string
  97. Fields []buildFieldView
  98. }
  99. type buildFieldSectionView struct {
  100. Key string
  101. Title string
  102. Description string
  103. EditableGroups []buildFieldGroupView
  104. EditableFields []buildFieldView
  105. DisabledFields []buildFieldView
  106. }
  107. type semanticSlotPreviewView struct {
  108. Slot string
  109. Count int
  110. Examples string
  111. }
  112. type pendingField struct {
  113. Field domain.TemplateField
  114. View buildFieldView
  115. }
  116. type fieldRole struct {
  117. Label string
  118. Order int
  119. }
  120. var blockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
  121. var looseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
  122. var knownBlockAreas = map[string]string{
  123. "m1710": "Hero / Haupttitel",
  124. "c7886": "Intro / Einleitung",
  125. "r4830": "Services",
  126. "m4178": "Gallery / Medien",
  127. "c2929": "Ueber uns / About",
  128. "r4748": "Team",
  129. "r1508": "Testimonials",
  130. "c1165": "CTA / Highlight / Banner",
  131. }
  132. type buildNewPageData struct {
  133. pageData
  134. Templates []domain.Template
  135. Drafts []domain.BuildDraft
  136. SelectedDraftID string
  137. SelectedTemplateID int64
  138. SelectedManifestID string
  139. FieldSections []buildFieldSectionView
  140. EditableFields []buildFieldView
  141. EnabledFields []buildFieldView
  142. SuggestionStateJSON string
  143. AutofillFocusID string
  144. ShowDebug bool
  145. Form buildFormInput
  146. SemanticSlots []semanticSlotPreviewView
  147. }
  148. type buildFormInput struct {
  149. DraftID string
  150. DraftSource string
  151. DraftStatus string
  152. DraftNotes string
  153. RequestName string
  154. CompanyName string
  155. BusinessType string
  156. Username string
  157. Email string
  158. Phone string
  159. OrgNumber string
  160. StartDate string
  161. Mission string
  162. DescriptionShort string
  163. DescriptionLong string
  164. SiteLanguage string
  165. AddressLine1 string
  166. AddressLine2 string
  167. AddressCity string
  168. AddressRegion string
  169. AddressZIP string
  170. AddressCountry string
  171. WebsiteURL string
  172. WebsiteSummary string
  173. LocaleStyle string
  174. MarketStyle string
  175. AddressMode string
  176. ContentTone string
  177. PromptInstructions string
  178. MasterPrompt string
  179. PromptBlocks []domain.PromptBlockConfig
  180. }
  181. type buildDetailPageData struct {
  182. pageData
  183. Build *domain.SiteBuild
  184. EffectiveGlobal []byte
  185. CanPoll bool
  186. CanFetchEditorURL bool
  187. AutoRefreshSeconds int
  188. }
  189. 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 {
  190. return &UI{
  191. templateSvc: templateSvc,
  192. onboardSvc: onboardSvc,
  193. draftSvc: draftSvc,
  194. buildSvc: buildSvc,
  195. settings: settings,
  196. suggestionGenerator: suggestionGenerator,
  197. cfg: cfg,
  198. render: render,
  199. }
  200. }
  201. func (u *UI) Home(w http.ResponseWriter, r *http.Request) {
  202. templates, err := u.templateSvc.ListTemplates(r.Context())
  203. if err != nil {
  204. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: 0})
  205. return
  206. }
  207. u.render.Render(w, "home", homePageData{pageData: basePageData(r, "Home", "/"), TemplateCount: len(templates)})
  208. }
  209. func (u *UI) Settings(w http.ResponseWriter, r *http.Request) {
  210. settings := u.loadPromptSettings(r.Context())
  211. u.render.Render(w, "settings", settingsPageData{
  212. pageData: basePageData(r, "Settings", "/settings"),
  213. QCBaseURL: u.cfg.QCBaseURL,
  214. PollIntervalSeconds: u.cfg.PollIntervalSeconds,
  215. PollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  216. PollMaxConcurrent: u.cfg.PollMaxConcurrent,
  217. TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "",
  218. LanguageOutputMode: "EN",
  219. MasterPrompt: settings.MasterPrompt,
  220. PromptBlocks: settings.PromptBlocks,
  221. })
  222. }
  223. func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) {
  224. if err := r.ParseForm(); err != nil {
  225. http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther)
  226. return
  227. }
  228. settings := u.loadPromptSettings(r.Context())
  229. settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt"))
  230. settings.PromptBlocks = parsePromptBlocksFromRequest(r)
  231. if err := u.settings.UpsertSettings(r.Context(), settings); err != nil {
  232. http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther)
  233. return
  234. }
  235. http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther)
  236. }
  237. func (u *UI) Templates(w http.ResponseWriter, r *http.Request) {
  238. templates, err := u.templateSvc.ListTemplates(r.Context())
  239. if err != nil {
  240. http.Error(w, err.Error(), http.StatusBadRequest)
  241. return
  242. }
  243. u.render.Render(w, "templates", templatesPageData{pageData: basePageData(r, "Templates", "/templates"), Templates: templates})
  244. }
  245. func (u *UI) SyncTemplates(w http.ResponseWriter, r *http.Request) {
  246. if _, err := u.templateSvc.SyncAITemplates(r.Context()); err != nil {
  247. http.Redirect(w, r, "/templates?err="+urlQuery(err.Error()), http.StatusSeeOther)
  248. return
  249. }
  250. http.Redirect(w, r, "/templates?msg=sync+done", http.StatusSeeOther)
  251. }
  252. func (u *UI) TemplateDetail(w http.ResponseWriter, r *http.Request) {
  253. templateID, ok := parseTemplateID(w, r)
  254. if !ok {
  255. return
  256. }
  257. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  258. if err != nil {
  259. http.Error(w, err.Error(), http.StatusNotFound)
  260. return
  261. }
  262. fields := make([]templateFieldView, 0, len(detail.Fields))
  263. for _, f := range detail.Fields {
  264. fields = append(fields, templateFieldView{
  265. Path: f.Path,
  266. FieldKind: f.FieldKind,
  267. IsEnabled: f.IsEnabled,
  268. IsRequiredByUs: f.IsRequiredByUs,
  269. DisplayLabel: f.DisplayLabel,
  270. DisplayOrder: f.DisplayOrder,
  271. WebsiteSection: domain.NormalizeWebsiteSection(f.WebsiteSection),
  272. Notes: f.Notes,
  273. SampleValue: f.SampleValue,
  274. })
  275. }
  276. u.render.Render(w, "template_detail", templateDetailPageData{
  277. pageData: basePageData(r, "Template Detail", "/templates"),
  278. Detail: detail,
  279. Fields: fields,
  280. WebsiteSectionOptions: websiteSectionOptions(),
  281. })
  282. }
  283. func (u *UI) OnboardTemplate(w http.ResponseWriter, r *http.Request) {
  284. templateID, ok := parseTemplateID(w, r)
  285. if !ok {
  286. return
  287. }
  288. if _, _, err := u.onboardSvc.OnboardTemplate(r.Context(), templateID); err != nil {
  289. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  290. return
  291. }
  292. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=onboarding+done", templateID), http.StatusSeeOther)
  293. }
  294. func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) {
  295. templateID, ok := parseTemplateID(w, r)
  296. if !ok {
  297. return
  298. }
  299. if err := r.ParseForm(); err != nil {
  300. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid form")), http.StatusSeeOther)
  301. return
  302. }
  303. count, _ := strconv.Atoi(r.FormValue("field_count"))
  304. patches := make([]onboarding.FieldPatch, 0, count)
  305. for i := 0; i < count; i++ {
  306. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  307. if path == "" {
  308. continue
  309. }
  310. enabled := r.FormValue(fmt.Sprintf("field_enabled_%d", i)) == "on"
  311. required := r.FormValue(fmt.Sprintf("field_required_%d", i)) == "on"
  312. label := r.FormValue(fmt.Sprintf("field_label_%d", i))
  313. notes := r.FormValue(fmt.Sprintf("field_notes_%d", i))
  314. websiteSection := r.FormValue(fmt.Sprintf("field_website_section_%d", i))
  315. order, err := strconv.Atoi(strings.TrimSpace(r.FormValue(fmt.Sprintf("field_order_%d", i))))
  316. if err != nil {
  317. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery("invalid display order")), http.StatusSeeOther)
  318. return
  319. }
  320. patches = append(patches, onboarding.FieldPatch{
  321. Path: path,
  322. IsEnabled: boolPtr(enabled),
  323. IsRequiredByUs: boolPtr(required),
  324. DisplayLabel: strPtr(label),
  325. DisplayOrder: intPtr(order),
  326. WebsiteSection: strPtr(websiteSection),
  327. Notes: strPtr(notes),
  328. })
  329. }
  330. manifestID := r.FormValue("manifest_id")
  331. if _, _, err := u.onboardSvc.UpdateTemplateFields(r.Context(), templateID, manifestID, patches); err != nil {
  332. http.Redirect(w, r, fmt.Sprintf("/templates/%d?err=%s", templateID, urlQuery(err.Error())), http.StatusSeeOther)
  333. return
  334. }
  335. http.Redirect(w, r, fmt.Sprintf("/templates/%d?msg=fields+saved", templateID), http.StatusSeeOther)
  336. }
  337. func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) {
  338. settings := u.loadPromptSettings(r.Context())
  339. selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64)
  340. selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id"))
  341. form := buildFormInput{
  342. DraftID: selectedDraftID,
  343. DraftSource: "ui",
  344. DraftStatus: "draft",
  345. MasterPrompt: settings.MasterPrompt,
  346. PromptBlocks: clonePromptBlocks(settings.PromptBlocks),
  347. }
  348. fieldValues := map[string]string{}
  349. suggestionState := domain.DraftSuggestionState{}
  350. if selectedDraftID != "" {
  351. draft, err := u.draftSvc.GetDraft(r.Context(), selectedDraftID)
  352. if err == nil {
  353. selectedTemplateID = draft.TemplateID
  354. form = buildFormInputFromDraft(draft)
  355. fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON)
  356. suggestionState = parseSuggestionStateJSON(draft.SuggestionStateJSON)
  357. form.MasterPrompt = settings.MasterPrompt
  358. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  359. }
  360. }
  361. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  362. data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues, suggestionState)
  363. if err != nil {
  364. http.Error(w, err.Error(), http.StatusBadRequest)
  365. return
  366. }
  367. u.render.Render(w, "build_new", data)
  368. }
  369. func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
  370. if err := r.ParseForm(); err != nil {
  371. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  372. return
  373. }
  374. form := buildFormInputFromRequest(r)
  375. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  376. fieldValues := parseBuildFieldValues(r)
  377. suggestionState := parseSuggestionStateFromRequest(r)
  378. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  379. CompanyName: form.CompanyName,
  380. BusinessType: form.BusinessType,
  381. Username: form.Username,
  382. Email: form.Email,
  383. Phone: form.Phone,
  384. OrgNumber: form.OrgNumber,
  385. StartDate: form.StartDate,
  386. Mission: form.Mission,
  387. DescriptionShort: form.DescriptionShort,
  388. DescriptionLong: form.DescriptionLong,
  389. SiteLanguage: form.SiteLanguage,
  390. AddressLine1: form.AddressLine1,
  391. AddressLine2: form.AddressLine2,
  392. AddressCity: form.AddressCity,
  393. AddressRegion: form.AddressRegion,
  394. AddressZIP: form.AddressZIP,
  395. AddressCountry: form.AddressCountry,
  396. })
  397. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  398. if err != nil || templateID <= 0 {
  399. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  400. return
  401. }
  402. result, err := u.buildSvc.StartBuild(r.Context(), buildsvc.StartBuildRequest{
  403. TemplateID: templateID,
  404. RequestName: form.RequestName,
  405. GlobalData: globalData,
  406. FieldValues: fieldValues,
  407. })
  408. if err != nil {
  409. data, loadErr := u.loadBuildNewPageData(r, pageData{
  410. Title: "New Build",
  411. Err: err.Error(),
  412. Current: "/builds/new",
  413. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  414. if loadErr != nil {
  415. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  416. return
  417. }
  418. u.render.Render(w, "build_new", data)
  419. return
  420. }
  421. if form.DraftID != "" {
  422. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  423. DraftID: form.DraftID,
  424. TemplateID: int64Ptr(templateID),
  425. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  426. Source: form.DraftSource,
  427. RequestName: form.RequestName,
  428. GlobalData: globalData,
  429. FieldValues: fieldValues,
  430. DraftContext: buildDraftContextFromForm(form, globalData),
  431. SuggestionState: &suggestionState,
  432. Status: "submitted",
  433. Notes: form.DraftNotes,
  434. })
  435. }
  436. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=build+started", result.BuildID), http.StatusSeeOther)
  437. }
  438. func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
  439. if err := r.ParseForm(); err != nil {
  440. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  441. return
  442. }
  443. form := buildFormInputFromRequest(r)
  444. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  445. fieldValues := parseBuildFieldValues(r)
  446. suggestionState := parseSuggestionStateFromRequest(r)
  447. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  448. if err != nil || templateID <= 0 {
  449. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  450. return
  451. }
  452. globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  453. CompanyName: form.CompanyName,
  454. BusinessType: form.BusinessType,
  455. Username: form.Username,
  456. Email: form.Email,
  457. Phone: form.Phone,
  458. OrgNumber: form.OrgNumber,
  459. StartDate: form.StartDate,
  460. Mission: form.Mission,
  461. DescriptionShort: form.DescriptionShort,
  462. DescriptionLong: form.DescriptionLong,
  463. SiteLanguage: form.SiteLanguage,
  464. AddressLine1: form.AddressLine1,
  465. AddressLine2: form.AddressLine2,
  466. AddressCity: form.AddressCity,
  467. AddressRegion: form.AddressRegion,
  468. AddressZIP: form.AddressZIP,
  469. AddressCountry: form.AddressCountry,
  470. })
  471. draft, err := u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  472. DraftID: form.DraftID,
  473. TemplateID: int64Ptr(templateID),
  474. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  475. Source: form.DraftSource,
  476. RequestName: form.RequestName,
  477. GlobalData: globalData,
  478. FieldValues: fieldValues,
  479. DraftContext: buildDraftContextFromForm(form, globalData),
  480. SuggestionState: &suggestionState,
  481. Status: defaultDraftStatus(form.DraftStatus),
  482. Notes: form.DraftNotes,
  483. })
  484. if err != nil {
  485. data, loadErr := u.loadBuildNewPageData(r, pageData{
  486. Title: "New Build",
  487. Err: err.Error(),
  488. Current: "/builds/new",
  489. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  490. if loadErr != nil {
  491. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  492. return
  493. }
  494. u.render.Render(w, "build_new", data)
  495. return
  496. }
  497. http.Redirect(w, r, fmt.Sprintf("/builds/new?template_id=%d&draft_id=%s&msg=draft+saved", templateID, urlQuery(draft.ID)), http.StatusSeeOther)
  498. }
  499. func (u *UI) AutofillDraft(w http.ResponseWriter, r *http.Request) {
  500. if err := r.ParseForm(); err != nil {
  501. http.Redirect(w, r, "/builds/new?err=invalid+form", http.StatusSeeOther)
  502. return
  503. }
  504. form := buildFormInputFromRequest(r)
  505. form = u.applyPromptConfigForBuildFlow(r.Context(), form)
  506. fieldValues := parseBuildFieldValues(r)
  507. suggestionState := parseSuggestionStateFromRequest(r)
  508. templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
  509. if err != nil || templateID <= 0 {
  510. http.Redirect(w, r, "/builds/new?err=invalid+template", http.StatusSeeOther)
  511. return
  512. }
  513. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), templateID)
  514. if err != nil || detail.Manifest == nil {
  515. http.Redirect(w, r, "/builds/new?err=template+detail+missing", http.StatusSeeOther)
  516. return
  517. }
  518. globalData := buildGlobalDataFromForm(form)
  519. draftContext := buildDraftContextFromForm(form, globalData)
  520. action, targetFieldPath := parseAutofillAction(strings.TrimSpace(r.FormValue("autofill_action")))
  521. focusFieldPath := targetFieldPath
  522. now := time.Now().UTC()
  523. req := mapping.SuggestionRequest{
  524. TemplateID: templateID,
  525. Fields: detail.Fields,
  526. GlobalData: globalData,
  527. DraftContext: draftContext,
  528. MasterPrompt: form.MasterPrompt,
  529. PromptBlocks: form.PromptBlocks,
  530. Existing: fieldValues,
  531. }
  532. msg := "autofill ready"
  533. switch action {
  534. case "generate_all":
  535. suggestionState = mapping.GenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
  536. msg = "suggestions generated"
  537. case "regenerate_all":
  538. suggestionState = mapping.RegenerateAllSuggestions(r.Context(), u.suggestionGenerator, req, suggestionState, now)
  539. msg = "suggestions regenerated"
  540. case "apply_all":
  541. fieldValues, suggestionState = mapping.ApplyAllSuggestions(fieldValues, suggestionState, now)
  542. msg = "all suggestions applied"
  543. case "apply_all_empty":
  544. fieldValues, suggestionState = mapping.ApplySuggestionsToEmptyFields(fieldValues, suggestionState, now)
  545. msg = "suggestions applied to empty fields"
  546. case "apply_field":
  547. fieldValues, suggestionState = mapping.ApplySuggestionToField(fieldValues, suggestionState, targetFieldPath, now)
  548. msg = "field suggestion applied"
  549. case "regenerate_field":
  550. suggestionState = mapping.RegenerateFieldSuggestion(r.Context(), u.suggestionGenerator, req, suggestionState, targetFieldPath, now)
  551. msg = "field suggestion regenerated"
  552. default:
  553. msg = "unknown autofill action"
  554. }
  555. if strings.TrimSpace(form.DraftID) != "" {
  556. _, _ = u.draftSvc.SaveDraft(r.Context(), draftsvc.UpsertDraftRequest{
  557. DraftID: form.DraftID,
  558. TemplateID: int64Ptr(templateID),
  559. ManifestID: strings.TrimSpace(r.FormValue("manifest_id")),
  560. Source: form.DraftSource,
  561. RequestName: form.RequestName,
  562. GlobalData: globalData,
  563. FieldValues: fieldValues,
  564. DraftContext: draftContext,
  565. SuggestionState: &suggestionState,
  566. Status: defaultDraftStatus(form.DraftStatus),
  567. Notes: form.DraftNotes,
  568. })
  569. }
  570. data, loadErr := u.loadBuildNewPageData(r, pageData{
  571. Title: "New Build",
  572. Msg: msg,
  573. Current: "/builds/new",
  574. }, form.DraftID, templateID, form, fieldValues, suggestionState)
  575. if loadErr != nil {
  576. http.Error(w, loadErr.Error(), http.StatusBadRequest)
  577. return
  578. }
  579. data.AutofillFocusID = fieldAnchorID(focusFieldPath)
  580. u.render.Render(w, "build_new", data)
  581. }
  582. func (u *UI) BuildDetail(w http.ResponseWriter, r *http.Request) {
  583. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  584. build, err := u.buildSvc.GetBuild(r.Context(), buildID)
  585. if err != nil {
  586. http.Error(w, err.Error(), http.StatusNotFound)
  587. return
  588. }
  589. status := strings.ToLower(strings.TrimSpace(build.QCStatus))
  590. canPoll := status == "queued" || status == "processing"
  591. canFetchEditor := (status == "done" || status == "failed" || status == "timeout") &&
  592. build.QCSiteID != nil &&
  593. strings.TrimSpace(build.QCEditorURL) == ""
  594. autoRefresh := 0
  595. if canPoll && u.cfg.PollIntervalSeconds > 0 {
  596. autoRefresh = u.cfg.PollIntervalSeconds
  597. }
  598. effectiveGlobal := build.GlobalDataJSON
  599. if payloadGlobal, err := extractGlobalDataFromFinalPayload(build.FinalSitesPayload); err == nil && len(payloadGlobal) > 0 {
  600. effectiveGlobal = payloadGlobal
  601. }
  602. u.render.Render(w, "build_detail", buildDetailPageData{
  603. pageData: basePageData(r, "Build Detail", "/builds"),
  604. Build: build,
  605. EffectiveGlobal: effectiveGlobal,
  606. CanPoll: canPoll,
  607. CanFetchEditorURL: canFetchEditor,
  608. AutoRefreshSeconds: autoRefresh,
  609. })
  610. }
  611. func (u *UI) PollBuild(w http.ResponseWriter, r *http.Request) {
  612. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  613. if err := u.buildSvc.PollOnce(r.Context(), buildID); err != nil {
  614. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  615. return
  616. }
  617. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=poll+done", buildID), http.StatusSeeOther)
  618. }
  619. func (u *UI) FetchEditorURL(w http.ResponseWriter, r *http.Request) {
  620. buildID := strings.TrimSpace(chi.URLParam(r, "id"))
  621. if err := u.buildSvc.FetchEditorURL(r.Context(), buildID); err != nil {
  622. http.Redirect(w, r, fmt.Sprintf("/builds/%s?err=%s", buildID, urlQuery(err.Error())), http.StatusSeeOther)
  623. return
  624. }
  625. http.Redirect(w, r, fmt.Sprintf("/builds/%s?msg=editor+url+loaded", buildID), http.StatusSeeOther)
  626. }
  627. func basePageData(r *http.Request, title, current string) pageData {
  628. q := r.URL.Query()
  629. return pageData{Title: title, Msg: q.Get("msg"), Err: q.Get("err"), Current: current}
  630. }
  631. func parseTemplateID(w http.ResponseWriter, r *http.Request) (int64, bool) {
  632. rawID := chi.URLParam(r, "id")
  633. templateID, err := strconv.ParseInt(rawID, 10, 64)
  634. if err != nil {
  635. http.Error(w, "invalid template id", http.StatusBadRequest)
  636. return 0, false
  637. }
  638. return templateID, true
  639. }
  640. func urlQuery(s string) string {
  641. return url.QueryEscape(s)
  642. }
  643. func boolPtr(v bool) *bool { return &v }
  644. func intPtr(v int) *int { return &v }
  645. func int64Ptr(v int64) *int64 { return &v }
  646. func strPtr(v string) *string {
  647. return &v
  648. }
  649. func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string, suggestionState domain.DraftSuggestionState) (buildNewPageData, error) {
  650. if strings.TrimSpace(form.MasterPrompt) == "" {
  651. form.MasterPrompt = domain.SeedMasterPrompt
  652. }
  653. form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks)
  654. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  655. templates, err := u.templateSvc.ListTemplates(r.Context())
  656. if err != nil {
  657. return buildNewPageData{}, err
  658. }
  659. drafts, err := u.draftSvc.ListDrafts(r.Context(), 50)
  660. if err != nil {
  661. return buildNewPageData{}, err
  662. }
  663. data := buildNewPageData{
  664. pageData: page,
  665. Templates: templates,
  666. Drafts: drafts,
  667. SelectedDraftID: selectedDraftID,
  668. SelectedTemplateID: selectedTemplateID,
  669. SuggestionStateJSON: encodeSuggestionStateJSON(suggestionState),
  670. ShowDebug: parseDebugMode(r),
  671. Form: form,
  672. }
  673. if selectedTemplateID <= 0 {
  674. return data, nil
  675. }
  676. detail, err := u.templateSvc.GetTemplateDetail(r.Context(), selectedTemplateID)
  677. if err != nil || detail.Manifest == nil {
  678. return data, nil
  679. }
  680. data.SelectedManifestID = detail.Manifest.ID
  681. data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues, suggestionState.ByFieldPath)
  682. data.EnabledFields = data.EditableFields
  683. data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
  684. return data, nil
  685. }
  686. func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput {
  687. settings := u.loadPromptSettings(ctx)
  688. form.MasterPrompt = settings.MasterPrompt
  689. form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks)
  690. if strings.TrimSpace(form.DraftID) == "" {
  691. return form
  692. }
  693. draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID))
  694. if err != nil || draft == nil {
  695. return form
  696. }
  697. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  698. form.MasterPrompt = settings.MasterPrompt
  699. form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
  700. form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
  701. return form
  702. }
  703. func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string, suggestions map[string]domain.DraftSuggestion) ([]buildFieldView, []buildFieldSectionView) {
  704. sectionOrder := []string{
  705. domain.WebsiteSectionHero,
  706. domain.WebsiteSectionIntro,
  707. domain.WebsiteSectionServices,
  708. domain.WebsiteSectionAbout,
  709. domain.WebsiteSectionTeam,
  710. domain.WebsiteSectionTestimonials,
  711. domain.WebsiteSectionCTA,
  712. domain.WebsiteSectionContact,
  713. domain.WebsiteSectionGallery,
  714. domain.WebsiteSectionFooter,
  715. domain.WebsiteSectionOther,
  716. }
  717. sectionDescriptions := map[string]string{
  718. domain.WebsiteSectionHero: "Headline-nahe Felder, bevorzugt nach Block-ID gruppiert.",
  719. domain.WebsiteSectionIntro: "Intro-/Einleitungs-Felder, bevorzugt nach Block-ID gruppiert.",
  720. domain.WebsiteSectionServices: "Services-Felder, bevorzugt nach Block-ID gruppiert.",
  721. domain.WebsiteSectionAbout: "About-Felder, bevorzugt nach Block-ID gruppiert.",
  722. domain.WebsiteSectionTeam: "Team-Felder, bevorzugt nach Block-ID gruppiert.",
  723. domain.WebsiteSectionTestimonials: "Testimonial-Felder, bevorzugt nach Block-ID gruppiert.",
  724. domain.WebsiteSectionCTA: "CTA-/Highlight-Felder, bevorzugt nach Block-ID gruppiert.",
  725. domain.WebsiteSectionContact: "Kontakt-Felder, bevorzugt nach Block-ID gruppiert.",
  726. domain.WebsiteSectionGallery: "Media/Gallery-Felder, Bildfelder bleiben im MVP nicht editierbar.",
  727. domain.WebsiteSectionFooter: "Footer-Felder, bevorzugt nach Block-ID gruppiert.",
  728. domain.WebsiteSectionOther: "Aktive Textfelder ausserhalb der Kern-Sections, bevorzugt nach Block-ID gruppiert.",
  729. }
  730. sectionsByKey := make(map[string]buildFieldSectionView, len(sectionOrder))
  731. pendingByKey := make(map[string][]pendingField, len(sectionOrder))
  732. for _, key := range sectionOrder {
  733. sectionsByKey[key] = buildFieldSectionView{
  734. Key: key,
  735. Title: domain.WebsiteSectionLabel(key),
  736. Description: sectionDescriptions[key],
  737. }
  738. }
  739. for _, f := range fields {
  740. targetSection := preferredBuildSection(f)
  741. if isMediaOrGalleryField(f) || targetSection == domain.WebsiteSectionGallery {
  742. labelFallback := domain.WebsiteSectionLabel(domain.WebsiteSectionGallery) + " - " + humanizeKey(f.KeyName)
  743. if blockID := extractBlockID(f); blockID != "" {
  744. labelFallback = "Media - " + blockGroupTitle(blockID)
  745. }
  746. media := sectionsByKey[domain.WebsiteSectionGallery]
  747. media.DisabledFields = append(media.DisabledFields, buildFieldView{
  748. AnchorID: fieldAnchorID(f.Path),
  749. Path: f.Path,
  750. DisplayLabel: effectiveLabel(f, labelFallback),
  751. SampleValue: f.SampleValue,
  752. Value: "",
  753. })
  754. sectionsByKey[domain.WebsiteSectionGallery] = media
  755. continue
  756. }
  757. if !f.IsEnabled || !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
  758. continue
  759. }
  760. suggestion := suggestions[f.Path]
  761. pf := pendingField{
  762. Field: f,
  763. View: buildFieldView{
  764. AnchorID: fieldAnchorID(f.Path),
  765. Path: f.Path,
  766. DisplayLabel: effectiveLabel(f, humanizeKey(f.KeyName)),
  767. SampleValue: f.SampleValue,
  768. Value: strings.TrimSpace(fieldValues[f.Path]),
  769. SuggestedValue: strings.TrimSpace(suggestion.Value),
  770. SuggestionReason: strings.TrimSpace(suggestion.Reason),
  771. SuggestionStatus: strings.TrimSpace(suggestion.Status),
  772. SuggestionSource: strings.TrimSpace(suggestion.Source),
  773. },
  774. }
  775. pendingByKey[targetSection] = append(pendingByKey[targetSection], pf)
  776. }
  777. for _, key := range sectionOrder {
  778. section := sectionsByKey[key]
  779. items := pendingByKey[key]
  780. switch key {
  781. case domain.WebsiteSectionServices:
  782. section = applyServicesGrouping(section, items)
  783. case domain.WebsiteSectionTestimonials:
  784. section = applyTestimonialsGrouping(section, items)
  785. case domain.WebsiteSectionHero, domain.WebsiteSectionIntro, domain.WebsiteSectionAbout, domain.WebsiteSectionTeam, domain.WebsiteSectionCTA, domain.WebsiteSectionContact, domain.WebsiteSectionFooter:
  786. section = applyTextGrouping(section, items)
  787. case domain.WebsiteSectionOther:
  788. section = applyOtherGrouping(section, items)
  789. case domain.WebsiteSectionGallery:
  790. // Gallery fields are handled as disabled entries only in this MVP.
  791. default:
  792. section = applyOtherGrouping(section, items)
  793. }
  794. sectionsByKey[key] = section
  795. }
  796. sections := make([]buildFieldSectionView, 0, len(sectionOrder))
  797. for _, key := range sectionOrder {
  798. sections = append(sections, sectionsByKey[key])
  799. }
  800. return assignEditableIndexes(sections)
  801. }
  802. func applyServicesGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  803. return applyBlockFirstGrouping(section, fields, "Services", applyServicesGroupingFallback)
  804. }
  805. func applyServicesGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  806. titles := make([]pendingField, 0)
  807. descriptions := make([]pendingField, 0)
  808. other := make([]buildFieldView, 0)
  809. for _, pf := range fields {
  810. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  811. switch {
  812. case strings.HasPrefix(key, "servicestitle_") || strings.HasPrefix(key, "servicestitle"):
  813. titles = append(titles, pf)
  814. case strings.HasPrefix(key, "servicesdescription_") || strings.HasPrefix(key, "servicesdescription"):
  815. descriptions = append(descriptions, pf)
  816. default:
  817. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Services - "+humanizeKey(pf.Field.KeyName))
  818. other = append(other, pf.View)
  819. }
  820. }
  821. maxCount := len(titles)
  822. if len(descriptions) > maxCount {
  823. maxCount = len(descriptions)
  824. }
  825. for i := 0; i < maxCount; i++ {
  826. block := buildFieldGroupView{Title: fmt.Sprintf("Service %d", i+1)}
  827. if i < len(titles) {
  828. item := titles[i]
  829. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Titel", i+1))
  830. block.Fields = append(block.Fields, item.View)
  831. }
  832. if i < len(descriptions) {
  833. item := descriptions[i]
  834. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Service %d - Beschreibung", i+1))
  835. block.Fields = append(block.Fields, item.View)
  836. }
  837. if len(block.Fields) > 0 {
  838. section.EditableGroups = append(section.EditableGroups, block)
  839. }
  840. }
  841. section.EditableFields = append(section.EditableFields, other...)
  842. return section
  843. }
  844. func applyTestimonialsGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  845. return applyBlockFirstGrouping(section, fields, "Testimonials", applyTestimonialsGroupingFallback)
  846. }
  847. func applyTestimonialsGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  848. names := make([]pendingField, 0)
  849. titles := make([]pendingField, 0)
  850. descriptions := make([]pendingField, 0)
  851. other := make([]buildFieldView, 0)
  852. for _, pf := range fields {
  853. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  854. switch {
  855. case strings.HasPrefix(key, "testimonialsname_") || strings.HasPrefix(key, "testimonialsname"):
  856. names = append(names, pf)
  857. case strings.HasPrefix(key, "testimonialstitle_") || strings.HasPrefix(key, "testimonialstitle"):
  858. titles = append(titles, pf)
  859. case strings.HasPrefix(key, "testimonialsdescription_") || strings.HasPrefix(key, "testimonialsdescription"):
  860. descriptions = append(descriptions, pf)
  861. default:
  862. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Testimonials - "+humanizeKey(pf.Field.KeyName))
  863. other = append(other, pf.View)
  864. }
  865. }
  866. maxCount := len(names)
  867. if len(titles) > maxCount {
  868. maxCount = len(titles)
  869. }
  870. if len(descriptions) > maxCount {
  871. maxCount = len(descriptions)
  872. }
  873. for i := 0; i < maxCount; i++ {
  874. block := buildFieldGroupView{Title: fmt.Sprintf("Testimonial %d", i+1)}
  875. if i < len(names) {
  876. item := names[i]
  877. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Name", i+1))
  878. block.Fields = append(block.Fields, item.View)
  879. }
  880. if i < len(titles) {
  881. item := titles[i]
  882. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Titel", i+1))
  883. block.Fields = append(block.Fields, item.View)
  884. }
  885. if i < len(descriptions) {
  886. item := descriptions[i]
  887. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Testimonial %d - Beschreibung", i+1))
  888. block.Fields = append(block.Fields, item.View)
  889. }
  890. if len(block.Fields) > 0 {
  891. section.EditableGroups = append(section.EditableGroups, block)
  892. }
  893. }
  894. section.EditableFields = append(section.EditableFields, other...)
  895. return section
  896. }
  897. func applyTextGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  898. return applyBlockFirstGrouping(section, fields, "Text", applyTextGroupingFallback)
  899. }
  900. func applyTextGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  901. titles := make([]pendingField, 0)
  902. descriptions := make([]pendingField, 0)
  903. names := make([]pendingField, 0)
  904. other := make([]buildFieldView, 0)
  905. for _, pf := range fields {
  906. key := strings.ToLower(strings.TrimSpace(pf.Field.KeyName))
  907. switch {
  908. case strings.HasPrefix(key, "texttitle_") || strings.HasPrefix(key, "texttitle") || strings.HasPrefix(key, "exttitle_") || strings.HasPrefix(key, "exttitle"):
  909. titles = append(titles, pf)
  910. case strings.HasPrefix(key, "textdescription_") || strings.HasPrefix(key, "textdescription") || strings.HasPrefix(key, "extdescription_") || strings.HasPrefix(key, "extdescription"):
  911. descriptions = append(descriptions, pf)
  912. case strings.HasPrefix(key, "textname_") || strings.HasPrefix(key, "textname") || strings.HasPrefix(key, "extname_") || strings.HasPrefix(key, "extname"):
  913. names = append(names, pf)
  914. default:
  915. pf.View.DisplayLabel = effectiveLabel(pf.Field, "Text - "+humanizeKey(pf.Field.KeyName))
  916. other = append(other, pf.View)
  917. }
  918. }
  919. maxCount := len(titles)
  920. if len(descriptions) > maxCount {
  921. maxCount = len(descriptions)
  922. }
  923. if len(names) > maxCount {
  924. maxCount = len(names)
  925. }
  926. for i := 0; i < maxCount; i++ {
  927. block := buildFieldGroupView{Title: fmt.Sprintf("Textblock %d", i+1)}
  928. if i < len(titles) {
  929. item := titles[i]
  930. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Titel", i+1))
  931. block.Fields = append(block.Fields, item.View)
  932. }
  933. if i < len(descriptions) {
  934. item := descriptions[i]
  935. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Beschreibung", i+1))
  936. block.Fields = append(block.Fields, item.View)
  937. }
  938. if i < len(names) {
  939. item := names[i]
  940. item.View.DisplayLabel = effectiveLabel(item.Field, fmt.Sprintf("Textblock %d - Name", i+1))
  941. block.Fields = append(block.Fields, item.View)
  942. }
  943. if len(block.Fields) > 0 {
  944. section.EditableGroups = append(section.EditableGroups, block)
  945. }
  946. }
  947. section.EditableFields = append(section.EditableFields, other...)
  948. return section
  949. }
  950. func applyOtherGrouping(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  951. return applyBlockFirstGrouping(section, fields, "", applyOtherGroupingFallback)
  952. }
  953. func applyOtherGroupingFallback(section buildFieldSectionView, fields []pendingField) buildFieldSectionView {
  954. for _, pf := range fields {
  955. pf.View.DisplayLabel = effectiveLabel(pf.Field, normalizedSectionTitle(pf.Field.Section)+" - "+humanizeKey(pf.Field.KeyName))
  956. section.EditableFields = append(section.EditableFields, pf.View)
  957. }
  958. sort.SliceStable(section.EditableFields, func(i, j int) bool {
  959. return section.EditableFields[i].Path < section.EditableFields[j].Path
  960. })
  961. return section
  962. }
  963. func applyBlockFirstGrouping(section buildFieldSectionView, fields []pendingField, fallbackPrefix string, fallback func(buildFieldSectionView, []pendingField) buildFieldSectionView) buildFieldSectionView {
  964. grouped := map[string][]pendingField{}
  965. withoutBlockID := make([]pendingField, 0)
  966. for _, pf := range fields {
  967. blockID := extractBlockID(pf.Field)
  968. if blockID == "" {
  969. withoutBlockID = append(withoutBlockID, pf)
  970. continue
  971. }
  972. grouped[blockID] = append(grouped[blockID], pf)
  973. }
  974. if len(grouped) > 0 {
  975. blockIDs := make([]string, 0, len(grouped))
  976. for blockID := range grouped {
  977. blockIDs = append(blockIDs, blockID)
  978. }
  979. sort.SliceStable(blockIDs, func(i, j int) bool {
  980. li, lj := blockSortRank(blockIDs[i]), blockSortRank(blockIDs[j])
  981. if li != lj {
  982. return li < lj
  983. }
  984. return blockIDs[i] < blockIDs[j]
  985. })
  986. for _, blockID := range blockIDs {
  987. items := grouped[blockID]
  988. sort.SliceStable(items, func(i, j int) bool {
  989. ri := deriveFieldRole(items[i].Field.KeyName)
  990. rj := deriveFieldRole(items[j].Field.KeyName)
  991. if ri.Order != rj.Order {
  992. return ri.Order < rj.Order
  993. }
  994. return items[i].Field.Path < items[j].Field.Path
  995. })
  996. group := buildFieldGroupView{Title: blockGroupTitle(blockID)}
  997. for _, item := range items {
  998. role := deriveFieldRole(item.Field.KeyName)
  999. fallbackLabel := role.Label
  1000. if fallbackLabel == "" {
  1001. fallbackLabel = humanizeKey(item.Field.KeyName)
  1002. }
  1003. item.View.DisplayLabel = effectiveLabel(item.Field, fallbackLabel)
  1004. group.Fields = append(group.Fields, item.View)
  1005. }
  1006. if len(group.Fields) > 0 {
  1007. section.EditableGroups = append(section.EditableGroups, group)
  1008. }
  1009. }
  1010. }
  1011. if len(withoutBlockID) > 0 {
  1012. if fallback != nil {
  1013. return fallback(section, withoutBlockID)
  1014. }
  1015. for _, pf := range withoutBlockID {
  1016. labelPrefix := fallbackPrefix
  1017. if labelPrefix == "" {
  1018. labelPrefix = normalizedSectionTitle(pf.Field.Section)
  1019. }
  1020. pf.View.DisplayLabel = effectiveLabel(pf.Field, labelPrefix+" - "+humanizeKey(pf.Field.KeyName))
  1021. section.EditableFields = append(section.EditableFields, pf.View)
  1022. }
  1023. }
  1024. return section
  1025. }
  1026. func extractBlockID(f domain.TemplateField) string {
  1027. candidates := []string{f.KeyName, f.Path}
  1028. for _, candidate := range candidates {
  1029. normalized := strings.ToLower(strings.TrimSpace(candidate))
  1030. if normalized == "" {
  1031. continue
  1032. }
  1033. if match := blockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1034. return match[1]
  1035. }
  1036. if match := looseBlockIDPattern.FindStringSubmatch(normalized); len(match) > 1 {
  1037. return match[1]
  1038. }
  1039. }
  1040. return ""
  1041. }
  1042. func blockGroupTitle(blockID string) string {
  1043. blockID = strings.ToLower(strings.TrimSpace(blockID))
  1044. if blockID == "" {
  1045. return "Unbekannter Block"
  1046. }
  1047. if known, ok := knownBlockAreas[blockID]; ok {
  1048. return fmt.Sprintf("%s (%s)", known, blockID)
  1049. }
  1050. return "Block " + blockID
  1051. }
  1052. func blockSortRank(blockID string) int {
  1053. switch strings.ToLower(strings.TrimSpace(blockID)) {
  1054. case "m1710":
  1055. return 10
  1056. case "c7886":
  1057. return 20
  1058. case "r4830":
  1059. return 30
  1060. case "c2929":
  1061. return 40
  1062. case "r4748":
  1063. return 50
  1064. case "r1508":
  1065. return 60
  1066. case "c1165":
  1067. return 70
  1068. case "m4178":
  1069. return 80
  1070. default:
  1071. return 1000
  1072. }
  1073. }
  1074. func deriveFieldRole(key string) fieldRole {
  1075. normalized := strings.ToLower(strings.TrimSpace(key))
  1076. switch {
  1077. case strings.Contains(normalized, "subtitle"):
  1078. return fieldRole{Label: "Untertitel", Order: 15}
  1079. case strings.Contains(normalized, "title"):
  1080. return fieldRole{Label: "Titel", Order: 20}
  1081. case strings.Contains(normalized, "description"):
  1082. return fieldRole{Label: "Beschreibung", Order: 30}
  1083. case strings.Contains(normalized, "name"):
  1084. return fieldRole{Label: "Name", Order: 40}
  1085. case strings.Contains(normalized, "button") || strings.Contains(normalized, "cta"):
  1086. return fieldRole{Label: "CTA Text", Order: 50}
  1087. default:
  1088. return fieldRole{Label: humanizeKey(key), Order: 100}
  1089. }
  1090. }
  1091. func assignEditableIndexes(sections []buildFieldSectionView) ([]buildFieldView, []buildFieldSectionView) {
  1092. editable := make([]buildFieldView, 0)
  1093. nextIndex := 0
  1094. for si := range sections {
  1095. for gi := range sections[si].EditableGroups {
  1096. for fi := range sections[si].EditableGroups[gi].Fields {
  1097. sections[si].EditableGroups[gi].Fields[fi].Index = nextIndex
  1098. editable = append(editable, sections[si].EditableGroups[gi].Fields[fi])
  1099. nextIndex++
  1100. }
  1101. }
  1102. for fi := range sections[si].EditableFields {
  1103. sections[si].EditableFields[fi].Index = nextIndex
  1104. editable = append(editable, sections[si].EditableFields[fi])
  1105. nextIndex++
  1106. }
  1107. }
  1108. return editable, sections
  1109. }
  1110. func preferredBuildSection(f domain.TemplateField) string {
  1111. websiteSection := strings.TrimSpace(f.WebsiteSection)
  1112. if websiteSection != "" {
  1113. normalized := domain.NormalizeWebsiteSection(websiteSection)
  1114. if normalized == domain.WebsiteSectionServiceItem {
  1115. return domain.WebsiteSectionServices
  1116. }
  1117. return normalized
  1118. }
  1119. return fallbackBuildSection(f)
  1120. }
  1121. func fallbackBuildSection(f domain.TemplateField) string {
  1122. switch normalizedSection(f.Section) {
  1123. case "services":
  1124. return domain.WebsiteSectionServices
  1125. case "testimonials":
  1126. return domain.WebsiteSectionTestimonials
  1127. case "text":
  1128. if isMediaOrGalleryField(f) {
  1129. return domain.WebsiteSectionGallery
  1130. }
  1131. return domain.WebsiteSectionOther
  1132. default:
  1133. if isMediaOrGalleryField(f) {
  1134. return domain.WebsiteSectionGallery
  1135. }
  1136. return domain.WebsiteSectionOther
  1137. }
  1138. }
  1139. func normalizedSection(raw string) string {
  1140. section := strings.ToLower(strings.TrimSpace(raw))
  1141. switch section {
  1142. case "ext":
  1143. return "text"
  1144. default:
  1145. return section
  1146. }
  1147. }
  1148. func normalizedSectionTitle(raw string) string {
  1149. switch normalizedSection(raw) {
  1150. case "text":
  1151. return "Text"
  1152. case "services":
  1153. return "Services"
  1154. case "testimonials":
  1155. return "Testimonials"
  1156. case "gallery", "media":
  1157. return "Media"
  1158. default:
  1159. return "Feld"
  1160. }
  1161. }
  1162. func isMediaOrGalleryField(f domain.TemplateField) bool {
  1163. if strings.EqualFold(strings.TrimSpace(f.FieldKind), "image") {
  1164. return true
  1165. }
  1166. section := strings.ToLower(strings.TrimSpace(f.Section))
  1167. key := strings.ToLower(strings.TrimSpace(f.KeyName))
  1168. path := strings.ToLower(strings.TrimSpace(f.Path))
  1169. if section == "gallery" || section == "media" {
  1170. return true
  1171. }
  1172. hints := []string{"gallery", "image", "img", "photo", "picture"}
  1173. for _, hint := range hints {
  1174. if strings.Contains(section, hint) || strings.Contains(key, hint) || strings.Contains(path, hint) {
  1175. return true
  1176. }
  1177. }
  1178. return false
  1179. }
  1180. func effectiveLabel(f domain.TemplateField, fallback string) string {
  1181. if !isRawPathLikeLabel(f.DisplayLabel, f.Path) {
  1182. return strings.TrimSpace(f.DisplayLabel)
  1183. }
  1184. return strings.TrimSpace(fallback)
  1185. }
  1186. func isRawPathLikeLabel(label string, path string) bool {
  1187. l := strings.TrimSpace(label)
  1188. if l == "" {
  1189. return true
  1190. }
  1191. if strings.EqualFold(l, strings.TrimSpace(path)) {
  1192. return true
  1193. }
  1194. if strings.Contains(l, ".") || strings.Contains(l, "_") {
  1195. return true
  1196. }
  1197. return false
  1198. }
  1199. func humanizeKey(key string) string {
  1200. raw := strings.TrimSpace(key)
  1201. if raw == "" {
  1202. return "Inhalt"
  1203. }
  1204. base := raw
  1205. if idx := strings.Index(base, "_"); idx > 0 {
  1206. base = base[:idx]
  1207. }
  1208. runes := make([]rune, 0, len(base)+4)
  1209. for i, r := range base {
  1210. if i > 0 && unicode.IsUpper(r) {
  1211. runes = append(runes, ' ')
  1212. }
  1213. runes = append(runes, r)
  1214. }
  1215. human := strings.TrimSpace(string(runes))
  1216. if human == "" {
  1217. return "Inhalt"
  1218. }
  1219. words := strings.Fields(strings.ToLower(human))
  1220. for i := range words {
  1221. if len(words[i]) > 0 {
  1222. words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
  1223. }
  1224. }
  1225. return strings.Join(words, " ")
  1226. }
  1227. func buildFormInputFromRequest(r *http.Request) buildFormInput {
  1228. form := buildFormInput{
  1229. DraftID: strings.TrimSpace(r.FormValue("draft_id")),
  1230. DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
  1231. DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
  1232. DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
  1233. RequestName: strings.TrimSpace(r.FormValue("request_name")),
  1234. CompanyName: strings.TrimSpace(r.FormValue("company_name")),
  1235. BusinessType: strings.TrimSpace(r.FormValue("business_type")),
  1236. Username: strings.TrimSpace(r.FormValue("username")),
  1237. Email: strings.TrimSpace(r.FormValue("email")),
  1238. Phone: strings.TrimSpace(r.FormValue("phone")),
  1239. OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
  1240. StartDate: strings.TrimSpace(r.FormValue("start_date")),
  1241. Mission: strings.TrimSpace(r.FormValue("mission")),
  1242. DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
  1243. DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
  1244. SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
  1245. AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
  1246. AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
  1247. AddressCity: strings.TrimSpace(r.FormValue("address_city")),
  1248. AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
  1249. AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
  1250. AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
  1251. WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
  1252. WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
  1253. LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
  1254. MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
  1255. AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
  1256. ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
  1257. }
  1258. return form
  1259. }
  1260. func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput {
  1261. form := buildFormInput{
  1262. DraftID: draft.ID,
  1263. DraftSource: draft.Source,
  1264. DraftStatus: draft.Status,
  1265. DraftNotes: draft.Notes,
  1266. RequestName: draft.RequestName,
  1267. }
  1268. mergeGlobalDataIntoForm(&form, draft.GlobalDataJSON)
  1269. mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
  1270. return form
  1271. }
  1272. func parseBuildFieldValues(r *http.Request) map[string]string {
  1273. fieldValues := map[string]string{}
  1274. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("field_count")))
  1275. for i := 0; i < count; i++ {
  1276. path := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_path_%d", i)))
  1277. value := strings.TrimSpace(r.FormValue(fmt.Sprintf("field_value_%d", i)))
  1278. if path != "" {
  1279. fieldValues[path] = value
  1280. }
  1281. }
  1282. return fieldValues
  1283. }
  1284. func parseSuggestionStateFromRequest(r *http.Request) domain.DraftSuggestionState {
  1285. return parseSuggestionStateRaw(strings.TrimSpace(r.FormValue("suggestion_state_json")))
  1286. }
  1287. func parseSuggestionStateJSON(raw []byte) domain.DraftSuggestionState {
  1288. return parseSuggestionStateRaw(strings.TrimSpace(string(raw)))
  1289. }
  1290. func parseSuggestionStateRaw(raw string) domain.DraftSuggestionState {
  1291. if strings.TrimSpace(raw) == "" {
  1292. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1293. }
  1294. var state domain.DraftSuggestionState
  1295. if err := json.Unmarshal([]byte(raw), &state); err != nil {
  1296. return domain.DraftSuggestionState{ByFieldPath: map[string]domain.DraftSuggestion{}}
  1297. }
  1298. if state.ByFieldPath == nil {
  1299. state.ByFieldPath = map[string]domain.DraftSuggestion{}
  1300. }
  1301. return state
  1302. }
  1303. func encodeSuggestionStateJSON(state domain.DraftSuggestionState) string {
  1304. normalized := state
  1305. if normalized.ByFieldPath == nil {
  1306. normalized.ByFieldPath = map[string]domain.DraftSuggestion{}
  1307. }
  1308. raw, err := json.Marshal(normalized)
  1309. if err != nil {
  1310. return `{"byFieldPath":{}}`
  1311. }
  1312. return string(raw)
  1313. }
  1314. func parseAutofillAction(raw string) (string, string) {
  1315. value := strings.TrimSpace(raw)
  1316. if value == "" {
  1317. return "", ""
  1318. }
  1319. parts := strings.SplitN(value, "::", 2)
  1320. action := strings.TrimSpace(parts[0])
  1321. if len(parts) == 1 {
  1322. return action, ""
  1323. }
  1324. return action, strings.TrimSpace(parts[1])
  1325. }
  1326. func parseDebugMode(r *http.Request) bool {
  1327. if r == nil {
  1328. return false
  1329. }
  1330. value := strings.ToLower(strings.TrimSpace(r.FormValue("debug")))
  1331. switch value {
  1332. case "1", "true", "on", "yes":
  1333. return true
  1334. default:
  1335. return false
  1336. }
  1337. }
  1338. func fieldAnchorID(fieldPath string) string {
  1339. path := strings.TrimSpace(strings.ToLower(fieldPath))
  1340. if path == "" {
  1341. return ""
  1342. }
  1343. var b strings.Builder
  1344. b.Grow(len(path) + len("field-"))
  1345. b.WriteString("field-")
  1346. lastDash := false
  1347. for _, r := range path {
  1348. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
  1349. b.WriteRune(r)
  1350. lastDash = false
  1351. continue
  1352. }
  1353. if !lastDash {
  1354. b.WriteByte('-')
  1355. lastDash = true
  1356. }
  1357. }
  1358. out := strings.Trim(b.String(), "-")
  1359. if out == "" || out == "field" {
  1360. return "field-anchor"
  1361. }
  1362. return out
  1363. }
  1364. func extractGlobalDataFromFinalPayload(raw []byte) ([]byte, error) {
  1365. if len(raw) == 0 {
  1366. return nil, nil
  1367. }
  1368. var payload struct {
  1369. GlobalData map[string]any `json:"globalData"`
  1370. }
  1371. if err := json.Unmarshal(raw, &payload); err != nil {
  1372. return nil, err
  1373. }
  1374. if len(payload.GlobalData) == 0 {
  1375. return nil, nil
  1376. }
  1377. data, err := json.Marshal(payload.GlobalData)
  1378. if err != nil {
  1379. return nil, err
  1380. }
  1381. return data, nil
  1382. }
  1383. func parseFieldValuesJSON(raw []byte) map[string]string {
  1384. out := map[string]string{}
  1385. if len(raw) == 0 {
  1386. return out
  1387. }
  1388. _ = json.Unmarshal(raw, &out)
  1389. return out
  1390. }
  1391. func mergeGlobalDataIntoForm(form *buildFormInput, raw []byte) {
  1392. if form == nil || len(raw) == 0 {
  1393. return
  1394. }
  1395. var global map[string]any
  1396. if err := json.Unmarshal(raw, &global); err != nil {
  1397. return
  1398. }
  1399. form.CompanyName = getString(global["companyName"])
  1400. form.BusinessType = getString(global["businessType"])
  1401. form.Username = getString(global["username"])
  1402. form.Email = getString(global["email"])
  1403. form.Phone = getString(global["phone"])
  1404. form.OrgNumber = getString(global["orgNumber"])
  1405. form.StartDate = getString(global["startDate"])
  1406. form.Mission = getString(global["mission"])
  1407. form.DescriptionShort = getString(global["descriptionShort"])
  1408. form.DescriptionLong = getString(global["descriptionLong"])
  1409. form.SiteLanguage = getString(global["siteLanguage"])
  1410. address, _ := global["address"].(map[string]any)
  1411. form.AddressLine1 = getString(address["line1"])
  1412. form.AddressLine2 = getString(address["line2"])
  1413. form.AddressCity = getString(address["city"])
  1414. form.AddressRegion = getString(address["region"])
  1415. form.AddressZIP = getString(address["zip"])
  1416. form.AddressCountry = getString(address["country"])
  1417. }
  1418. func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) {
  1419. if form == nil || len(raw) == 0 {
  1420. return
  1421. }
  1422. var ctx domain.DraftContext
  1423. if err := json.Unmarshal(raw, &ctx); err != nil {
  1424. return
  1425. }
  1426. if strings.TrimSpace(form.BusinessType) == "" {
  1427. form.BusinessType = strings.TrimSpace(ctx.LLM.BusinessType)
  1428. }
  1429. form.WebsiteURL = strings.TrimSpace(ctx.LLM.WebsiteURL)
  1430. form.WebsiteSummary = strings.TrimSpace(ctx.LLM.WebsiteSummary)
  1431. form.LocaleStyle = strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle)
  1432. form.MarketStyle = strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle)
  1433. form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode)
  1434. form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  1435. form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions)
  1436. form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks)
  1437. }
  1438. func buildGlobalDataFromForm(form buildFormInput) map[string]any {
  1439. return buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
  1440. CompanyName: form.CompanyName,
  1441. BusinessType: form.BusinessType,
  1442. Username: form.Username,
  1443. Email: form.Email,
  1444. Phone: form.Phone,
  1445. OrgNumber: form.OrgNumber,
  1446. StartDate: form.StartDate,
  1447. Mission: form.Mission,
  1448. DescriptionShort: form.DescriptionShort,
  1449. DescriptionLong: form.DescriptionLong,
  1450. SiteLanguage: form.SiteLanguage,
  1451. AddressLine1: form.AddressLine1,
  1452. AddressLine2: form.AddressLine2,
  1453. AddressCity: form.AddressCity,
  1454. AddressRegion: form.AddressRegion,
  1455. AddressZIP: form.AddressZIP,
  1456. AddressCountry: form.AddressCountry,
  1457. })
  1458. }
  1459. func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext {
  1460. businessType := strings.TrimSpace(form.BusinessType)
  1461. if businessType == "" {
  1462. businessType = strings.TrimSpace(getString(globalData["businessType"]))
  1463. }
  1464. return &domain.DraftContext{
  1465. IntakeSource: strings.TrimSpace(form.DraftSource),
  1466. LLM: domain.DraftLLMContext{
  1467. BusinessType: businessType,
  1468. WebsiteURL: strings.TrimSpace(form.WebsiteURL),
  1469. WebsiteSummary: strings.TrimSpace(form.WebsiteSummary),
  1470. StyleProfile: domain.DraftStyleProfile{
  1471. LocaleStyle: strings.TrimSpace(form.LocaleStyle),
  1472. MarketStyle: strings.TrimSpace(form.MarketStyle),
  1473. AddressMode: strings.TrimSpace(form.AddressMode),
  1474. ContentTone: strings.TrimSpace(form.ContentTone),
  1475. PromptInstructions: strings.TrimSpace(form.PromptInstructions),
  1476. },
  1477. Prompt: domain.DraftPromptConfig{
  1478. Blocks: clonePromptBlocks(form.PromptBlocks),
  1479. },
  1480. },
  1481. }
  1482. }
  1483. func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings {
  1484. settings := domain.AppSettings{
  1485. QCBaseURL: u.cfg.QCBaseURL,
  1486. QCBearerTokenEncrypted: u.cfg.QCToken,
  1487. LanguageOutputMode: "EN",
  1488. JobPollIntervalSeconds: u.cfg.PollIntervalSeconds,
  1489. JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds,
  1490. MasterPrompt: domain.SeedMasterPrompt,
  1491. PromptBlocks: domain.DefaultPromptBlocks(),
  1492. }
  1493. if u.settings == nil {
  1494. return settings
  1495. }
  1496. stored, err := u.settings.GetSettings(ctx)
  1497. if err != nil || stored == nil {
  1498. return settings
  1499. }
  1500. if strings.TrimSpace(stored.QCBaseURL) != "" {
  1501. settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL)
  1502. }
  1503. if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" {
  1504. settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted)
  1505. }
  1506. if strings.TrimSpace(stored.LanguageOutputMode) != "" {
  1507. settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode)
  1508. }
  1509. if stored.JobPollIntervalSeconds > 0 {
  1510. settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds
  1511. }
  1512. if stored.JobPollTimeoutSeconds > 0 {
  1513. settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds
  1514. }
  1515. settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt)
  1516. settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks)
  1517. return settings
  1518. }
  1519. func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig {
  1520. count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count")))
  1521. if count <= 0 {
  1522. return domain.DefaultPromptBlocks()
  1523. }
  1524. out := make([]domain.PromptBlockConfig, 0, count)
  1525. for i := 0; i < count; i++ {
  1526. id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i)))
  1527. if id == "" {
  1528. continue
  1529. }
  1530. out = append(out, domain.PromptBlockConfig{
  1531. ID: id,
  1532. Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))),
  1533. Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))),
  1534. Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on",
  1535. })
  1536. }
  1537. return domain.NormalizePromptBlocks(out)
  1538. }
  1539. func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1540. if len(blocks) == 0 {
  1541. return nil
  1542. }
  1543. out := make([]domain.PromptBlockConfig, len(blocks))
  1544. copy(out, blocks)
  1545. return out
  1546. }
  1547. func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig {
  1548. merged := make([]domain.PromptBlockConfig, 0, len(defaults))
  1549. merged = append(merged, clonePromptBlocks(defaults)...)
  1550. overrides := make(map[string]domain.PromptBlockConfig, len(current))
  1551. for _, block := range current {
  1552. id := strings.TrimSpace(block.ID)
  1553. if id == "" {
  1554. continue
  1555. }
  1556. overrides[id] = block
  1557. }
  1558. for i := range merged {
  1559. if override, ok := overrides[merged[i].ID]; ok {
  1560. if strings.TrimSpace(override.Label) != "" {
  1561. merged[i].Label = strings.TrimSpace(override.Label)
  1562. }
  1563. if strings.TrimSpace(override.Instruction) != "" {
  1564. merged[i].Instruction = strings.TrimSpace(override.Instruction)
  1565. }
  1566. merged[i].Enabled = override.Enabled
  1567. delete(overrides, merged[i].ID)
  1568. }
  1569. }
  1570. for _, override := range overrides {
  1571. merged = append(merged, override)
  1572. }
  1573. return domain.NormalizePromptBlocks(merged)
  1574. }
  1575. func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig {
  1576. out := clonePromptBlocks(blocks)
  1577. if len(out) == 0 {
  1578. return out
  1579. }
  1580. for i := range out {
  1581. switch out[i].ID {
  1582. case "business_type":
  1583. if strings.TrimSpace(form.BusinessType) != "" {
  1584. out[i].Enabled = true
  1585. }
  1586. case "website_summary":
  1587. if strings.TrimSpace(form.WebsiteSummary) != "" {
  1588. out[i].Enabled = true
  1589. }
  1590. case "address_mode":
  1591. if strings.TrimSpace(form.AddressMode) != "" {
  1592. out[i].Enabled = true
  1593. }
  1594. case "content_tone":
  1595. if strings.TrimSpace(form.ContentTone) != "" {
  1596. out[i].Enabled = true
  1597. }
  1598. case "free_instructions":
  1599. if strings.TrimSpace(form.PromptInstructions) != "" {
  1600. out[i].Enabled = true
  1601. }
  1602. }
  1603. }
  1604. return out
  1605. }
  1606. func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView {
  1607. if len(mappingResult.BySlot) == 0 {
  1608. return nil
  1609. }
  1610. slotKeys := make([]string, 0, len(mappingResult.BySlot))
  1611. for slot := range mappingResult.BySlot {
  1612. slotKeys = append(slotKeys, slot)
  1613. }
  1614. sort.Strings(slotKeys)
  1615. out := make([]semanticSlotPreviewView, 0, len(slotKeys))
  1616. for _, slot := range slotKeys {
  1617. targets := mappingResult.BySlot[slot]
  1618. examples := make([]string, 0, 2)
  1619. for i := 0; i < len(targets) && i < 2; i++ {
  1620. examples = append(examples, targets[i].FieldPath)
  1621. }
  1622. out = append(out, semanticSlotPreviewView{
  1623. Slot: slot,
  1624. Count: len(targets),
  1625. Examples: strings.Join(examples, ", "),
  1626. })
  1627. }
  1628. return out
  1629. }
  1630. func getString(v any) string {
  1631. s, _ := v.(string)
  1632. return strings.TrimSpace(s)
  1633. }
  1634. func defaultDraftStatus(status string) string {
  1635. switch strings.ToLower(strings.TrimSpace(status)) {
  1636. case "reviewed", "submitted":
  1637. return strings.ToLower(strings.TrimSpace(status))
  1638. default:
  1639. return "draft"
  1640. }
  1641. }
  1642. func websiteSectionOptions() []websiteSectionOptionView {
  1643. values := domain.WebsiteSectionOptions()
  1644. out := make([]websiteSectionOptionView, 0, len(values))
  1645. for _, value := range values {
  1646. out = append(out, websiteSectionOptionView{
  1647. Value: value,
  1648. Label: domain.WebsiteSectionLabel(value),
  1649. })
  1650. }
  1651. return out
  1652. }