| @@ -9,11 +9,13 @@ Die App kann heute: | |||
| - Templates onboarden (Discovery/Manifest) und Felder fuer Mapping/Review bearbeiten. | |||
| - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. | |||
| - Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build). | |||
| - Globalen Master-Prompt in Settings pflegen sowie Prompt-Bloecke fuer den spaeteren LLM-Flow als Standard konfigurieren. | |||
| - Im Draft-/Build-UI Prompt-Bloecke je Draft aktivieren/deaktivieren und editieren; Prompt-Aufbau wird als Vorschau angezeigt. | |||
| - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | |||
| Wichtig: | |||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | |||
| - LLM-Autofill ist noch nicht fertig; vorbereitet sind nur Kontextfelder (Website/Style/Ton) im Draft-Review-Flow. | |||
| - LLM-Autofill ist noch nicht fertig; vorbereitet sind Kontextfelder plus editierbarer Prompt-Generator (Master + Bloecke + Stilsteuerung) im Draft-Review-Flow. | |||
| ## Lokaler Start | |||
| @@ -38,6 +38,7 @@ Aktueller Stand: | |||
| - Draft-Erfassung, Listing, Update und UI-Weiterbearbeitung sind vorhanden. | |||
| - Definierter externer Intake unter `POST /api/drafts/intake` ist vorhanden; `templateId` ist optional, Build wird dort nicht ausgeloest. | |||
| - Draft-Kontext fuer spaetere LLM-Unterstuetzung ist vorbereitet (Website-URL, Website-Summary, Stilprofil inkl. locale/market/address mode/tone/prompt instructions). | |||
| - Prompt-Generator als MVP ist im UI vorhanden: globaler Master-Prompt (Settings), editierbare Prompt-Bloecke je Draft, sichtbare Prompt-Zusammensetzung. | |||
| - Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`. | |||
| - Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut. | |||
| @@ -100,7 +101,8 @@ Statusmarker: | |||
| ### E) LLM-Assistenz | |||
| - [ ] Feldvorschlaege fuer fehlende Inhalte im Draft. | |||
| - [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld. | |||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder vorhanden, produktive Vorschlagslogik offen). | |||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder + Auswahlfelder vorhanden, produktive Vorschlagslogik offen). | |||
| - [-] Prompt-Generator (Master-Prompt + aktivierbare Prompt-Bloecke + Prompt-Vorschau) als Vorbereitung fuer spaeteren LLM-Runner. | |||
| ### F) Security und Betriebsreife | |||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | |||
| @@ -74,23 +74,31 @@ func New(cfg config.Config) (*App, error) { | |||
| pollingSvc := polling.New(buildSvc, buildStore, time.Duration(cfg.PollIntervalSeconds)*time.Second, cfg.PollMaxConcurrent, logger) | |||
| api := handlers.NewAPI(templateSvc, onboardSvc, draftSvc, buildSvc) | |||
| _ = settingsStore.UpsertSettings(context.Background(), domain.AppSettings{ | |||
| baseSettings := domain.AppSettings{ | |||
| QCBaseURL: cfg.QCBaseURL, | |||
| QCBearerTokenEncrypted: cfg.QCToken, | |||
| LanguageOutputMode: "EN", | |||
| JobPollIntervalSeconds: cfg.PollIntervalSeconds, | |||
| JobPollTimeoutSeconds: cfg.PollTimeoutSeconds, | |||
| }) | |||
| MasterPrompt: domain.SeedMasterPrompt, | |||
| PromptBlocks: domain.DefaultPromptBlocks(), | |||
| } | |||
| if existing, err := settingsStore.GetSettings(context.Background()); err == nil && existing != nil { | |||
| baseSettings.MasterPrompt = existing.MasterPrompt | |||
| baseSettings.PromptBlocks = existing.PromptBlocks | |||
| } | |||
| _ = settingsStore.UpsertSettings(context.Background(), baseSettings) | |||
| renderer, err := views.NewRenderer("web/templates/*.gohtml") | |||
| if err != nil { | |||
| return nil, fmt.Errorf("init renderer: %w", err) | |||
| } | |||
| ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, cfg, renderer) | |||
| ui := handlers.NewUI(templateSvc, onboardSvc, draftSvc, buildSvc, settingsStore, cfg, renderer) | |||
| server := httpserver.New(cfg.HTTPAddr, logger, func(r chi.Router) { | |||
| r.Get("/", ui.Home) | |||
| r.Get("/settings", ui.Settings) | |||
| r.Post("/settings/prompt", ui.SavePromptSettings) | |||
| r.Get("/templates", ui.Templates) | |||
| r.Post("/templates/sync", ui.SyncTemplates) | |||
| r.Get("/templates/{id}", ui.TemplateDetail) | |||
| @@ -94,11 +94,23 @@ type DraftStyleProfile struct { | |||
| PromptInstructions string `json:"promptInstructions,omitempty"` | |||
| } | |||
| type PromptBlockConfig struct { | |||
| ID string `json:"id"` | |||
| Label string `json:"label,omitempty"` | |||
| Instruction string `json:"instruction,omitempty"` | |||
| Enabled bool `json:"enabled"` | |||
| } | |||
| type DraftPromptConfig struct { | |||
| Blocks []PromptBlockConfig `json:"blocks,omitempty"` | |||
| } | |||
| type DraftLLMContext struct { | |||
| BusinessType string `json:"businessType,omitempty"` | |||
| WebsiteURL string `json:"websiteUrl,omitempty"` | |||
| WebsiteSummary string `json:"websiteSummary,omitempty"` | |||
| StyleProfile DraftStyleProfile `json:"styleProfile,omitempty"` | |||
| Prompt DraftPromptConfig `json:"prompt,omitempty"` | |||
| } | |||
| type DraftContext struct { | |||
| @@ -107,9 +119,11 @@ type DraftContext struct { | |||
| } | |||
| type AppSettings struct { | |||
| QCBaseURL string `json:"qcBaseUrl"` | |||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | |||
| LanguageOutputMode string `json:"languageOutputMode"` | |||
| JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` | |||
| JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` | |||
| QCBaseURL string `json:"qcBaseUrl"` | |||
| QCBearerTokenEncrypted string `json:"qcBearerTokenEncrypted"` | |||
| LanguageOutputMode string `json:"languageOutputMode"` | |||
| JobPollIntervalSeconds int `json:"jobPollIntervalSeconds"` | |||
| JobPollTimeoutSeconds int `json:"jobPollTimeoutSeconds"` | |||
| MasterPrompt string `json:"masterPrompt,omitempty"` | |||
| PromptBlocks []PromptBlockConfig `json:"promptBlocks,omitempty"` | |||
| } | |||
| @@ -0,0 +1,105 @@ | |||
| package domain | |||
| import "strings" | |||
| const SeedMasterPrompt = `Du bist ein Assistenzsystem fuer den QC-Text-Builder. | |||
| Erstelle nur ueberschreibbare Textvorschlaege fuer Draft-Felder, keine finale Build-Ausfuehrung. | |||
| Arbeite praezise, branchengerecht und nachvollziehbar. | |||
| Priorisiere klare, konversionsstarke Website-Texte, die zum Geschaeftskontext passen. | |||
| Wenn Informationen fehlen, markiere Annahmen transparent statt Inhalte zu erfinden.` | |||
| func DefaultPromptBlocks() []PromptBlockConfig { | |||
| return []PromptBlockConfig{ | |||
| { | |||
| ID: "business_type", | |||
| Label: "Business Type / Branche", | |||
| Instruction: "Nutze den Business-Type als Leitplanke fuer Fachsprache, Leistungsversprechen und relevante Keywords.", | |||
| Enabled: true, | |||
| }, | |||
| { | |||
| ID: "website_summary", | |||
| Label: "Website-Zusammenfassung", | |||
| Instruction: "Nutze die Website-Zusammenfassung als Input fuer USPs, Angebote und vorhandene Botschaften.", | |||
| Enabled: true, | |||
| }, | |||
| { | |||
| ID: "style_market_locale", | |||
| Label: "Stil / Markt / Locale", | |||
| Instruction: "Beachte Locale-Style und Markt-Style fuer Wortwahl, Begriffe und regionale Passung.", | |||
| Enabled: true, | |||
| }, | |||
| { | |||
| ID: "address_mode", | |||
| Label: "Address Mode (du/sie)", | |||
| Instruction: "Halte die Ansprache konsistent im gewaehlten Address Mode.", | |||
| Enabled: true, | |||
| }, | |||
| { | |||
| ID: "content_tone", | |||
| Label: "Content Tone", | |||
| Instruction: "Setze den gewaehlten Ton ueber Headlines, Fliesstext und CTA konsistent um.", | |||
| Enabled: true, | |||
| }, | |||
| { | |||
| ID: "free_instructions", | |||
| Label: "Zusaetzliche freie Instruktionen", | |||
| Instruction: "Beruecksichtige zusaetzliche Prompt-Instruktionen als harte Vorgaben fuer die Vorschlaege.", | |||
| Enabled: true, | |||
| }, | |||
| } | |||
| } | |||
| func NormalizeMasterPrompt(value string) string { | |||
| trimmed := strings.TrimSpace(value) | |||
| if trimmed == "" { | |||
| return SeedMasterPrompt | |||
| } | |||
| return trimmed | |||
| } | |||
| func NormalizePromptBlocks(blocks []PromptBlockConfig) []PromptBlockConfig { | |||
| defaults := DefaultPromptBlocks() | |||
| if len(blocks) == 0 { | |||
| out := make([]PromptBlockConfig, len(defaults)) | |||
| copy(out, defaults) | |||
| return out | |||
| } | |||
| defaultByID := make(map[string]PromptBlockConfig, len(defaults)) | |||
| for _, block := range defaults { | |||
| defaultByID[block.ID] = block | |||
| } | |||
| used := make(map[string]struct{}, len(blocks)) | |||
| out := make([]PromptBlockConfig, 0, len(defaults)) | |||
| for _, block := range blocks { | |||
| id := strings.TrimSpace(block.ID) | |||
| if id == "" { | |||
| continue | |||
| } | |||
| if _, seen := used[id]; seen { | |||
| continue | |||
| } | |||
| base, ok := defaultByID[id] | |||
| if !ok { | |||
| base = PromptBlockConfig{ID: id, Label: id} | |||
| } | |||
| if strings.TrimSpace(block.Label) != "" { | |||
| base.Label = strings.TrimSpace(block.Label) | |||
| } | |||
| if strings.TrimSpace(block.Instruction) != "" { | |||
| base.Instruction = strings.TrimSpace(block.Instruction) | |||
| } | |||
| base.Enabled = block.Enabled | |||
| out = append(out, base) | |||
| used[id] = struct{}{} | |||
| } | |||
| for _, block := range defaults { | |||
| if _, ok := used[block.ID]; ok { | |||
| continue | |||
| } | |||
| out = append(out, block) | |||
| } | |||
| return out | |||
| } | |||
| @@ -1,6 +1,8 @@ | |||
| package handlers | |||
| import ( | |||
| "bytes" | |||
| "context" | |||
| "encoding/json" | |||
| "fmt" | |||
| "net/http" | |||
| @@ -18,6 +20,7 @@ import ( | |||
| "qctextbuilder/internal/domain" | |||
| "qctextbuilder/internal/draftsvc" | |||
| "qctextbuilder/internal/onboarding" | |||
| "qctextbuilder/internal/store" | |||
| "qctextbuilder/internal/templatesvc" | |||
| ) | |||
| @@ -26,6 +29,7 @@ type UI struct { | |||
| onboardSvc *onboarding.Service | |||
| draftSvc *draftsvc.Service | |||
| buildSvc buildsvc.Service | |||
| settings store.SettingsStore | |||
| cfg config.Config | |||
| render htmlRenderer | |||
| } | |||
| @@ -54,6 +58,8 @@ type settingsPageData struct { | |||
| PollMaxConcurrent int | |||
| TokenConfigured bool | |||
| LanguageOutputMode string | |||
| MasterPrompt string | |||
| PromptBlocks []domain.PromptBlockConfig | |||
| } | |||
| type templatesPageData struct { | |||
| @@ -142,6 +148,7 @@ type buildNewPageData struct { | |||
| EditableFields []buildFieldView | |||
| EnabledFields []buildFieldView | |||
| Form buildFormInput | |||
| PromptPreview string | |||
| } | |||
| type buildFormInput struct { | |||
| @@ -174,6 +181,8 @@ type buildFormInput struct { | |||
| AddressMode string | |||
| ContentTone string | |||
| PromptInstructions string | |||
| MasterPrompt string | |||
| PromptBlocks []domain.PromptBlockConfig | |||
| } | |||
| type buildDetailPageData struct { | |||
| @@ -185,8 +194,8 @@ type buildDetailPageData struct { | |||
| AutoRefreshSeconds int | |||
| } | |||
| func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, cfg config.Config, render htmlRenderer) *UI { | |||
| return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, cfg: cfg, render: render} | |||
| func NewUI(templateSvc *templatesvc.Service, onboardSvc *onboarding.Service, draftSvc *draftsvc.Service, buildSvc buildsvc.Service, settings store.SettingsStore, cfg config.Config, render htmlRenderer) *UI { | |||
| return &UI{templateSvc: templateSvc, onboardSvc: onboardSvc, draftSvc: draftSvc, buildSvc: buildSvc, settings: settings, cfg: cfg, render: render} | |||
| } | |||
| func (u *UI) Home(w http.ResponseWriter, r *http.Request) { | |||
| @@ -199,6 +208,7 @@ func (u *UI) Home(w http.ResponseWriter, r *http.Request) { | |||
| } | |||
| func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | |||
| settings := u.loadPromptSettings(r.Context()) | |||
| u.render.Render(w, "settings", settingsPageData{ | |||
| pageData: basePageData(r, "Settings", "/settings"), | |||
| QCBaseURL: u.cfg.QCBaseURL, | |||
| @@ -207,9 +217,26 @@ func (u *UI) Settings(w http.ResponseWriter, r *http.Request) { | |||
| PollMaxConcurrent: u.cfg.PollMaxConcurrent, | |||
| TokenConfigured: strings.TrimSpace(u.cfg.QCToken) != "", | |||
| LanguageOutputMode: "EN", | |||
| MasterPrompt: settings.MasterPrompt, | |||
| PromptBlocks: settings.PromptBlocks, | |||
| }) | |||
| } | |||
| func (u *UI) SavePromptSettings(w http.ResponseWriter, r *http.Request) { | |||
| if err := r.ParseForm(); err != nil { | |||
| http.Redirect(w, r, "/settings?err=invalid+form", http.StatusSeeOther) | |||
| return | |||
| } | |||
| settings := u.loadPromptSettings(r.Context()) | |||
| settings.MasterPrompt = domain.NormalizeMasterPrompt(r.FormValue("master_prompt")) | |||
| settings.PromptBlocks = parsePromptBlocksFromRequest(r) | |||
| if err := u.settings.UpsertSettings(r.Context(), settings); err != nil { | |||
| http.Redirect(w, r, "/settings?err="+urlQuery(err.Error()), http.StatusSeeOther) | |||
| return | |||
| } | |||
| http.Redirect(w, r, "/settings?msg=prompt+settings+saved", http.StatusSeeOther) | |||
| } | |||
| func (u *UI) Templates(w http.ResponseWriter, r *http.Request) { | |||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | |||
| if err != nil { | |||
| @@ -318,12 +345,15 @@ func (u *UI) UpdateTemplateFields(w http.ResponseWriter, r *http.Request) { | |||
| } | |||
| func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { | |||
| settings := u.loadPromptSettings(r.Context()) | |||
| selectedTemplateID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("template_id")), 10, 64) | |||
| selectedDraftID := strings.TrimSpace(r.URL.Query().Get("draft_id")) | |||
| form := buildFormInput{ | |||
| DraftID: selectedDraftID, | |||
| DraftSource: "ui", | |||
| DraftStatus: "draft", | |||
| DraftID: selectedDraftID, | |||
| DraftSource: "ui", | |||
| DraftStatus: "draft", | |||
| MasterPrompt: settings.MasterPrompt, | |||
| PromptBlocks: clonePromptBlocks(settings.PromptBlocks), | |||
| } | |||
| fieldValues := map[string]string{} | |||
| if selectedDraftID != "" { | |||
| @@ -332,8 +362,11 @@ func (u *UI) BuildNew(w http.ResponseWriter, r *http.Request) { | |||
| selectedTemplateID = draft.TemplateID | |||
| form = buildFormInputFromDraft(draft) | |||
| fieldValues = parseFieldValuesJSON(draft.FieldValuesJSON) | |||
| form.MasterPrompt = settings.MasterPrompt | |||
| form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks) | |||
| } | |||
| } | |||
| form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) | |||
| data, err := u.loadBuildNewPageData(r, basePageData(r, "New Build", "/builds/new"), selectedDraftID, selectedTemplateID, form, fieldValues) | |||
| if err != nil { | |||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||
| @@ -549,6 +582,12 @@ func strPtr(v string) *string { | |||
| } | |||
| func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftID string, selectedTemplateID int64, form buildFormInput, fieldValues map[string]string) (buildNewPageData, error) { | |||
| if strings.TrimSpace(form.MasterPrompt) == "" { | |||
| form.MasterPrompt = domain.SeedMasterPrompt | |||
| } | |||
| form.PromptBlocks = domain.NormalizePromptBlocks(form.PromptBlocks) | |||
| form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) | |||
| templates, err := u.templateSvc.ListTemplates(r.Context()) | |||
| if err != nil { | |||
| return buildNewPageData{}, err | |||
| @@ -565,6 +604,7 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||
| SelectedDraftID: selectedDraftID, | |||
| SelectedTemplateID: selectedTemplateID, | |||
| Form: form, | |||
| PromptPreview: composePromptPreview(form), | |||
| } | |||
| if selectedTemplateID <= 0 { | |||
| return data, nil | |||
| @@ -1136,7 +1176,7 @@ func humanizeKey(key string) string { | |||
| } | |||
| func buildFormInputFromRequest(r *http.Request) buildFormInput { | |||
| return buildFormInput{ | |||
| form := buildFormInput{ | |||
| DraftID: strings.TrimSpace(r.FormValue("draft_id")), | |||
| DraftSource: strings.TrimSpace(r.FormValue("draft_source")), | |||
| DraftStatus: strings.TrimSpace(r.FormValue("draft_status")), | |||
| @@ -1166,7 +1206,10 @@ func buildFormInputFromRequest(r *http.Request) buildFormInput { | |||
| AddressMode: strings.TrimSpace(r.FormValue("address_mode")), | |||
| ContentTone: strings.TrimSpace(r.FormValue("content_tone")), | |||
| PromptInstructions: strings.TrimSpace(r.FormValue("prompt_instructions")), | |||
| MasterPrompt: domain.NormalizeMasterPrompt(r.FormValue("master_prompt")), | |||
| PromptBlocks: parsePromptBlocksFromRequest(r), | |||
| } | |||
| return form | |||
| } | |||
| func buildFormInputFromDraft(draft *domain.BuildDraft) buildFormInput { | |||
| @@ -1270,6 +1313,7 @@ func mergeDraftContextIntoForm(form *buildFormInput, raw []byte) { | |||
| form.AddressMode = strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode) | |||
| form.ContentTone = strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone) | |||
| form.PromptInstructions = strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions) | |||
| form.PromptBlocks = clonePromptBlocks(ctx.LLM.Prompt.Blocks) | |||
| } | |||
| func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) *domain.DraftContext { | |||
| @@ -1290,10 +1334,201 @@ func buildDraftContextFromForm(form buildFormInput, globalData map[string]any) * | |||
| ContentTone: strings.TrimSpace(form.ContentTone), | |||
| PromptInstructions: strings.TrimSpace(form.PromptInstructions), | |||
| }, | |||
| Prompt: domain.DraftPromptConfig{ | |||
| Blocks: clonePromptBlocks(form.PromptBlocks), | |||
| }, | |||
| }, | |||
| } | |||
| } | |||
| func (u *UI) loadPromptSettings(ctx context.Context) domain.AppSettings { | |||
| settings := domain.AppSettings{ | |||
| QCBaseURL: u.cfg.QCBaseURL, | |||
| QCBearerTokenEncrypted: u.cfg.QCToken, | |||
| LanguageOutputMode: "EN", | |||
| JobPollIntervalSeconds: u.cfg.PollIntervalSeconds, | |||
| JobPollTimeoutSeconds: u.cfg.PollTimeoutSeconds, | |||
| MasterPrompt: domain.SeedMasterPrompt, | |||
| PromptBlocks: domain.DefaultPromptBlocks(), | |||
| } | |||
| if u.settings == nil { | |||
| return settings | |||
| } | |||
| stored, err := u.settings.GetSettings(ctx) | |||
| if err != nil || stored == nil { | |||
| return settings | |||
| } | |||
| if strings.TrimSpace(stored.QCBaseURL) != "" { | |||
| settings.QCBaseURL = strings.TrimSpace(stored.QCBaseURL) | |||
| } | |||
| if strings.TrimSpace(stored.QCBearerTokenEncrypted) != "" { | |||
| settings.QCBearerTokenEncrypted = strings.TrimSpace(stored.QCBearerTokenEncrypted) | |||
| } | |||
| if strings.TrimSpace(stored.LanguageOutputMode) != "" { | |||
| settings.LanguageOutputMode = strings.TrimSpace(stored.LanguageOutputMode) | |||
| } | |||
| if stored.JobPollIntervalSeconds > 0 { | |||
| settings.JobPollIntervalSeconds = stored.JobPollIntervalSeconds | |||
| } | |||
| if stored.JobPollTimeoutSeconds > 0 { | |||
| settings.JobPollTimeoutSeconds = stored.JobPollTimeoutSeconds | |||
| } | |||
| settings.MasterPrompt = domain.NormalizeMasterPrompt(stored.MasterPrompt) | |||
| settings.PromptBlocks = domain.NormalizePromptBlocks(stored.PromptBlocks) | |||
| return settings | |||
| } | |||
| func parsePromptBlocksFromRequest(r *http.Request) []domain.PromptBlockConfig { | |||
| count, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("prompt_block_count"))) | |||
| if count <= 0 { | |||
| return domain.DefaultPromptBlocks() | |||
| } | |||
| out := make([]domain.PromptBlockConfig, 0, count) | |||
| for i := 0; i < count; i++ { | |||
| id := strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_id_%d", i))) | |||
| if id == "" { | |||
| continue | |||
| } | |||
| out = append(out, domain.PromptBlockConfig{ | |||
| ID: id, | |||
| Label: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_label_%d", i))), | |||
| Instruction: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_instruction_%d", i))), | |||
| Enabled: strings.TrimSpace(r.FormValue(fmt.Sprintf("prompt_block_enabled_%d", i))) == "on", | |||
| }) | |||
| } | |||
| return domain.NormalizePromptBlocks(out) | |||
| } | |||
| func clonePromptBlocks(blocks []domain.PromptBlockConfig) []domain.PromptBlockConfig { | |||
| if len(blocks) == 0 { | |||
| return nil | |||
| } | |||
| out := make([]domain.PromptBlockConfig, len(blocks)) | |||
| copy(out, blocks) | |||
| return out | |||
| } | |||
| func mergePromptBlocks(current []domain.PromptBlockConfig, defaults []domain.PromptBlockConfig) []domain.PromptBlockConfig { | |||
| merged := make([]domain.PromptBlockConfig, 0, len(defaults)) | |||
| merged = append(merged, clonePromptBlocks(defaults)...) | |||
| overrides := make(map[string]domain.PromptBlockConfig, len(current)) | |||
| for _, block := range current { | |||
| id := strings.TrimSpace(block.ID) | |||
| if id == "" { | |||
| continue | |||
| } | |||
| overrides[id] = block | |||
| } | |||
| for i := range merged { | |||
| if override, ok := overrides[merged[i].ID]; ok { | |||
| if strings.TrimSpace(override.Label) != "" { | |||
| merged[i].Label = strings.TrimSpace(override.Label) | |||
| } | |||
| if strings.TrimSpace(override.Instruction) != "" { | |||
| merged[i].Instruction = strings.TrimSpace(override.Instruction) | |||
| } | |||
| merged[i].Enabled = override.Enabled | |||
| delete(overrides, merged[i].ID) | |||
| } | |||
| } | |||
| for _, override := range overrides { | |||
| merged = append(merged, override) | |||
| } | |||
| return domain.NormalizePromptBlocks(merged) | |||
| } | |||
| func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form buildFormInput) []domain.PromptBlockConfig { | |||
| out := clonePromptBlocks(blocks) | |||
| if len(out) == 0 { | |||
| return out | |||
| } | |||
| for i := range out { | |||
| switch out[i].ID { | |||
| case "business_type": | |||
| if strings.TrimSpace(form.BusinessType) != "" { | |||
| out[i].Enabled = true | |||
| } | |||
| case "website_summary": | |||
| if strings.TrimSpace(form.WebsiteSummary) != "" { | |||
| out[i].Enabled = true | |||
| } | |||
| case "address_mode": | |||
| if strings.TrimSpace(form.AddressMode) != "" { | |||
| out[i].Enabled = true | |||
| } | |||
| case "content_tone": | |||
| if strings.TrimSpace(form.ContentTone) != "" { | |||
| out[i].Enabled = true | |||
| } | |||
| case "free_instructions": | |||
| if strings.TrimSpace(form.PromptInstructions) != "" { | |||
| out[i].Enabled = true | |||
| } | |||
| } | |||
| } | |||
| return out | |||
| } | |||
| func composePromptPreview(form buildFormInput) string { | |||
| var b bytes.Buffer | |||
| b.WriteString(strings.TrimSpace(form.MasterPrompt)) | |||
| b.WriteString("\n\nAktive Prompt-Bloecke:\n") | |||
| active := 0 | |||
| for _, block := range form.PromptBlocks { | |||
| if !block.Enabled { | |||
| continue | |||
| } | |||
| active++ | |||
| b.WriteString("\n[") | |||
| b.WriteString(block.ID) | |||
| b.WriteString("] ") | |||
| b.WriteString(strings.TrimSpace(block.Instruction)) | |||
| contextValue := promptContextValueForBlock(block.ID, form) | |||
| if contextValue != "" { | |||
| b.WriteString("\nKontext: ") | |||
| b.WriteString(contextValue) | |||
| } | |||
| b.WriteString("\n") | |||
| } | |||
| if active == 0 { | |||
| b.WriteString("\n(keine aktiven Bloecke)\n") | |||
| } | |||
| if strings.TrimSpace(form.PromptInstructions) != "" { | |||
| b.WriteString("\nOptionale zusaetzliche Instructions:\n") | |||
| b.WriteString(strings.TrimSpace(form.PromptInstructions)) | |||
| b.WriteString("\n") | |||
| } | |||
| return strings.TrimSpace(b.String()) | |||
| } | |||
| func promptContextValueForBlock(blockID string, form buildFormInput) string { | |||
| switch strings.TrimSpace(blockID) { | |||
| case "business_type": | |||
| return strings.TrimSpace(form.BusinessType) | |||
| case "website_summary": | |||
| return strings.TrimSpace(form.WebsiteSummary) | |||
| case "style_market_locale": | |||
| parts := make([]string, 0, 2) | |||
| if strings.TrimSpace(form.LocaleStyle) != "" { | |||
| parts = append(parts, "Locale="+strings.TrimSpace(form.LocaleStyle)) | |||
| } | |||
| if strings.TrimSpace(form.MarketStyle) != "" { | |||
| parts = append(parts, "Market="+strings.TrimSpace(form.MarketStyle)) | |||
| } | |||
| return strings.Join(parts, ", ") | |||
| case "address_mode": | |||
| return strings.TrimSpace(form.AddressMode) | |||
| case "content_tone": | |||
| return strings.TrimSpace(form.ContentTone) | |||
| case "free_instructions": | |||
| return strings.TrimSpace(form.PromptInstructions) | |||
| default: | |||
| return "" | |||
| } | |||
| } | |||
| func getString(v any) string { | |||
| s, _ := v.(string) | |||
| return strings.TrimSpace(s) | |||
| @@ -0,0 +1,5 @@ | |||
| ALTER TABLE app_settings | |||
| ADD COLUMN master_prompt TEXT NOT NULL DEFAULT ''; | |||
| ALTER TABLE app_settings | |||
| ADD COLUMN prompt_blocks_json BLOB; | |||
| @@ -406,22 +406,30 @@ func (s *Store) UpdateBuildEditorURL(ctx context.Context, buildID string, editor | |||
| } | |||
| func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) error { | |||
| _, err := s.db.ExecContext(ctx, ` | |||
| promptBlocksRaw, err := json.Marshal(domain.NormalizePromptBlocks(settings.PromptBlocks)) | |||
| if err != nil { | |||
| return fmt.Errorf("marshal prompt blocks: %w", err) | |||
| } | |||
| _, err = s.db.ExecContext(ctx, ` | |||
| INSERT INTO app_settings ( | |||
| id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, updated_at | |||
| ) VALUES (1, ?, ?, ?, ?, ?, ?) | |||
| id, qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, master_prompt, prompt_blocks_json, updated_at | |||
| ) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?) | |||
| ON CONFLICT(id) DO UPDATE SET | |||
| qc_base_url = excluded.qc_base_url, | |||
| qc_bearer_token_encrypted = excluded.qc_bearer_token_encrypted, | |||
| language_output_mode = excluded.language_output_mode, | |||
| job_poll_interval_seconds = excluded.job_poll_interval_seconds, | |||
| job_poll_timeout_seconds = excluded.job_poll_timeout_seconds, | |||
| master_prompt = excluded.master_prompt, | |||
| prompt_blocks_json = excluded.prompt_blocks_json, | |||
| updated_at = excluded.updated_at`, | |||
| settings.QCBaseURL, | |||
| settings.QCBearerTokenEncrypted, | |||
| defaultString(settings.LanguageOutputMode, "EN"), | |||
| settings.JobPollIntervalSeconds, | |||
| settings.JobPollTimeoutSeconds, | |||
| domain.NormalizeMasterPrompt(settings.MasterPrompt), | |||
| promptBlocksRaw, | |||
| time.Now().UTC().Format(time.RFC3339Nano), | |||
| ) | |||
| return err | |||
| @@ -429,22 +437,30 @@ func (s *Store) UpsertSettings(ctx context.Context, settings domain.AppSettings) | |||
| func (s *Store) GetSettings(ctx context.Context) (*domain.AppSettings, error) { | |||
| row := s.db.QueryRowContext(ctx, ` | |||
| SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds | |||
| SELECT qc_base_url, qc_bearer_token_encrypted, language_output_mode, job_poll_interval_seconds, job_poll_timeout_seconds, master_prompt, prompt_blocks_json | |||
| FROM app_settings | |||
| WHERE id = 1`) | |||
| var settings domain.AppSettings | |||
| var promptBlocksRaw []byte | |||
| if err := row.Scan( | |||
| &settings.QCBaseURL, | |||
| &settings.QCBearerTokenEncrypted, | |||
| &settings.LanguageOutputMode, | |||
| &settings.JobPollIntervalSeconds, | |||
| &settings.JobPollTimeoutSeconds, | |||
| &settings.MasterPrompt, | |||
| &promptBlocksRaw, | |||
| ); err != nil { | |||
| if err == sql.ErrNoRows { | |||
| return nil, store.ErrNotFound | |||
| } | |||
| return nil, err | |||
| } | |||
| settings.MasterPrompt = domain.NormalizeMasterPrompt(settings.MasterPrompt) | |||
| if len(promptBlocksRaw) > 0 { | |||
| _ = json.Unmarshal(promptBlocksRaw, &settings.PromptBlocks) | |||
| } | |||
| settings.PromptBlocks = domain.NormalizePromptBlocks(settings.PromptBlocks) | |||
| return &settings, nil | |||
| } | |||
| @@ -40,6 +40,7 @@ | |||
| <input type="hidden" name="template_id" value="{{.SelectedTemplateID}}"> | |||
| <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | |||
| <input type="hidden" name="field_count" value="{{len .EditableFields}}"> | |||
| <input type="hidden" name="prompt_block_count" value="{{len .Form.PromptBlocks}}"> | |||
| <h2>Global Data</h2> | |||
| <div class="grid2"> | |||
| @@ -68,13 +69,71 @@ | |||
| <h3>Intake / Website-Kontext</h3> | |||
| <div class="grid2"> | |||
| <div><label>Website URL<input type="url" name="website_url" value="{{.Form.WebsiteURL}}" placeholder="https://example.com"></label></div> | |||
| <div><label>Locale Style (z.B. de-CH)<input type="text" name="locale_style" value="{{.Form.LocaleStyle}}" placeholder="de-CH"></label></div> | |||
| <div><label>Market Style (z.B. DACH)<input type="text" name="market_style" value="{{.Form.MarketStyle}}" placeholder="de-CH, de-DE, de-AT"></label></div> | |||
| <div><label>Address Mode<input type="text" name="address_mode" value="{{.Form.AddressMode}}" placeholder="du oder sie"></label></div> | |||
| <div><label>Content Tone<input type="text" name="content_tone" value="{{.Form.ContentTone}}" placeholder="sachlich, freundlich, ..."></label></div> | |||
| <div> | |||
| <label>Locale Style | |||
| <select name="locale_style"> | |||
| <option value="">Bitte waehlen</option> | |||
| <option value="de-CH" {{if eq .Form.LocaleStyle "de-CH"}}selected{{end}}>de-CH</option> | |||
| <option value="de-DE" {{if eq .Form.LocaleStyle "de-DE"}}selected{{end}}>de-DE</option> | |||
| <option value="de-AT" {{if eq .Form.LocaleStyle "de-AT"}}selected{{end}}>de-AT</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| <div> | |||
| <label>Market Style | |||
| <select name="market_style"> | |||
| <option value="">Bitte waehlen</option> | |||
| <option value="DACH" {{if eq .Form.MarketStyle "DACH"}}selected{{end}}>DACH</option> | |||
| <option value="Schweiz" {{if eq .Form.MarketStyle "Schweiz"}}selected{{end}}>Schweiz</option> | |||
| <option value="Deutschland" {{if eq .Form.MarketStyle "Deutschland"}}selected{{end}}>Deutschland</option> | |||
| <option value="Oesterreich" {{if eq .Form.MarketStyle "Oesterreich"}}selected{{end}}>Oesterreich</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| <div> | |||
| <label>Address Mode | |||
| <select name="address_mode"> | |||
| <option value="">Bitte waehlen</option> | |||
| <option value="du" {{if eq .Form.AddressMode "du"}}selected{{end}}>du</option> | |||
| <option value="sie" {{if eq .Form.AddressMode "sie"}}selected{{end}}>sie</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| <div> | |||
| <label>Content Tone | |||
| <select name="content_tone"> | |||
| <option value="">Bitte waehlen</option> | |||
| <option value="sachlich" {{if eq .Form.ContentTone "sachlich"}}selected{{end}}>sachlich</option> | |||
| <option value="freundlich" {{if eq .Form.ContentTone "freundlich"}}selected{{end}}>freundlich</option> | |||
| <option value="modern" {{if eq .Form.ContentTone "modern"}}selected{{end}}>modern</option> | |||
| <option value="professionell" {{if eq .Form.ContentTone "professionell"}}selected{{end}}>professionell</option> | |||
| <option value="locker" {{if eq .Form.ContentTone "locker"}}selected{{end}}>locker</option> | |||
| <option value="premium" {{if eq .Form.ContentTone "premium"}}selected{{end}}>premium</option> | |||
| </select> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <div><label>Website Summary<textarea name="website_summary">{{.Form.WebsiteSummary}}</textarea></label></div> | |||
| <div><label>Prompt Instructions<textarea name="prompt_instructions">{{.Form.PromptInstructions}}</textarea></label></div> | |||
| <h3>Prompt-Generator (MVP)</h3> | |||
| <p>Globaler Master Prompt kommt aus <a href="/settings">Settings</a>.</p> | |||
| <div><label>Master Prompt (global)<textarea name="master_prompt" readonly>{{.Form.MasterPrompt}}</textarea></label></div> | |||
| {{range $i, $block := .Form.PromptBlocks}} | |||
| <input type="hidden" name="prompt_block_id_{{$i}}" value="{{$block.ID}}"> | |||
| <input type="hidden" name="prompt_block_label_{{$i}}" value="{{$block.Label}}"> | |||
| <div> | |||
| <label> | |||
| <input type="checkbox" name="prompt_block_enabled_{{$i}}" {{if $block.Enabled}}checked{{end}}> | |||
| Prompt-Block aktiv: {{$block.Label}} | |||
| </label> | |||
| <textarea name="prompt_block_instruction_{{$i}}">{{$block.Instruction}}</textarea> | |||
| </div> | |||
| {{end}} | |||
| <div><label>Optionale Prompt Instructions<textarea name="prompt_instructions">{{.Form.PromptInstructions}}</textarea></label></div> | |||
| <h4>Prompt-Aufbau Vorschau</h4> | |||
| <pre class="mono">{{.PromptPreview}}</pre> | |||
| <h3>Kontakt</h3> | |||
| <div class="grid2"> | |||
| @@ -10,7 +10,7 @@ | |||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | |||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | |||
| <h1>Settings</h1> | |||
| <p>Read-only summary for milestone 4.</p> | |||
| <p>QC-Settings plus globaler Prompt-Standard fuer den spaeteren LLM-Flow.</p> | |||
| <table> | |||
| <tr><th>QC Base URL</th><td class="mono">{{.QCBaseURL}}</td></tr> | |||
| <tr><th>Bearer token configured</th><td>{{if .TokenConfigured}}yes{{else}}no{{end}}</td></tr> | |||
| @@ -19,6 +19,29 @@ | |||
| <tr><th>Poll max concurrent</th><td>{{.PollMaxConcurrent}}</td></tr> | |||
| <tr><th>Language output mode</th><td>{{.LanguageOutputMode}}</td></tr> | |||
| </table> | |||
| <h2>Globaler Master Prompt</h2> | |||
| <form method="post" action="/settings/prompt"> | |||
| <input type="hidden" name="prompt_block_count" value="{{len .PromptBlocks}}"> | |||
| <div> | |||
| <label>Master Prompt | |||
| <textarea name="master_prompt">{{.MasterPrompt}}</textarea> | |||
| </label> | |||
| </div> | |||
| <h3>Prompt-Bloecke (Standard)</h3> | |||
| {{range $i, $block := .PromptBlocks}} | |||
| <input type="hidden" name="prompt_block_id_{{$i}}" value="{{$block.ID}}"> | |||
| <div> | |||
| <label> | |||
| <input type="checkbox" name="prompt_block_enabled_{{$i}}" {{if $block.Enabled}}checked{{end}}> | |||
| {{$block.Label}} | |||
| </label> | |||
| <input type="hidden" name="prompt_block_label_{{$i}}" value="{{$block.Label}}"> | |||
| <textarea name="prompt_block_instruction_{{$i}}">{{$block.Instruction}}</textarea> | |||
| </div> | |||
| {{end}} | |||
| <button type="submit">Prompt-Settings speichern</button> | |||
| </form> | |||
| </body> | |||
| </html> | |||
| {{end}} | |||