Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

1547 linhas
50KB

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