Переглянути джерело

feat: prepare semantic slot mapping for autofill

master
Jan Svabenik 1 місяць тому
джерело
коміт
7d32fac979
7 змінених файлів з 848 додано та 113 видалено
  1. +4
    -2
      README.md
  2. +5
    -2
      docs/TARGET_STATE_AND_ROADMAP.md
  3. +77
    -87
      internal/httpserver/handlers/ui.go
  4. +386
    -0
      internal/mapping/semantic_slots.go
  5. +353
    -0
      internal/mapping/semantic_slots_test.go
  6. +21
    -21
      web/templates/build_new.gohtml
  7. +2
    -1
      web/templates/settings.gohtml

+ 4
- 2
README.md Переглянути файл

@@ -10,12 +10,14 @@ Die App kann heute:
- Drafts anlegen, aktualisieren und im Status `draft` -> `reviewed` -> `submitted` fuehren.
- Externen Draft-Intake ueber `POST /api/drafts/intake` verarbeiten (Stammdaten + optional Website-/Stilkontext, kein Direkt-Build).
- Globalen Master-Prompt in Settings pflegen sowie Prompt-Bloecke fuer den spaeteren LLM-Flow als Standard konfigurieren.
- Im Draft-/Build-UI Prompt-Bloecke je Draft aktivieren/deaktivieren und editieren; Prompt-Aufbau wird als Vorschau angezeigt.
- Im Draft-/Build-UI den User-Flow auf Stammdaten, Intake-/Website-Kontext, Stil-Auswahl und Template-Felder fokussieren; Prompt-Interna liegen in Settings.
- Interne semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) auf Template-Felder abbilden als Vorbereitung fuer spaeteren LLM-Autofill.
- Repeated-Bereiche in semantischen Slots werden block-/rollenbasiert getrennt (z. B. Services/Team/Testimonials pro Item statt Sammel-Slot).
- Builds aus geprueften Daten starten sowie Job-Status pollen und Editor-URL nachladen.

Wichtig:
- Leadharvester liefert nur Intake-Daten (Stammdaten + optional Kontext) in Drafts.
- LLM-Autofill ist noch nicht fertig; vorbereitet sind Kontextfelder plus editierbarer Prompt-Generator (Master + Bloecke + Stilsteuerung) im Draft-Review-Flow.
- LLM-Autofill ist noch nicht fertig; vorbereitet sind Kontextfelder, globale Prompt-Steuerung in Settings und semantische Slot-Mappings als Bruecke zu `fieldValues`.

## Lokaler Start



+ 5
- 2
docs/TARGET_STATE_AND_ROADMAP.md Переглянути файл

@@ -38,7 +38,9 @@ Aktueller Stand:
- Draft-Erfassung, Listing, Update und UI-Weiterbearbeitung sind vorhanden.
- Definierter externer Intake unter `POST /api/drafts/intake` ist vorhanden; `templateId` ist optional, Build wird dort nicht ausgeloest.
- Draft-Kontext fuer spaetere LLM-Unterstuetzung ist vorbereitet (Website-URL, Website-Summary, Stilprofil inkl. locale/market/address mode/tone/prompt instructions).
- Prompt-Generator als MVP ist im UI vorhanden: globaler Master-Prompt (Settings), editierbare Prompt-Bloecke je Draft, sichtbare Prompt-Zusammensetzung.
- Prompt-/Systemsteuerung liegt global in Settings; der normale Build-/Review-Flow bleibt auf Inhalte und Feldbearbeitung fokussiert.
- Semantische Zielslots (z. B. `hero.title`, `service_items[n].description`) werden intern auf konkrete Template-Felder gemappt als Vorbereitung fuer spaeteren LLM-Autofill.
- Repeated-Sektionen (u. a. Services/Team/Testimonials) werden in der Slot-Vorschau block- und rollentypisch pro Item getrennt statt in Sammel-Slots zusammenzufallen.
- Build-Start erfordert bereits einen Template-Manifest-Status `reviewed`/`validated`.
- Prozessuale Review-Gates (z. B. Freigabe-Policy, Rollen, Pflichtchecks pro Feld) sind noch nicht vollstaendig ausgebaut.

