You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1843 lines
61KB

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