Sfoglia il codice sorgente

Add prompt settings and draft prompt context

master
Jan Svabenik 1 mese fa
parent
commit
8826c4c0dd
12 ha cambiato i file con 495 aggiunte e 26 eliminazioni
  1. +3
    -1
      README.md
  2. BIN
      data/qctextbuilder.db
  3. BIN
      dist/qctextbuilder.exe
  4. +3
    -1
      docs/TARGET_STATE_AND_ROADMAP.md
  5. +11
    -3
      internal/app/app.go
  6. +19
    -5
      internal/domain/models.go
  7. +105
    -0
      internal/domain/prompt_defaults.go
  8. +241
    -6
      internal/httpserver/handlers/ui.go
  9. +5
    -0
      internal/store/sqlite/migrations/004_add_prompt_settings.sql
  10. +20
    -4
      internal/store/sqlite/store.go
  11. +64
    -5
      web/templates/build_new.gohtml
  12. +24
    -1
      web/templates/settings.gohtml

+ 3
- 1
README.md Vedi File

@@ -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



BIN
data/qctextbuilder.db Vedi File


BIN
dist/qctextbuilder.exe Vedi File


+ 3
- 1
docs/TARGET_STATE_AND_ROADMAP.md Vedi File

@@ -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).


+ 11
- 3
internal/app/app.go Vedi File

@@ -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)


+ 19
- 5
internal/domain/models.go Vedi File

@@ -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"`
}

+ 105
- 0
internal/domain/prompt_defaults.go Vedi File

@@ -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
}

+ 241
- 6
internal/httpserver/handlers/ui.go Vedi File

@@ -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)


+ 5
- 0
internal/store/sqlite/migrations/004_add_prompt_settings.sql Vedi File

@@ -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;

+ 20
- 4
internal/store/sqlite/store.go Vedi File

@@ -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
}



+ 64
- 5
web/templates/build_new.gohtml Vedi File

@@ -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">


+ 24
- 1
web/templates/settings.gohtml Vedi File

@@ -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}}

Loading…
Annulla
Salva