| @@ -10,12 +10,14 @@ Die App kann heute: | |||||
| - Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren. | - 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). | - 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. | - 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. | - Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen. | ||||
| Wichtig: | Wichtig: | ||||
| - Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts. | - 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 | ## Lokaler Start | ||||
| @@ -38,7 +38,9 @@ Aktueller Stand: | |||||
| - Draft-Erfassung, Listing, Update und UI-Weiterbearbeitung sind vorhanden. | - 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. | - 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). | - 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`. | - 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. | - 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. | - [ ] Feldvorschlaege fuer fehlende Inhalte im Draft. | ||||
| - [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld. | - [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld. | ||||
| - [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder + Auswahlfelder 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. | |||||
| - [-] 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 | ### F) Security und Betriebsreife | ||||
| - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | - [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik). | ||||
| @@ -1,7 +1,6 @@ | |||||
| package handlers | package handlers | ||||
| import ( | import ( | ||||
| "bytes" | |||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "fmt" | "fmt" | ||||
| @@ -19,6 +18,7 @@ import ( | |||||
| "qctextbuilder/internal/config" | "qctextbuilder/internal/config" | ||||
| "qctextbuilder/internal/domain" | "qctextbuilder/internal/domain" | ||||
| "qctextbuilder/internal/draftsvc" | "qctextbuilder/internal/draftsvc" | ||||
| "qctextbuilder/internal/mapping" | |||||
| "qctextbuilder/internal/onboarding" | "qctextbuilder/internal/onboarding" | ||||
| "qctextbuilder/internal/store" | "qctextbuilder/internal/store" | ||||
| "qctextbuilder/internal/templatesvc" | "qctextbuilder/internal/templatesvc" | ||||
| @@ -113,6 +113,12 @@ type buildFieldSectionView struct { | |||||
| DisabledFields []buildFieldView | DisabledFields []buildFieldView | ||||
| } | } | ||||
| type semanticSlotPreviewView struct { | |||||
| Slot string | |||||
| Count int | |||||
| Examples string | |||||
| } | |||||
| type pendingField struct { | type pendingField struct { | ||||
| Field domain.TemplateField | Field domain.TemplateField | ||||
| View buildFieldView | View buildFieldView | ||||
| @@ -148,7 +154,7 @@ type buildNewPageData struct { | |||||
| EditableFields []buildFieldView | EditableFields []buildFieldView | ||||
| EnabledFields []buildFieldView | EnabledFields []buildFieldView | ||||
| Form buildFormInput | Form buildFormInput | ||||
| PromptPreview string | |||||
| SemanticSlots []semanticSlotPreviewView | |||||
| } | } | ||||
| type buildFormInput struct { | type buildFormInput struct { | ||||
| @@ -382,6 +388,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) { | |||||
| } | } | ||||
| form := buildFormInputFromRequest(r) | form := buildFormInputFromRequest(r) | ||||
| form = u.applyPromptConfigForBuildFlow(r.Context(), form) | |||||
| fieldValues := parseBuildFieldValues(r) | fieldValues := parseBuildFieldValues(r) | ||||
| globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ | globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{ | ||||
| CompanyName: form.CompanyName, | CompanyName: form.CompanyName, | ||||
| @@ -453,6 +460,7 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) { | |||||
| return | return | ||||
| } | } | ||||
| form := buildFormInputFromRequest(r) | form := buildFormInputFromRequest(r) | ||||
| form = u.applyPromptConfigForBuildFlow(r.Context(), form) | |||||
| fieldValues := parseBuildFieldValues(r) | fieldValues := parseBuildFieldValues(r) | ||||
| templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64) | ||||
| if err != nil || templateID <= 0 { | if err != nil || templateID <= 0 { | ||||
| @@ -604,7 +612,6 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||||
| SelectedDraftID: selectedDraftID, | SelectedDraftID: selectedDraftID, | ||||
| SelectedTemplateID: selectedTemplateID, | SelectedTemplateID: selectedTemplateID, | ||||
| Form: form, | Form: form, | ||||
| PromptPreview: composePromptPreview(form), | |||||
| } | } | ||||
| if selectedTemplateID <= 0 { | if selectedTemplateID <= 0 { | ||||
| return data, nil | return data, nil | ||||
| @@ -617,9 +624,29 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI | |||||
| data.SelectedManifestID = detail.Manifest.ID | data.SelectedManifestID = detail.Manifest.ID | ||||
| data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues) | data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues) | ||||
| data.EnabledFields = data.EditableFields | data.EnabledFields = data.EditableFields | ||||
| data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields)) | |||||
| return data, nil | 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) { | func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) { | ||||
| sectionOrder := []string{ | sectionOrder := []string{ | ||||
| domain.WebsiteSectionHero, | domain.WebsiteSectionHero, | ||||
| @@ -1177,37 +1204,34 @@ func humanizeKey(key string) string { | |||||
| func buildFormInputFromRequest(r *http.Request) buildFormInput { | func buildFormInputFromRequest(r *http.Request) buildFormInput { | ||||
| form := 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 | return form | ||||
| } | } | ||||
| @@ -1469,64 +1493,30 @@ func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form | |||||
| return out | 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 { | 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="template_id" value="{{.SelectedTemplateID}}"> | ||||
| <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | <input type="hidden" name="manifest_id" value="{{.SelectedManifestID}}"> | ||||
| <input type="hidden" name="field_count" value="{{len .EditableFields}}"> | <input type="hidden" name="field_count" value="{{len .EditableFields}}"> | ||||
| <input type="hidden" name="prompt_block_count" value="{{len .Form.PromptBlocks}}"> | |||||
| <h2>Global Data</h2> | <h2>Global Data</h2> | ||||
| <div class="grid2"> | <div class="grid2"> | ||||
| @@ -114,26 +113,7 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div><label>Website Summary<textarea name="website_summary">{{.Form.WebsiteSummary}}</textarea></label></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> | <h3>Kontakt</h3> | ||||
| <div class="grid2"> | <div class="grid2"> | ||||
| @@ -161,6 +141,26 @@ | |||||
| </div> | </div> | ||||
| <h2>Template-Felder</h2> | <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}} | {{range .FieldSections}} | ||||
| <h3>{{.Title}}</h3> | <h3>{{.Title}}</h3> | ||||
| {{if .Description}}<p>{{.Description}}</p>{{end}} | {{if .Description}}<p>{{.Description}}</p>{{end}} | ||||
| @@ -10,7 +10,7 @@ | |||||
| {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | {{if .Msg}}<div class="flash flash-ok">{{.Msg}}</div>{{end}} | ||||
| {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | {{if .Err}}<div class="flash flash-err">{{.Err}}</div>{{end}} | ||||
| <h1>Settings</h1> | <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> | <table> | ||||
| <tr><th>QC Base URL</th><td class="mono">{{.QCBaseURL}}</td></tr> | <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> | <tr><th>Bearer token configured</th><td>{{if .TokenConfigured}}yes{{else}}no{{end}}</td></tr> | ||||
| @@ -21,6 +21,7 @@ | |||||
| </table> | </table> | ||||
| <h2>Globaler Master Prompt</h2> | <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"> | <form method="post" action="/settings/prompt"> | ||||
| <input type="hidden" name="prompt_block_count" value="{{len .PromptBlocks}}"> | <input type="hidden" name="prompt_block_count" value="{{len .PromptBlocks}}"> | ||||
| <div> | <div> | ||||