@@ -102,7 +104,8 @@ Statusmarker:
- [ ] Feldvorschlaege fuer fehlende Inhalte im Draft.
- [ ] Draft-Autofill mit nachvollziehbarer Herkunft je Feld.
- [-] Stilprofil-Logik unter Beruecksichtigung von `businessType` + Tonalitaet (Kontextfelder + Auswahlfelder vorhanden, produktive Vorschlagslogik offen).
- [-] Prompt-Generator (Master-Prompt + aktivierbare Prompt-Bloecke + Prompt-Vorschau) als Vorbereitung fuer spaeteren LLM-Runner.
- [-] Prompt-/Systemsteuerung (Master-Prompt + Prompt-Bloecke) in Settings als Vorbereitung fuer spaeteren LLM-Runner; Build-Flow ohne prominente Prompt-Interna.
- [-] Semantische Slot-Mappings zwischen Template-Feldern und Zielrollen als Bruecke fuer spaeteren LLM-Autofill vorbereitet (inkl. verbesserter Trennung in Repeated-Bereichen).

### F) Security und Betriebsreife
- [ ] Verbindliche Secret-Strategie (verschluesselte Speicherung statt einfacher Platzhalterlogik).


+ 77
- 87
internal/httpserver/handlers/ui.go Переглянути файл

@@ -1,7 +1,6 @@
package handlers

