diff --git a/README.md b/README.md index ef3b64f..e8d269c 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ Die App kann heute: - 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. +- Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings. +- Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill. +- Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot). - 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 Kontextfelder plus editierbarer Prompt-Generator (Master + Bloecke + Stilsteuerung) im Draft-Review-Flow. +- LLM-Autofill ist noch nicht fertig; vorbereitet sind Kontextfelder, globale Prompt-Steuerung in Settings und semantische Slot-Mappings als Bruecke zu `fieldValues`. ## Lokaler Start diff --git a/docs/TARGET_STATE_AND_ROADMAP.md b/docs/TARGET_STATE_AND_ROADMAP.md index 0b507e3..9b18f7a 100644 --- a/docs/TARGET_STATE_AND_ROADMAP.md +++ b/docs/TARGET_STATE_AND_ROADMAP.md @@ -38,7 +38,9 @@ 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. +- Prompt-/Systemsteuerung liegt global in Settings; der normale Build-/Review-Flow bleibt auf Inhalte und Feldbearbeitung fokussiert. +- Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill. +- Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen. - 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. @@ -102,7 +104,8 @@ Statusmarker: - [ ] Feldvorschlaege fuer fehlende Inhalte im Draft. - [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld. - [-] 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. +- [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings als Vorbereitung fuer spaeteren LLM-Runner; Build-Flow ohne prominente Prompt-Interna. +- [-] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer spaeteren LLM-Autofill vorbereitet (inkl. verbesserter Trennung in Repeated-Bereichen). ### F) Security und Betriebsreife - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). diff --git a/internal/httpserver/handlers/ui.go b/internal/httpserver/handlers/ui.go index f64f9f9..4816674 100644 --- a/internal/httpserver/handlers/ui.go +++ b/internal/httpserver/handlers/ui.go @@ -1,7 +1,6 @@ package handlers import ( - "bytes" "context" "encoding/json" "fmt" @@ -19,6 +18,7 @@ import ( "qctextbuilder/internal/config" "qctextbuilder/internal/domain" "qctextbuilder/internal/draftsvc" + "qctextbuilder/internal/mapping" "qctextbuilder/internal/onboarding" "qctextbuilder/internal/store" "qctextbuilder/internal/templatesvc" @@ -113,6 +113,12 @@ type buildFieldSectionView struct { DisabledFields []buildFieldView } +type semanticSlotPreviewView struct { + Slot string + Count int + Examples string +} + type pendingField struct { Field domain.TemplateField View buildFieldView @@ -148,7 +154,7 @@ type buildNewPageData struct { EditableFields []buildFieldView EnabledFields []buildFieldView Form buildFormInput - PromptPreview string + SemanticSlots []semanticSlotPreviewView } type buildFormInput struct { @@ -382,6 +388,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { } form := buildFormInputFromRequest(r) + form = u.applyPromptConfigForBuildFlow(r.Context(), form) fieldValues := parseBuildFieldValues(r) globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ CompanyName: form.CompanyName, @@ -453,6 +460,7 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) { return } form := buildFormInputFromRequest(r) + form = u.applyPromptConfigForBuildFlow(r.Context(), form) fieldValues := parseBuildFieldValues(r) templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) if err != nil || templateID <= 0 { @@ -604,7 +612,6 @@ 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 @@ -617,9 +624,29 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI data.SelectedManifestID = detail.Manifest.ID data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues) data.EnabledFields = data.EditableFields + data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields)) return data, nil } +func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput { + settings := u.loadPromptSettings(ctx) + form.MasterPrompt = settings.MasterPrompt + form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks) + if strings.TrimSpace(form.DraftID) == "" { + return form + } + + draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID)) + if err != nil || draft == nil { + return form + } + mergeDraftContextIntoForm(&form, draft.DraftContextJSON) + form.MasterPrompt = settings.MasterPrompt + form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks) + form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form) + return form +} + func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) { sectionOrder := []string{ domain.WebsiteSectionHero, @@ -1177,37 +1204,34 @@ func humanizeKey(key string) string { func buildFormInputFromRequest(r *http.Request) buildFormInput { form := buildFormInput{ - DraftID: strings.TrimSpace(r.FormValue("draft_id")), - DraftSource: strings.TrimSpace(r.FormValue("draft_source")), - DraftStatus: strings.TrimSpace(r.FormValue("draft_status")), - DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")), - RequestName: strings.TrimSpace(r.FormValue("request_name")), - CompanyName: strings.TrimSpace(r.FormValue("company_name")), - BusinessType: strings.TrimSpace(r.FormValue("business_type")), - Username: strings.TrimSpace(r.FormValue("username")), - Email: strings.TrimSpace(r.FormValue("email")), - Phone: strings.TrimSpace(r.FormValue("phone")), - OrgNumber: strings.TrimSpace(r.FormValue("org_number")), - StartDate: strings.TrimSpace(r.FormValue("start_date")), - Mission: strings.TrimSpace(r.FormValue("mission")), - DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), - DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), - SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), - AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), - AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), - AddressCity: strings.TrimSpace(r.FormValue("address_city")), - AddressRegion: strings.TrimSpace(r.FormValue("address_region")), - AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), - AddressCountry: strings.TrimSpace(r.FormValue("address_country")), - WebsiteURL: strings.TrimSpace(r.FormValue("website_url")), - WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")), - LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")), - MarketStyle: strings.TrimSpace(r.FormValue("market_style")), - 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), + DraftID: strings.TrimSpace(r.FormValue("draft_id")), + DraftSource: strings.TrimSpace(r.FormValue("draft_source")), + DraftStatus: strings.TrimSpace(r.FormValue("draft_status")), + DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")), + RequestName: strings.TrimSpace(r.FormValue("request_name")), + CompanyName: strings.TrimSpace(r.FormValue("company_name")), + BusinessType: strings.TrimSpace(r.FormValue("business_type")), + Username: strings.TrimSpace(r.FormValue("username")), + Email: strings.TrimSpace(r.FormValue("email")), + Phone: strings.TrimSpace(r.FormValue("phone")), + OrgNumber: strings.TrimSpace(r.FormValue("org_number")), + StartDate: strings.TrimSpace(r.FormValue("start_date")), + Mission: strings.TrimSpace(r.FormValue("mission")), + DescriptionShort: strings.TrimSpace(r.FormValue("description_short")), + DescriptionLong: strings.TrimSpace(r.FormValue("description_long")), + SiteLanguage: strings.TrimSpace(r.FormValue("site_language")), + AddressLine1: strings.TrimSpace(r.FormValue("address_line1")), + AddressLine2: strings.TrimSpace(r.FormValue("address_line2")), + AddressCity: strings.TrimSpace(r.FormValue("address_city")), + AddressRegion: strings.TrimSpace(r.FormValue("address_region")), + AddressZIP: strings.TrimSpace(r.FormValue("address_zip")), + AddressCountry: strings.TrimSpace(r.FormValue("address_country")), + WebsiteURL: strings.TrimSpace(r.FormValue("website_url")), + WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")), + LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")), + MarketStyle: strings.TrimSpace(r.FormValue("market_style")), + AddressMode: strings.TrimSpace(r.FormValue("address_mode")), + ContentTone: strings.TrimSpace(r.FormValue("content_tone")), } return form } @@ -1469,64 +1493,30 @@ func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form 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") +func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView { + if len(mappingResult.BySlot) == 0 { + return nil } - if strings.TrimSpace(form.PromptInstructions) != "" { - b.WriteString("\nOptionale zusaetzliche Instructions:\n") - b.WriteString(strings.TrimSpace(form.PromptInstructions)) - b.WriteString("\n") + slotKeys := make([]string, 0, len(mappingResult.BySlot)) + for slot := range mappingResult.BySlot { + slotKeys = append(slotKeys, slot) } - return strings.TrimSpace(b.String()) -} + sort.Strings(slotKeys) -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)) + out := make([]semanticSlotPreviewView, 0, len(slotKeys)) + for _, slot := range slotKeys { + targets := mappingResult.BySlot[slot] + examples := make([]string, 0, 2) + for i := 0; i < len(targets) && i < 2; i++ { + examples = append(examples, targets[i].FieldPath) } - 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 "" + out = append(out, semanticSlotPreviewView{ + Slot: slot, + Count: len(targets), + Examples: strings.Join(examples, ", "), + }) } + return out } func getString(v any) string { diff --git a/internal/mapping/semantic_slots.go b/internal/mapping/semantic_slots.go new file mode 100644 index 0000000..ab16387 --- /dev/null +++ b/internal/mapping/semantic_slots.go @@ -0,0 +1,386 @@ +package mapping + +import ( + "fmt" + "math" + "regexp" + "sort" + "strconv" + "strings" + + "qctextbuilder/internal/domain" +) + +type SemanticSlotTarget struct { + Slot string `json:"slot"` + FieldPath string `json:"fieldPath"` + FieldKey string `json:"fieldKey"` + DisplayLabel string `json:"displayLabel,omitempty"` + WebsiteSection string `json:"websiteSection,omitempty"` + BlockID string `json:"blockId,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type SemanticSlotMapping struct { + Targets []SemanticSlotTarget `json:"targets"` + BySlot map[string][]SemanticSlotTarget +} + +var semanticBlockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`) +var semanticLooseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`) +var semanticIndexSuffixPattern = regexp.MustCompile(`_\d+$`) +var semanticTrailingNumberPattern = regexp.MustCompile(`_(\d+)$`) + +func MapTemplateFieldsToSemanticSlots(fields []domain.TemplateField) SemanticSlotMapping { + sectionGroupIndex := map[string]map[string]int{ + domain.WebsiteSectionServices: {}, + domain.WebsiteSectionServiceItem: {}, + domain.WebsiteSectionTeam: {}, + domain.WebsiteSectionTestimonials: {}, + } + sectionGroupNext := map[string]int{ + domain.WebsiteSectionServices: 0, + domain.WebsiteSectionServiceItem: 0, + domain.WebsiteSectionTeam: 0, + domain.WebsiteSectionTestimonials: 0, + } + repeatedIndexResolver := newSemanticRepeatedIndexResolver(fields) + + targets := make([]SemanticSlotTarget, 0) + for _, field := range fields { + if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") { + continue + } + + section := semanticSection(field) + role := semanticRole(field) + slot, mapped := semanticSlotForField(field, section, role, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver) + if !mapped { + continue + } + + reason := "section=" + section + ", role=" + role + if blockID := semanticExtractBlockID(field); blockID != "" { + reason += ", block=" + blockID + } + + targets = append(targets, SemanticSlotTarget{ + Slot: slot, + FieldPath: strings.TrimSpace(field.Path), + FieldKey: strings.TrimSpace(field.KeyName), + DisplayLabel: strings.TrimSpace(field.DisplayLabel), + WebsiteSection: section, + BlockID: semanticExtractBlockID(field), + Reason: reason, + }) + } + + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].Slot == targets[j].Slot { + return targets[i].FieldPath < targets[j].FieldPath + } + return targets[i].Slot < targets[j].Slot + }) + + bySlot := make(map[string][]SemanticSlotTarget, len(targets)) + for _, target := range targets { + bySlot[target.Slot] = append(bySlot[target.Slot], target) + } + return SemanticSlotMapping{ + Targets: targets, + BySlot: bySlot, + } +} + +func semanticSlotForField( + field domain.TemplateField, + section string, + role string, + sectionGroupIndex map[string]map[string]int, + sectionGroupNext map[string]int, + repeatedIndexResolver *semanticRepeatedIndexResolver, +) (string, bool) { + switch section { + case domain.WebsiteSectionHero: + if role == "title" { + return "hero.title", true + } + case domain.WebsiteSectionIntro: + if role == "title" { + return "intro.title", true + } + if role == "description" { + return "intro.description", true + } + case domain.WebsiteSectionAbout: + if role == "description" || role == "title" { + return "about.description", true + } + case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem: + if role == "title" || role == "description" { + index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver) + return fmt.Sprintf("service_items[%d].%s", index, role), true + } + case domain.WebsiteSectionTeam: + if role == "name" || role == "description" { + index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver) + return fmt.Sprintf("team_items[%d].%s", index, role), true + } + case domain.WebsiteSectionTestimonials: + if role == "title" || role == "description" || role == "name" { + index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver) + return fmt.Sprintf("testimonial_items[%d].%s", index, role), true + } + case domain.WebsiteSectionCTA: + if role == "cta_text" || role == "title" || role == "description" { + return "cta.text", true + } + } + return "", false +} + +func semanticRepeatedIndex( + section string, + role string, + field domain.TemplateField, + sectionGroupIndex map[string]map[string]int, + sectionGroupNext map[string]int, + repeatedIndexResolver *semanticRepeatedIndexResolver, +) int { + if repeatedIndexResolver != nil { + if idx, ok := repeatedIndexResolver.IndexFor(section, role, field); ok { + return idx + } + } + return semanticGroupIndex(section, field, sectionGroupIndex, sectionGroupNext) +} + +func semanticGroupIndex( + section string, + field domain.TemplateField, + sectionGroupIndex map[string]map[string]int, + sectionGroupNext map[string]int, +) int { + normalizedSection := domain.NormalizeWebsiteSection(section) + group := semanticGroupKey(field) + if _, ok := sectionGroupIndex[normalizedSection]; !ok { + sectionGroupIndex[normalizedSection] = map[string]int{} + } + if idx, ok := sectionGroupIndex[normalizedSection][group]; ok { + return idx + } + idx := sectionGroupNext[normalizedSection] + sectionGroupNext[normalizedSection] = idx + 1 + sectionGroupIndex[normalizedSection][group] = idx + return idx +} + +func semanticGroupKey(field domain.TemplateField) string { + if blockID := semanticExtractBlockID(field); blockID != "" { + return "block:" + blockID + } + key := strings.ToLower(strings.TrimSpace(field.KeyName)) + if key != "" { + return "key:" + semanticIndexSuffixPattern.ReplaceAllString(key, "") + } + path := strings.ToLower(strings.TrimSpace(field.Path)) + return "path:" + semanticIndexSuffixPattern.ReplaceAllString(path, "") +} + +func semanticSection(field domain.TemplateField) string { + websiteSection := domain.NormalizeWebsiteSection(field.WebsiteSection) + if websiteSection != domain.WebsiteSectionOther { + return websiteSection + } + return domain.SuggestWebsiteSection(field) +} + +func semanticRole(field domain.TemplateField) string { + parts := []string{ + strings.ToLower(strings.TrimSpace(field.KeyName)), + strings.ToLower(strings.TrimSpace(field.Path)), + strings.ToLower(strings.TrimSpace(field.DisplayLabel)), + strings.ToLower(strings.TrimSpace(field.Section)), + } + combined := strings.Join(parts, " ") + switch { + case semanticContainsAny(combined, "description", "subtitle", "paragraph", "copy", "body", "content", "mission", "story", "bio", "quote"): + return "description" + case semanticContainsAny(combined, "button", "btn", "calltoaction", "call_to_action", "cta"): + return "cta_text" + case semanticContainsAny(combined, "headline", "heading", "title"): + return "title" + case semanticContainsAny(combined, "author", "customer", "person", "member", "name"): + return "name" + default: + return "description" + } +} + +func semanticExtractBlockID(field domain.TemplateField) string { + candidates := []string{ + strings.TrimSpace(field.KeyName), + strings.TrimSpace(field.Path), + strings.TrimSpace(field.DisplayLabel), + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if match := semanticBlockIDPattern.FindStringSubmatch(candidate); len(match) > 1 { + return strings.ToLower(match[1]) + } + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if match := semanticLooseBlockIDPattern.FindStringSubmatch(candidate); len(match) > 1 { + return strings.ToLower(match[1]) + } + } + return "" +} + +func semanticContainsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + +type semanticRepeatedIndexResolver struct { + byFieldKey map[string]int +} + +type semanticRepeatedField struct { + fieldKey string + suffix int + path string +} + +func newSemanticRepeatedIndexResolver(fields []domain.TemplateField) *semanticRepeatedIndexResolver { + resolver := &semanticRepeatedIndexResolver{ + byFieldKey: map[string]int{}, + } + // Pair repeated fields by section + block + role + numeric suffix ordering. + buckets := map[string][]semanticRepeatedField{} + for _, field := range fields { + if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") { + continue + } + section := semanticSection(field) + if !semanticIsRepeatedSection(section) { + continue + } + role := semanticRole(field) + if !semanticRoleAllowedForRepeated(section, role) { + continue + } + suffix, ok := semanticTrailingNumber(field) + if !ok { + continue + } + bucket := semanticRepeatedBucketKey(section, semanticExtractBlockID(field), role) + entry := semanticRepeatedField{ + fieldKey: semanticFieldIdentity(field), + suffix: suffix, + path: strings.TrimSpace(field.Path), + } + buckets[bucket] = append(buckets[bucket], entry) + } + + for _, bucketEntries := range buckets { + sort.SliceStable(bucketEntries, func(i, j int) bool { + if bucketEntries[i].suffix != bucketEntries[j].suffix { + return bucketEntries[i].suffix < bucketEntries[j].suffix + } + return bucketEntries[i].path < bucketEntries[j].path + }) + for idx, entry := range bucketEntries { + resolver.byFieldKey[entry.fieldKey] = idx + } + } + return resolver +} + +func (r *semanticRepeatedIndexResolver) IndexFor(section string, role string, field domain.TemplateField) (int, bool) { + if r == nil || len(r.byFieldKey) == 0 { + return 0, false + } + if !semanticIsRepeatedSection(section) || !semanticRoleAllowedForRepeated(section, role) { + return 0, false + } + idx, ok := r.byFieldKey[semanticFieldIdentity(field)] + return idx, ok +} + +func semanticIsRepeatedSection(section string) bool { + switch domain.NormalizeWebsiteSection(section) { + case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem, domain.WebsiteSectionTeam, domain.WebsiteSectionTestimonials: + return true + default: + return false + } +} + +func semanticRoleAllowedForRepeated(section string, role string) bool { + switch domain.NormalizeWebsiteSection(section) { + case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem: + return role == "title" || role == "description" + case domain.WebsiteSectionTeam: + return role == "name" || role == "description" + case domain.WebsiteSectionTestimonials: + return role == "name" || role == "title" || role == "description" + default: + return false + } +} + +func semanticRepeatedBucketKey(section string, blockID string, role string) string { + normalizedSection := domain.NormalizeWebsiteSection(section) + if normalizedSection == domain.WebsiteSectionServiceItem { + normalizedSection = domain.WebsiteSectionServices + } + block := strings.TrimSpace(strings.ToLower(blockID)) + if block == "" { + block = "__no_block__" + } + return normalizedSection + "|" + block + "|" + role +} + +func semanticTrailingNumber(field domain.TemplateField) (int, bool) { + candidates := []string{ + strings.TrimSpace(strings.ToLower(field.Path)), + strings.TrimSpace(strings.ToLower(field.KeyName)), + } + best := math.MaxInt + found := false + for _, candidate := range candidates { + if candidate == "" { + continue + } + match := semanticTrailingNumberPattern.FindStringSubmatch(candidate) + if len(match) < 2 { + continue + } + value, err := strconv.Atoi(match[1]) + if err != nil { + continue + } + if value < best { + best = value + } + found = true + } + if !found { + return 0, false + } + return best, true +} + +func semanticFieldIdentity(field domain.TemplateField) string { + return strings.ToLower(strings.TrimSpace(field.Path)) + "|" + strings.ToLower(strings.TrimSpace(field.KeyName)) +} diff --git a/internal/mapping/semantic_slots_test.go b/internal/mapping/semantic_slots_test.go new file mode 100644 index 0000000..2d4ad87 --- /dev/null +++ b/internal/mapping/semantic_slots_test.go @@ -0,0 +1,353 @@ +package mapping + +import ( + "testing" + + "qctextbuilder/internal/domain" +) + +func TestMapTemplateFieldsToSemanticSlots(t *testing.T) { + t.Parallel() + + fields := []domain.TemplateField{ + { + Path: "text.textTitle_m1710_1", + KeyName: "textTitle_m1710_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionHero, + }, + { + Path: "text.introTitle_c7886_1", + KeyName: "introTitle_c7886_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionIntro, + }, + { + Path: "text.introDescription_c7886_2", + KeyName: "introDescription_c7886_2", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionIntro, + }, + { + Path: "services.serviceTitle_r4830_1", + KeyName: "serviceTitle_r4830_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.serviceDescription_r4830_2", + KeyName: "serviceDescription_r4830_2", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "team.teamMemberName_r4748_1", + KeyName: "teamMemberName_r4748_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "team.teamDescription_r4748_2", + KeyName: "teamDescription_r4748_2", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "testimonials.testimonialTitle_r1508_1", + KeyName: "testimonialTitle_r1508_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialDescription_r1508_2", + KeyName: "testimonialDescription_r1508_2", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialName_r1508_3", + KeyName: "testimonialName_r1508_3", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "text.buttonText_c1165_1", + KeyName: "buttonText_c1165_1", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionCTA, + }, + } + + got := MapTemplateFieldsToSemanticSlots(fields) + if len(got.Targets) != 11 { + t.Fatalf("expected 11 targets, got %d", len(got.Targets)) + } + + assertHasSlotPath(t, got, "hero.title", "text.textTitle_m1710_1") + assertHasSlotPath(t, got, "intro.title", "text.introTitle_c7886_1") + assertHasSlotPath(t, got, "intro.description", "text.introDescription_c7886_2") + assertHasSlotPath(t, got, "service_items[0].title", "services.serviceTitle_r4830_1") + assertHasSlotPath(t, got, "service_items[0].description", "services.serviceDescription_r4830_2") + assertHasSlotPath(t, got, "team_items[0].name", "team.teamMemberName_r4748_1") + assertHasSlotPath(t, got, "team_items[0].description", "team.teamDescription_r4748_2") + assertHasSlotPath(t, got, "testimonial_items[0].title", "testimonials.testimonialTitle_r1508_1") + assertHasSlotPath(t, got, "testimonial_items[0].description", "testimonials.testimonialDescription_r1508_2") + assertHasSlotPath(t, got, "testimonial_items[0].name", "testimonials.testimonialName_r1508_3") + assertHasSlotPath(t, got, "cta.text", "text.buttonText_c1165_1") +} + +func TestMapTemplateFieldsToSemanticSlots_UsesSuggestedSection(t *testing.T) { + t.Parallel() + + fields := []domain.TemplateField{ + { + Path: "welcome.headline_m1710_1", + KeyName: "headline_m1710_1", + Section: "welcome", + FieldKind: "text", + IsEnabled: true, + }, + } + + got := MapTemplateFieldsToSemanticSlots(fields) + assertHasSlotPath(t, got, "hero.title", "welcome.headline_m1710_1") +} + +func TestMapTemplateFieldsToSemanticSlots_RepeatedBlocksStaySeparatedAndTypePure(t *testing.T) { + t.Parallel() + + fields := []domain.TemplateField{ + { + Path: "services.servicesTitle_r4830_8", + KeyName: "servicesTitle_r4830_8", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.servicesDescription_r4830_9", + KeyName: "servicesDescription_r4830_9", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.servicesTitle_r4830_10", + KeyName: "servicesTitle_r4830_10", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.servicesDescription_r4830_11", + KeyName: "servicesDescription_r4830_11", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.servicesTitle_r4830_12", + KeyName: "servicesTitle_r4830_12", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "services.servicesDescription_r4830_13", + KeyName: "servicesDescription_r4830_13", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionServices, + }, + { + Path: "text.textName_r4748_17", + KeyName: "textName_r4748_17", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "text.textDescription_r4748_18", + KeyName: "textDescription_r4748_18", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "text.textName_r4748_19", + KeyName: "textName_r4748_19", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "text.textDescription_r4748_20", + KeyName: "textDescription_r4748_20", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "text.textName_r4748_21", + KeyName: "textName_r4748_21", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "text.textDescription_r4748_22", + KeyName: "textDescription_r4748_22", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTeam, + }, + { + Path: "testimonials.testimonialsName_r1508_23", + KeyName: "testimonialsName_r1508_23", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsTitle_r1508_24", + KeyName: "testimonialsTitle_r1508_24", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsDescription_r1508_25", + KeyName: "testimonialsDescription_r1508_25", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsName_r1508_26", + KeyName: "testimonialsName_r1508_26", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsTitle_r1508_27", + KeyName: "testimonialsTitle_r1508_27", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsDescription_r1508_28", + KeyName: "testimonialsDescription_r1508_28", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsName_r1508_29", + KeyName: "testimonialsName_r1508_29", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsTitle_r1508_30", + KeyName: "testimonialsTitle_r1508_30", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsDescription_r1508_31", + KeyName: "testimonialsDescription_r1508_31", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsName_r1508_32", + KeyName: "testimonialsName_r1508_32", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsTitle_r1508_33", + KeyName: "testimonialsTitle_r1508_33", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + { + Path: "testimonials.testimonialsDescription_r1508_34", + KeyName: "testimonialsDescription_r1508_34", + FieldKind: "text", + IsEnabled: true, + WebsiteSection: domain.WebsiteSectionTestimonials, + }, + } + + got := MapTemplateFieldsToSemanticSlots(fields) + + assertHasSlotPath(t, got, "service_items[0].title", "services.servicesTitle_r4830_8") + assertHasSlotPath(t, got, "service_items[0].description", "services.servicesDescription_r4830_9") + assertHasSlotPath(t, got, "service_items[1].title", "services.servicesTitle_r4830_10") + assertHasSlotPath(t, got, "service_items[1].description", "services.servicesDescription_r4830_11") + assertHasSlotPath(t, got, "service_items[2].title", "services.servicesTitle_r4830_12") + assertHasSlotPath(t, got, "service_items[2].description", "services.servicesDescription_r4830_13") + + assertHasSlotPath(t, got, "team_items[0].name", "text.textName_r4748_17") + assertHasSlotPath(t, got, "team_items[0].description", "text.textDescription_r4748_18") + assertHasSlotPath(t, got, "team_items[1].name", "text.textName_r4748_19") + assertHasSlotPath(t, got, "team_items[1].description", "text.textDescription_r4748_20") + assertHasSlotPath(t, got, "team_items[2].name", "text.textName_r4748_21") + assertHasSlotPath(t, got, "team_items[2].description", "text.textDescription_r4748_22") + + assertHasSlotPath(t, got, "testimonial_items[0].name", "testimonials.testimonialsName_r1508_23") + assertHasSlotPath(t, got, "testimonial_items[0].title", "testimonials.testimonialsTitle_r1508_24") + assertHasSlotPath(t, got, "testimonial_items[0].description", "testimonials.testimonialsDescription_r1508_25") + assertHasSlotPath(t, got, "testimonial_items[1].name", "testimonials.testimonialsName_r1508_26") + assertHasSlotPath(t, got, "testimonial_items[1].title", "testimonials.testimonialsTitle_r1508_27") + assertHasSlotPath(t, got, "testimonial_items[1].description", "testimonials.testimonialsDescription_r1508_28") + assertHasSlotPath(t, got, "testimonial_items[2].name", "testimonials.testimonialsName_r1508_29") + assertHasSlotPath(t, got, "testimonial_items[2].title", "testimonials.testimonialsTitle_r1508_30") + assertHasSlotPath(t, got, "testimonial_items[2].description", "testimonials.testimonialsDescription_r1508_31") + assertHasSlotPath(t, got, "testimonial_items[3].name", "testimonials.testimonialsName_r1508_32") + assertHasSlotPath(t, got, "testimonial_items[3].title", "testimonials.testimonialsTitle_r1508_33") + assertHasSlotPath(t, got, "testimonial_items[3].description", "testimonials.testimonialsDescription_r1508_34") + + assertSlotCount(t, got, "team_items[0].name", 1) + assertSlotCount(t, got, "team_items[0].description", 1) + assertSlotCount(t, got, "testimonial_items[0].name", 1) + assertSlotCount(t, got, "testimonial_items[0].title", 1) + assertSlotCount(t, got, "testimonial_items[0].description", 1) +} + +func assertHasSlotPath(t *testing.T, mapping SemanticSlotMapping, slot string, path string) { + t.Helper() + candidates := mapping.BySlot[slot] + for _, item := range candidates { + if item.FieldPath == path { + return + } + } + t.Fatalf("slot %q missing path %q", slot, path) +} + +func assertSlotCount(t *testing.T, mapping SemanticSlotMapping, slot string, expected int) { + t.Helper() + if got := len(mapping.BySlot[slot]); got != expected { + t.Fatalf("slot %q has %d candidates, want %d", slot, got, expected) + } +} diff --git a/web/templates/build_new.gohtml b/web/templates/build_new.gohtml index 78612c1..2c2c86e 100644 --- a/web/templates/build_new.gohtml +++ b/web/templates/build_new.gohtml @@ -40,7 +40,6 @@ -
Globaler Master Prompt kommt aus Settings.
- - - {{range $i, $block := .Form.PromptBlocks}} - - -{{.PromptPreview}}
+ Prompt-/Systemsteuerung wird global unter Settings gepflegt.
| Slot | Zuordnungen | Beispiele |
|---|---|---|
| {{.Slot}} | +{{.Count}} | +{{.Examples}} | +
{{.Description}}
{{end}} diff --git a/web/templates/settings.gohtml b/web/templates/settings.gohtml index 39fc9aa..dc2c004 100644 --- a/web/templates/settings.gohtml +++ b/web/templates/settings.gohtml @@ -10,7 +10,7 @@ {{if .Msg}}QC-Settings plus globaler Prompt-Standard fuer den spaeteren LLM-Flow.
+QC-Settings plus globale Prompt-/Systemsteuerung fuer den spaeteren LLM-Flow.
| QC Base URL | {{.QCBaseURL}} |
|---|---|
| Bearer token configured | {{if .TokenConfigured}}yes{{else}}no{{end}} |
Diese Einstellungen gelten systemweit und werden im normalen Build-/Review-Formular nicht mehr direkt editiert.