Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

2105 wiersze
70KB

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