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