import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -19,6 +18,7 @@ import (
"qctextbuilder/internal/config"
"qctextbuilder/internal/domain"
"qctextbuilder/internal/draftsvc"
"qctextbuilder/internal/mapping"
"qctextbuilder/internal/onboarding"
"qctextbuilder/internal/store"
"qctextbuilder/internal/templatesvc"
@@ -113,6 +113,12 @@ type buildFieldSectionView struct {
DisabledFields []buildFieldView
}

type semanticSlotPreviewView struct {
Slot string
Count int
Examples string
}

type pendingField struct {
Field domain.TemplateField
View buildFieldView
@@ -148,7 +154,7 @@ type buildNewPageData struct {
EditableFields []buildFieldView
EnabledFields []buildFieldView
Form buildFormInput
PromptPreview string
SemanticSlots []semanticSlotPreviewView
}

type buildFormInput struct {
@@ -382,6 +388,7 @@ func (u *UI) CreateBuild(w http.ResponseWriter, r *http.Request) {
}

form := buildFormInputFromRequest(r)
form = u.applyPromptConfigForBuildFlow(r.Context(), form)
fieldValues := parseBuildFieldValues(r)
globalData := buildsvc.BuildGlobalData(buildsvc.GlobalDataInput{
CompanyName: form.CompanyName,
@@ -453,6 +460,7 @@ func (u *UI) SaveDraft(w http.ResponseWriter, r *http.Request) {
return
}
form := buildFormInputFromRequest(r)
form = u.applyPromptConfigForBuildFlow(r.Context(), form)
fieldValues := parseBuildFieldValues(r)
templateID, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("template_id")), 10, 64)
if err != nil || templateID <= 0 {
@@ -604,7 +612,6 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI
SelectedDraftID: selectedDraftID,
SelectedTemplateID: selectedTemplateID,
Form: form,
PromptPreview: composePromptPreview(form),
}
if selectedTemplateID <= 0 {
return data, nil
@@ -617,9 +624,29 @@ func (u *UI) loadBuildNewPageData(r *http.Request, page pageData, selectedDraftI
data.SelectedManifestID = detail.Manifest.ID
data.EditableFields, data.FieldSections = buildFieldSections(detail.Fields, fieldValues)
data.EnabledFields = data.EditableFields
data.SemanticSlots = semanticSlotPreview(mapping.MapTemplateFieldsToSemanticSlots(detail.Fields))
return data, nil
}

func (u *UI) applyPromptConfigForBuildFlow(ctx context.Context, form buildFormInput) buildFormInput {
settings := u.loadPromptSettings(ctx)
form.MasterPrompt = settings.MasterPrompt
form.PromptBlocks = clonePromptBlocks(settings.PromptBlocks)
if strings.TrimSpace(form.DraftID) == "" {
return form
}

draft, err := u.draftSvc.GetDraft(ctx, strings.TrimSpace(form.DraftID))
if err != nil || draft == nil {
return form
}
mergeDraftContextIntoForm(&form, draft.DraftContextJSON)
form.MasterPrompt = settings.MasterPrompt
form.PromptBlocks = mergePromptBlocks(form.PromptBlocks, settings.PromptBlocks)
form.PromptBlocks = applyPromptBlockActivationDefaults(form.PromptBlocks, form)
return form
}

func buildFieldSections(fields []domain.TemplateField, fieldValues map[string]string) ([]buildFieldView, []buildFieldSectionView) {
sectionOrder := []string{
domain.WebsiteSectionHero,
@@ -1177,37 +1204,34 @@ func humanizeKey(key string) string {

func buildFormInputFromRequest(r *http.Request) buildFormInput {
form := buildFormInput{
DraftID: strings.TrimSpace(r.FormValue("draft_id")),
DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
RequestName: strings.TrimSpace(r.FormValue("request_name")),
CompanyName: strings.TrimSpace(r.FormValue("company_name")),
BusinessType: strings.TrimSpace(r.FormValue("business_type")),
Username: strings.TrimSpace(r.FormValue("username")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
StartDate: strings.TrimSpace(r.FormValue("start_date")),
Mission: strings.TrimSpace(r.FormValue("mission")),
DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
AddressCity: strings.TrimSpace(r.FormValue("address_city")),
AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
PromptInstructions: strings.TrimSpace(r.FormValue("prompt_instructions")),
MasterPrompt: domain.NormalizeMasterPrompt(r.FormValue("master_prompt")),
PromptBlocks: parsePromptBlocksFromRequest(r),
DraftID: strings.TrimSpace(r.FormValue("draft_id")),
DraftSource: strings.TrimSpace(r.FormValue("draft_source")),
DraftStatus: strings.TrimSpace(r.FormValue("draft_status")),
DraftNotes: strings.TrimSpace(r.FormValue("draft_notes")),
RequestName: strings.TrimSpace(r.FormValue("request_name")),
CompanyName: strings.TrimSpace(r.FormValue("company_name")),
BusinessType: strings.TrimSpace(r.FormValue("business_type")),
Username: strings.TrimSpace(r.FormValue("username")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
OrgNumber: strings.TrimSpace(r.FormValue("org_number")),
StartDate: strings.TrimSpace(r.FormValue("start_date")),
Mission: strings.TrimSpace(r.FormValue("mission")),
DescriptionShort: strings.TrimSpace(r.FormValue("description_short")),
DescriptionLong: strings.TrimSpace(r.FormValue("description_long")),
SiteLanguage: strings.TrimSpace(r.FormValue("site_language")),
AddressLine1: strings.TrimSpace(r.FormValue("address_line1")),
AddressLine2: strings.TrimSpace(r.FormValue("address_line2")),
AddressCity: strings.TrimSpace(r.FormValue("address_city")),
AddressRegion: strings.TrimSpace(r.FormValue("address_region")),
AddressZIP: strings.TrimSpace(r.FormValue("address_zip")),
AddressCountry: strings.TrimSpace(r.FormValue("address_country")),
WebsiteURL: strings.TrimSpace(r.FormValue("website_url")),
WebsiteSummary: strings.TrimSpace(r.FormValue("website_summary")),
LocaleStyle: strings.TrimSpace(r.FormValue("locale_style")),
MarketStyle: strings.TrimSpace(r.FormValue("market_style")),
AddressMode: strings.TrimSpace(r.FormValue("address_mode")),
ContentTone: strings.TrimSpace(r.FormValue("content_tone")),
}
return form
}
@@ -1469,64 +1493,30 @@ func applyPromptBlockActivationDefaults(blocks []domain.PromptBlockConfig, form
return out
}

func composePromptPreview(form buildFormInput) string {
var b bytes.Buffer
b.WriteString(strings.TrimSpace(form.MasterPrompt))
b.WriteString("\n\nAktive Prompt-Bloecke:\n")

active := 0
for _, block := range form.PromptBlocks {
if !block.Enabled {
continue
}
active++
b.WriteString("\n[")
b.WriteString(block.ID)
b.WriteString("] ")
b.WriteString(strings.TrimSpace(block.Instruction))
contextValue := promptContextValueForBlock(block.ID, form)
if contextValue != "" {
b.WriteString("\nKontext: ")
b.WriteString(contextValue)
}
b.WriteString("\n")
}

if active == 0 {
b.WriteString("\n(keine aktiven Bloecke)\n")
func semanticSlotPreview(mappingResult mapping.SemanticSlotMapping) []semanticSlotPreviewView {
if len(mappingResult.BySlot) == 0 {
return nil
}
if strings.TrimSpace(form.PromptInstructions) != "" {
b.WriteString("\nOptionale zusaetzliche Instructions:\n")
b.WriteString(strings.TrimSpace(form.PromptInstructions))
b.WriteString("\n")
slotKeys := make([]string, 0, len(mappingResult.BySlot))
for slot := range mappingResult.BySlot {
slotKeys = append(slotKeys, slot)
}
return strings.TrimSpace(b.String())
}
sort.Strings(slotKeys)

func promptContextValueForBlock(blockID string, form buildFormInput) string {
switch strings.TrimSpace(blockID) {
case "business_type":
return strings.TrimSpace(form.BusinessType)
case "website_summary":
return strings.TrimSpace(form.WebsiteSummary)
case "style_market_locale":
parts := make([]string, 0, 2)
if strings.TrimSpace(form.LocaleStyle) != "" {
parts = append(parts, "Locale="+strings.TrimSpace(form.LocaleStyle))
}
if strings.TrimSpace(form.MarketStyle) != "" {
parts = append(parts, "Market="+strings.TrimSpace(form.MarketStyle))
out := make([]semanticSlotPreviewView, 0, len(slotKeys))
for _, slot := range slotKeys {
targets := mappingResult.BySlot[slot]
examples := make([]string, 0, 2)
for i := 0; i < len(targets) && i < 2; i++ {
examples = append(examples, targets[i].FieldPath)
}
return strings.Join(parts, ", ")
case "address_mode":
return strings.TrimSpace(form.AddressMode)
case "content_tone":
return strings.TrimSpace(form.ContentTone)
case "free_instructions":
return strings.TrimSpace(form.PromptInstructions)
default:
return ""
out = append(out, semanticSlotPreviewView{
Slot: slot,
Count: len(targets),
Examples: strings.Join(examples, ", "),
})
}
return out
}

func getString(v any) string {


+ 386
- 0
internal/mapping/semantic_slots.go Переглянути файл

@@ -0,0 +1,386 @@
package mapping

import (
"fmt"
"math"
"regexp"
"sort"
"strconv"
"strings"

"qctextbuilder/internal/domain"
)

type SemanticSlotTarget struct {
Slot string `json:"slot"`
FieldPath string `json:"fieldPath"`
FieldKey string `json:"fieldKey"`
DisplayLabel string `json:"displayLabel,omitempty"`
WebsiteSection string `json:"websiteSection,omitempty"`
BlockID string `json:"blockId,omitempty"`
Reason string `json:"reason,omitempty"`
}

type SemanticSlotMapping struct {
Targets []SemanticSlotTarget `json:"targets"`
BySlot map[string][]SemanticSlotTarget
}

var semanticBlockIDPattern = regexp.MustCompile(`(?i)(?:^|[_.])([mcr]\d{3,})(?:[_.]|$)`)
var semanticLooseBlockIDPattern = regexp.MustCompile(`(?i)([mcr]\d{3,})`)
var semanticIndexSuffixPattern = regexp.MustCompile(`_\d+$`)
var semanticTrailingNumberPattern = regexp.MustCompile(`_(\d+)$`)

func MapTemplateFieldsToSemanticSlots(fields []domain.TemplateField) SemanticSlotMapping {
sectionGroupIndex := map[string]map[string]int{
domain.WebsiteSectionServices: {},
domain.WebsiteSectionServiceItem: {},
domain.WebsiteSectionTeam: {},
domain.WebsiteSectionTestimonials: {},
}
sectionGroupNext := map[string]int{
domain.WebsiteSectionServices: 0,
domain.WebsiteSectionServiceItem: 0,
domain.WebsiteSectionTeam: 0,
domain.WebsiteSectionTestimonials: 0,
}
repeatedIndexResolver := newSemanticRepeatedIndexResolver(fields)

targets := make([]SemanticSlotTarget, 0)
for _, field := range fields {
if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") {
continue
}

section := semanticSection(field)
role := semanticRole(field)
slot, mapped := semanticSlotForField(field, section, role, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver)
if !mapped {
continue
}

reason := "section=" + section + ", role=" + role
if blockID := semanticExtractBlockID(field); blockID != "" {
reason += ", block=" + blockID
}

targets = append(targets, SemanticSlotTarget{
Slot: slot,
FieldPath: strings.TrimSpace(field.Path),
FieldKey: strings.TrimSpace(field.KeyName),
DisplayLabel: strings.TrimSpace(field.DisplayLabel),
WebsiteSection: section,
BlockID: semanticExtractBlockID(field),
Reason: reason,
})
}

sort.SliceStable(targets, func(i, j int) bool {
if targets[i].Slot == targets[j].Slot {
return targets[i].FieldPath < targets[j].FieldPath
}
return targets[i].Slot < targets[j].Slot
})

bySlot := make(map[string][]SemanticSlotTarget, len(targets))
for _, target := range targets {
bySlot[target.Slot] = append(bySlot[target.Slot], target)
}
return SemanticSlotMapping{
Targets: targets,
BySlot: bySlot,
}
}

func semanticSlotForField(
field domain.TemplateField,
section string,
role string,
sectionGroupIndex map[string]map[string]int,
sectionGroupNext map[string]int,
repeatedIndexResolver *semanticRepeatedIndexResolver,
) (string, bool) {
switch section {
case domain.WebsiteSectionHero:
if role == "title" {
return "hero.title", true
}
case domain.WebsiteSectionIntro:
if role == "title" {
return "intro.title", true
}
if role == "description" {
return "intro.description", true
}
case domain.WebsiteSectionAbout:
if role == "description" || role == "title" {
return "about.description", true
}
case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem:
if role == "title" || role == "description" {
index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver)
return fmt.Sprintf("service_items[%d].%s", index, role), true
}
case domain.WebsiteSectionTeam:
if role == "name" || role == "description" {
index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver)
return fmt.Sprintf("team_items[%d].%s", index, role), true
}
case domain.WebsiteSectionTestimonials:
if role == "title" || role == "description" || role == "name" {
index := semanticRepeatedIndex(section, role, field, sectionGroupIndex, sectionGroupNext, repeatedIndexResolver)
return fmt.Sprintf("testimonial_items[%d].%s", index, role), true
}
case domain.WebsiteSectionCTA:
if role == "cta_text" || role == "title" || role == "description" {
return "cta.text", true
}
}
return "", false
}

func semanticRepeatedIndex(
section string,
role string,
field domain.TemplateField,
sectionGroupIndex map[string]map[string]int,
sectionGroupNext map[string]int,
repeatedIndexResolver *semanticRepeatedIndexResolver,
) int {
if repeatedIndexResolver != nil {
if idx, ok := repeatedIndexResolver.IndexFor(section, role, field); ok {
return idx
}
}
return semanticGroupIndex(section, field, sectionGroupIndex, sectionGroupNext)
}

func semanticGroupIndex(
section string,
field domain.TemplateField,
sectionGroupIndex map[string]map[string]int,
sectionGroupNext map[string]int,
) int {
normalizedSection := domain.NormalizeWebsiteSection(section)
group := semanticGroupKey(field)
if _, ok := sectionGroupIndex[normalizedSection]; !ok {
sectionGroupIndex[normalizedSection] = map[string]int{}
}
if idx, ok := sectionGroupIndex[normalizedSection][group]; ok {
return idx
}
idx := sectionGroupNext[normalizedSection]
sectionGroupNext[normalizedSection] = idx + 1
sectionGroupIndex[normalizedSection][group] = idx
return idx
}

func semanticGroupKey(field domain.TemplateField) string {
if blockID := semanticExtractBlockID(field); blockID != "" {
return "block:" + blockID
}
key := strings.ToLower(strings.TrimSpace(field.KeyName))
if key != "" {
return "key:" + semanticIndexSuffixPattern.ReplaceAllString(key, "")
}
path := strings.ToLower(strings.TrimSpace(field.Path))
return "path:" + semanticIndexSuffixPattern.ReplaceAllString(path, "")
}

func semanticSection(field domain.TemplateField) string {
websiteSection := domain.NormalizeWebsiteSection(field.WebsiteSection)
if websiteSection != domain.WebsiteSectionOther {
return websiteSection
}
return domain.SuggestWebsiteSection(field)
}

func semanticRole(field domain.TemplateField) string {
parts := []string{
strings.ToLower(strings.TrimSpace(field.KeyName)),
strings.ToLower(strings.TrimSpace(field.Path)),
strings.ToLower(strings.TrimSpace(field.DisplayLabel)),
strings.ToLower(strings.TrimSpace(field.Section)),
}
combined := strings.Join(parts, " ")
switch {
case semanticContainsAny(combined, "description", "subtitle", "paragraph", "copy", "body", "content", "mission", "story", "bio", "quote"):
return "description"
case semanticContainsAny(combined, "button", "btn", "calltoaction", "call_to_action", "cta"):
return "cta_text"
case semanticContainsAny(combined, "headline", "heading", "title"):
return "title"
case semanticContainsAny(combined, "author", "customer", "person", "member", "name"):
return "name"
default:
return "description"
}
}

func semanticExtractBlockID(field domain.TemplateField) string {
candidates := []string{
strings.TrimSpace(field.KeyName),
strings.TrimSpace(field.Path),
strings.TrimSpace(field.DisplayLabel),
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if match := semanticBlockIDPattern.FindStringSubmatch(candidate); len(match) > 1 {
return strings.ToLower(match[1])
}
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if match := semanticLooseBlockIDPattern.FindStringSubmatch(candidate); len(match) > 1 {
return strings.ToLower(match[1])
}
}
return ""
}

func semanticContainsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}

type semanticRepeatedIndexResolver struct {
byFieldKey map[string]int
}

type semanticRepeatedField struct {
fieldKey string
suffix int
path string
}

func newSemanticRepeatedIndexResolver(fields []domain.TemplateField) *semanticRepeatedIndexResolver {
resolver := &semanticRepeatedIndexResolver{
byFieldKey: map[string]int{},
}
// Pair repeated fields by section + block + role + numeric suffix ordering.
buckets := map[string][]semanticRepeatedField{}
for _, field := range fields {
if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") {
continue
}
section := semanticSection(field)
if !semanticIsRepeatedSection(section) {
continue
}
role := semanticRole(field)
if !semanticRoleAllowedForRepeated(section, role) {
continue
}
suffix, ok := semanticTrailingNumber(field)
if !ok {
continue
}
bucket := semanticRepeatedBucketKey(section, semanticExtractBlockID(field), role)
entry := semanticRepeatedField{
fieldKey: semanticFieldIdentity(field),
suffix: suffix,
path: strings.TrimSpace(field.Path),
}
buckets[bucket] = append(buckets[bucket], entry)
}

for _, bucketEntries := range buckets {
sort.SliceStable(bucketEntries, func(i, j int) bool {
if bucketEntries[i].suffix != bucketEntries[j].suffix {
return bucketEntries[i].suffix < bucketEntries[j].suffix
}
return bucketEntries[i].path < bucketEntries[j].path
})
for idx, entry := range bucketEntries {
resolver.byFieldKey[entry.fieldKey] = idx
}
}
return resolver
}

func (r *semanticRepeatedIndexResolver) IndexFor(section string, role string, field domain.TemplateField) (int, bool) {
if r == nil || len(r.byFieldKey) == 0 {
return 0, false
}
if !semanticIsRepeatedSection(section) || !semanticRoleAllowedForRepeated(section, role) {
return 0, false
}
idx, ok := r.byFieldKey[semanticFieldIdentity(field)]
return idx, ok
}

func semanticIsRepeatedSection(section string) bool {
switch domain.NormalizeWebsiteSection(section) {
case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem, domain.WebsiteSectionTeam, domain.WebsiteSectionTestimonials:
return true
default:
return false
}
}

func semanticRoleAllowedForRepeated(section string, role string) bool {
switch domain.NormalizeWebsiteSection(section) {
case domain.WebsiteSectionServices, domain.WebsiteSectionServiceItem:
return role == "title" || role == "description"
case domain.WebsiteSectionTeam:
return role == "name" || role == "description"
case domain.WebsiteSectionTestimonials:
return role == "name" || role == "title" || role == "description"
default:
return false
}
}

func semanticRepeatedBucketKey(section string, blockID string, role string) string {
normalizedSection := domain.NormalizeWebsiteSection(section)
if normalizedSection == domain.WebsiteSectionServiceItem {
normalizedSection = domain.WebsiteSectionServices
}
block := strings.TrimSpace(strings.ToLower(blockID))
if block == "" {
block = "__no_block__"
}
return normalizedSection + "|" + block + "|" + role
}

func semanticTrailingNumber(field domain.TemplateField) (int, bool) {
candidates := []string{
strings.TrimSpace(strings.ToLower(field.Path)),
strings.TrimSpace(strings.ToLower(field.KeyName)),
}
best := math.MaxInt
found := false
for _, candidate := range candidates {
if candidate == "" {
continue
}
match := semanticTrailingNumberPattern.FindStringSubmatch(candidate)
if len(match) < 2 {
continue
}
value, err := strconv.Atoi(match[1])
if err != nil {
continue
}
if value < best {
best = value
}
found = true
}
if !found {
return 0, false
}
return best, true
}

func semanticFieldIdentity(field domain.TemplateField) string {
return strings.ToLower(strings.TrimSpace(field.Path)) + "|" + strings.ToLower(strings.TrimSpace(field.KeyName))
}

+ 353
- 0
internal/mapping/semantic_slots_test.go Переглянути файл

@@ -0,0 +1,353 @@
package mapping

import (
"testing"

"qctextbuilder/internal/domain"
)

func TestMapTemplateFieldsToSemanticSlots(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{
Path: "text.textTitle_m1710_1",
KeyName: "textTitle_m1710_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionHero,
},
{
Path: "text.introTitle_c7886_1",
KeyName: "introTitle_c7886_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionIntro,
},
{
Path: "text.introDescription_c7886_2",
KeyName: "introDescription_c7886_2",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionIntro,
},
{
Path: "services.serviceTitle_r4830_1",
KeyName: "serviceTitle_r4830_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.serviceDescription_r4830_2",
KeyName: "serviceDescription_r4830_2",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "team.teamMemberName_r4748_1",
KeyName: "teamMemberName_r4748_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "team.teamDescription_r4748_2",
KeyName: "teamDescription_r4748_2",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "testimonials.testimonialTitle_r1508_1",
KeyName: "testimonialTitle_r1508_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialDescription_r1508_2",
KeyName: "testimonialDescription_r1508_2",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialName_r1508_3",
KeyName: "testimonialName_r1508_3",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "text.buttonText_c1165_1",
KeyName: "buttonText_c1165_1",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionCTA,
},
}

got := MapTemplateFieldsToSemanticSlots(fields)
if len(got.Targets) != 11 {
t.Fatalf("expected 11 targets, got %d", len(got.Targets))
}

assertHasSlotPath(t, got, "hero.title", "text.textTitle_m1710_1")
assertHasSlotPath(t, got, "intro.title", "text.introTitle_c7886_1")
assertHasSlotPath(t, got, "intro.description", "text.introDescription_c7886_2")
assertHasSlotPath(t, got, "service_items[0].title", "services.serviceTitle_r4830_1")
assertHasSlotPath(t, got, "service_items[0].description", "services.serviceDescription_r4830_2")
assertHasSlotPath(t, got, "team_items[0].name", "team.teamMemberName_r4748_1")
assertHasSlotPath(t, got, "team_items[0].description", "team.teamDescription_r4748_2")
assertHasSlotPath(t, got, "testimonial_items[0].title", "testimonials.testimonialTitle_r1508_1")
assertHasSlotPath(t, got, "testimonial_items[0].description", "testimonials.testimonialDescription_r1508_2")
assertHasSlotPath(t, got, "testimonial_items[0].name", "testimonials.testimonialName_r1508_3")
assertHasSlotPath(t, got, "cta.text", "text.buttonText_c1165_1")
}

func TestMapTemplateFieldsToSemanticSlots_UsesSuggestedSection(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{
Path: "welcome.headline_m1710_1",
KeyName: "headline_m1710_1",
Section: "welcome",
FieldKind: "text",
IsEnabled: true,
},
}

got := MapTemplateFieldsToSemanticSlots(fields)
assertHasSlotPath(t, got, "hero.title", "welcome.headline_m1710_1")
}

func TestMapTemplateFieldsToSemanticSlots_RepeatedBlocksStaySeparatedAndTypePure(t *testing.T) {
t.Parallel()

fields := []domain.TemplateField{
{
Path: "services.servicesTitle_r4830_8",
KeyName: "servicesTitle_r4830_8",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.servicesDescription_r4830_9",
KeyName: "servicesDescription_r4830_9",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.servicesTitle_r4830_10",
KeyName: "servicesTitle_r4830_10",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.servicesDescription_r4830_11",
KeyName: "servicesDescription_r4830_11",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.servicesTitle_r4830_12",
KeyName: "servicesTitle_r4830_12",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "services.servicesDescription_r4830_13",
KeyName: "servicesDescription_r4830_13",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionServices,
},
{
Path: "text.textName_r4748_17",
KeyName: "textName_r4748_17",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "text.textDescription_r4748_18",
KeyName: "textDescription_r4748_18",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "text.textName_r4748_19",
KeyName: "textName_r4748_19",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "text.textDescription_r4748_20",
KeyName: "textDescription_r4748_20",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "text.textName_r4748_21",
KeyName: "textName_r4748_21",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "text.textDescription_r4748_22",
KeyName: "textDescription_r4748_22",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTeam,
},
{
Path: "testimonials.testimonialsName_r1508_23",
KeyName: "testimonialsName_r1508_23",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsTitle_r1508_24",
KeyName: "testimonialsTitle_r1508_24",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsDescription_r1508_25",
KeyName: "testimonialsDescription_r1508_25",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsName_r1508_26",
KeyName: "testimonialsName_r1508_26",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsTitle_r1508_27",
KeyName: "testimonialsTitle_r1508_27",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsDescription_r1508_28",
KeyName: "testimonialsDescription_r1508_28",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsName_r1508_29",
KeyName: "testimonialsName_r1508_29",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsTitle_r1508_30",
KeyName: "testimonialsTitle_r1508_30",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsDescription_r1508_31",
KeyName: "testimonialsDescription_r1508_31",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsName_r1508_32",
KeyName: "testimonialsName_r1508_32",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsTitle_r1508_33",
KeyName: "testimonialsTitle_r1508_33",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
{
Path: "testimonials.testimonialsDescription_r1508_34",
KeyName: "testimonialsDescription_r1508_34",
FieldKind: "text",
IsEnabled: true,
WebsiteSection: domain.WebsiteSectionTestimonials,
},
}

got := MapTemplateFieldsToSemanticSlots(fields)

assertHasSlotPath(t, got, "service_items[0].title", "services.servicesTitle_r4830_8")
assertHasSlotPath(t, got, "service_items[0].description", "services.servicesDescription_r4830_9")
assertHasSlotPath(t, got, "service_items[1].title", "services.servicesTitle_r4830_10")
assertHasSlotPath(t, got, "service_items[1].description", "services.servicesDescription_r4830_11")
assertHasSlotPath(t, got, "service_items[2].title", "services.servicesTitle_r4830_12")
assertHasSlotPath(t, got, "service_items[2].description", "services.servicesDescription_r4830_13")

assertHasSlotPath(t, got, "team_items[0].name", "text.textName_r4748_17")
assertHasSlotPath(t, got, "team_items[0].description", "text.textDescription_r4748_18")
assertHasSlotPath(t, got, "team_items[1].name", "text.textName_r4748_19")
assertHasSlotPath(t, got, "team_items[1].description", "text.textDescription_r4748_20")
assertHasSlotPath(t, got, "team_items[2].name", "text.textName_r4748_21")
assertHasSlotPath(t, got, "team_items[2].description", "text.textDescription_r4748_22")

assertHasSlotPath(t, got, "testimonial_items[0].name", "testimonials.testimonialsName_r1508_23")
assertHasSlotPath(t, got, "testimonial_items[0].title", "testimonials.testimonialsTitle_r1508_24")
assertHasSlotPath(t, got, "testimonial_items[0].description", "testimonials.testimonialsDescription_r1508_25")
assertHasSlotPath(t, got, "testimonial_items[1].name", "testimonials.testimonialsName_r1508_26")
assertHasSlotPath(t, got, "testimonial_items[1].title", "testimonials.testimonialsTitle_r1508_27")
assertHasSlotPath(t, got, "testimonial_items[1].description", "testimonials.testimonialsDescription_r1508_28")
assertHasSlotPath(t, got, "testimonial_items[2].name", "testimonials.testimonialsName_r1508_29")
assertHasSlotPath(t, got, "testimonial_items[2].title", "testimonials.testimonialsTitle_r1508_30")
assertHasSlotPath(t, got, "testimonial_items[2].description", "testimonials.testimonialsDescription_r1508_31")
assertHasSlotPath(t, got, "testimonial_items[3].name", "testimonials.testimonialsName_r1508_32")
assertHasSlotPath(t, got, "testimonial_items[3].title", "testimonials.testimonialsTitle_r1508_33")
assertHasSlotPath(t, got, "testimonial_items[3].description", "testimonials.testimonialsDescription_r1508_34")

assertSlotCount(t, got, "team_items[0].name", 1)
assertSlotCount(t, got, "team_items[0].description", 1)
assertSlotCount(t, got, "testimonial_items[0].name", 1)
assertSlotCount(t, got, "testimonial_items[0].title", 1)
assertSlotCount(t, got, "testimonial_items[0].description", 1)
}

func assertHasSlotPath(t *testing.T, mapping SemanticSlotMapping, slot string, path string) {
t.Helper()
candidates := mapping.BySlot[slot]
for _, item := range candidates {
if item.FieldPath == path {
return
}
}
t.Fatalf("slot %q missing path %q", slot, path)
}

func assertSlotCount(t *testing.T, mapping SemanticSlotMapping, slot string, expected int) {
t.Helper()
if got := len(mapping.BySlot[slot]); got != expected {
t.Fatalf("slot %q has %d candidates, want %d", slot, got, expected)
}
}

+ 21
- 21
web/templates/build_new.gohtml Переглянути файл

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


+ 2
- 1
web/templates/settings.gohtml Переглянути файл

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


Завантаження…
Відмінити
Зберегти