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