diff --git a/README.md b/README.md index 5bb9620..ef3b64f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/qctextbuilder.db b/data/qctextbuilder.db index fb57b09..6be6818 100644 Binary files a/data/qctextbuilder.db and b/data/qctextbuilder.db differ diff --git a/dist/qctextbuilder.exe b/dist/qctextbuilder.exe index 088c1af..71e2ca6 100644 Binary files a/dist/qctextbuilder.exe and b/dist/qctextbuilder.exe differ diff --git a/docs/TARGET_STATE_AND_ROADMAP.md b/docs/TARGET_STATE_AND_ROADMAP.md index 7aa4259..0b507e3 100644 --- a/docs/TARGET_STATE_AND_ROADMAP.md +++ b/docs/TARGET_STATE_AND_ROADMAP.md @@ -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). diff --git a/internal/app/app.go b/internal/app/app.go index 9622058..6a5be6d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/domain/models.go b/internal/domain/models.go index 783f6a0..f015882 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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"` } diff --git a/internal/domain/prompt_defaults.go b/internal/domain/prompt_defaults.go new file mode 100644 index 0000000..ad4d83c --- /dev/null +++ b/internal/domain/prompt_defaults.go @@ -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 +} diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index ccb00b9..f64f9f9 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -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) diff --git a/internal/store/sqlite/migrations/004_add_prompt_settings.sql b/internal/store/sqlite/migrations/004_add_prompt_settings.sql new file mode 100644 index 0000000..878bb82 --- /dev/null +++ b/internal/store/sqlite/migrations/004_add_prompt_settings.sql @@ -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; diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index e7684a4..5d21115 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -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 } diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index 96b1b2f..78612c1 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -40,6 +40,7 @@ +
Globaler Master Prompt kommt aus Settings.
+ + + {{range $i, $block := .Form.PromptBlocks}} + + +{{.PromptPreview}}
Read-only summary for milestone 4.
+QC-Settings plus globaler Prompt-Standard fuer den spaeteren LLM-Flow.
| QC Base URL | {{.QCBaseURL}} |
|---|---|
| Bearer token configured | {{if .TokenConfigured}}yes{{else}}no{{end}} |
| Poll max concurrent | {{.PollMaxConcurrent}} |
| Language output mode | {{.LanguageOutputMode}} |