| @@ -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 | |||
| @@ -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). | |||
| @@ -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 { | |||
| @@ -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)) | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| @@ -40,7 +40,6 @@ | |||
| <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"> | |||
| @@ -114,26 +113,7 @@ | |||
| </div> | |||
| </div> | |||
| <div><label>Website Summary<textarea name="website_summary">{{.Form.WebsiteSummary}}</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> | |||
| <p><small>Prompt-/Systemsteuerung wird global unter <a href="/settings">Settings</a> gepflegt.</small></p> | |||
| <h3>Kontakt</h3> | |||
| <div class="grid2"> | |||
| @@ -161,6 +141,26 @@ | |||
| </div> | |||
| <h2>Template-Felder</h2> | |||
| {{if .SemanticSlots}} | |||
| <details> | |||
| <summary>Technik-Preview: Semantische Zielslots (intern)</summary> | |||
| <table> | |||
| <thead> | |||
| <tr><th>Slot</th><th>Zuordnungen</th><th>Beispiele</th></tr> | |||
| </thead> | |||
| <tbody> | |||
| {{range .SemanticSlots}} | |||
| <tr> | |||
| <td class="mono">{{.Slot}}</td> | |||
| <td>{{.Count}}</td> | |||
| <td class="mono">{{.Examples}}</td> | |||
| </tr> | |||
| {{end}} | |||
| </tbody> | |||
| </table> | |||
| </details> | |||
| {{end}} | |||
| {{range .FieldSections}} | |||
| <h3>{{.Title}}</h3> | |||
| {{if .Description}}<p>{{.Description}}</p>{{end}} | |||
| @@ -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>QC-Settings plus globaler Prompt-Standard fuer den spaeteren LLM-Flow.</p> | |||
| <p>QC-Settings plus globale Prompt-/Systemsteuerung 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> | |||
| @@ -21,6 +21,7 @@ | |||
| </table> | |||
| <h2>Globaler Master Prompt</h2> | |||
| <p><small>Diese Einstellungen gelten systemweit und werden im normalen Build-/Review-Formular nicht mehr direkt editiert.</small></p> | |||
| <form method="post" action="/settings/prompt"> | |||
| <input type="hidden" name="prompt_block_count" value="{{len .PromptBlocks}}"> | |||
| <div> | |||