package mapping import ( "context" "fmt" "sort" "strings" "time" "qctextbuilder/internal/domain" ) type SuggestionRequest struct { TemplateID int64 DraftID string Fields []domain.TemplateField GlobalData map[string]any DraftContext *domain.DraftContext MasterPrompt string PromptBlocks []domain.PromptBlockConfig Existing map[string]string IncludeFilled bool } type Suggestion struct { FieldPath string `json:"fieldPath"` Slot string `json:"slot,omitempty"` Value string `json:"value"` Reason string `json:"reason,omitempty"` Source string `json:"source,omitempty"` } type SuggestionResult struct { Suggestions []Suggestion `json:"suggestions"` ByFieldPath map[string]Suggestion `json:"byFieldPath"` } func SuggestFieldValues(req SuggestionRequest) SuggestionResult { return SuggestFieldValuesRuleBased(req) } func SuggestFieldValuesRuleBased(req SuggestionRequest) SuggestionResult { existing := req.Existing if existing == nil { existing = map[string]string{} } mappingResult := MapTemplateFieldsToSemanticSlots(req.Fields) ctx := suggestionContextFrom(req.GlobalData, req.DraftContext) out := SuggestionResult{ Suggestions: make([]Suggestion, 0), ByFieldPath: map[string]Suggestion{}, } seen := map[string]struct{}{} targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...) sort.SliceStable(targets, func(i, j int) bool { if targets[i].FieldPath == targets[j].FieldPath { return targets[i].Slot < targets[j].Slot } return targets[i].FieldPath < targets[j].FieldPath }) for _, target := range targets { if _, ok := seen[target.FieldPath]; ok { continue } if !req.IncludeFilled && strings.TrimSpace(existing[target.FieldPath]) != "" { continue } value, reason, ok := suggestValueForSlot(target.Slot, ctx) if !ok || strings.TrimSpace(value) == "" { continue } suggestion := Suggestion{ FieldPath: target.FieldPath, Slot: target.Slot, Value: value, Reason: reason, Source: domain.DraftSuggestionSourceFallbackRuleBased, } out.Suggestions = append(out.Suggestions, suggestion) out.ByFieldPath[target.FieldPath] = suggestion seen[target.FieldPath] = struct{}{} } return out } type suggestionContext struct { CompanyName string BusinessType string WebsiteSummary string LocaleStyle string MarketStyle string AddressMode string ContentTone string PromptNote string DescriptionShort string DescriptionLong string Mission string } func suggestionContextFrom(globalData map[string]any, draftContext *domain.DraftContext) suggestionContext { ctx := suggestionContext{ CompanyName: getMapString(globalData, "companyName"), BusinessType: getMapString(globalData, "businessType"), DescriptionShort: getMapString(globalData, "descriptionShort"), DescriptionLong: getMapString(globalData, "descriptionLong"), Mission: getMapString(globalData, "mission"), } if draftContext == nil { return ctx } if strings.TrimSpace(ctx.BusinessType) == "" { ctx.BusinessType = strings.TrimSpace(draftContext.LLM.BusinessType) } ctx.WebsiteSummary = strings.TrimSpace(draftContext.LLM.WebsiteSummary) ctx.LocaleStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.LocaleStyle) ctx.MarketStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.MarketStyle) ctx.AddressMode = strings.TrimSpace(draftContext.LLM.StyleProfile.AddressMode) ctx.ContentTone = strings.TrimSpace(draftContext.LLM.StyleProfile.ContentTone) ctx.PromptNote = strings.TrimSpace(draftContext.LLM.StyleProfile.PromptInstructions) return ctx } func suggestValueForSlot(slot string, ctx suggestionContext) (string, string, bool) { company := fallback(ctx.CompanyName, "Ihr Unternehmen") business := fallback(ctx.BusinessType, "Angebot") toneAdj := toneAdjective(ctx.ContentTone) audienceLine := audienceFlavor(ctx) switch { case slot == "hero.title": return strings.TrimSpace(fmt.Sprintf("%s fuer %s mit %s Klarheit", company, business, toneAdj)), "slot-based hero headline", true case slot == "intro.title": return strings.TrimSpace(fmt.Sprintf("Was %s fuer Sie einfacher macht", company)), "slot-based intro title", true case slot == "intro.description": return firstNonEmpty( shortenSentence(ctx.WebsiteSummary, 180), fmt.Sprintf("%s unterstuetzt Kunden mit %s Leistungen, klarer Kommunikation und einem %s Auftritt.", company, business, toneAdj), ), "slot-based intro description", true case slot == "about.description": return firstNonEmpty( shortenSentence(ctx.DescriptionLong, 260), shortenSentence(ctx.Mission, 220), fmt.Sprintf("%s steht fuer %s, verlaessliche Zusammenarbeit und einen %s Anspruch in Beratung und Umsetzung.", company, business, toneAdj), ), "slot-based about description", true case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].title"): idx := repeatedSlotIndex(slot) return fmt.Sprintf("%s Leistung %d", titleCaseBusiness(business), idx+1), "slot-based service title", true case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].description"): idx := repeatedSlotIndex(slot) return fmt.Sprintf("Praezise Umsetzung von %s mit Fokus auf Nutzen, Verstaendlichkeit und %s Wirkung%s.", business, toneAdj, audienceLineForIndex(audienceLine, idx)), "slot-based service description", true case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].name"): idx := repeatedSlotIndex(slot) return fmt.Sprintf("Ansprechperson %d", idx+1), "slot-based team placeholder", true case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].description"): idx := repeatedSlotIndex(slot) return fmt.Sprintf("Begleitet Projekte bei %s mit fachlicher Sicherheit, klarer Kommunikation und %s Auftreten.", business, toneAdjForIndex(toneAdj, idx)), "slot-based team description", true case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].name"): idx := repeatedSlotIndex(slot) return fmt.Sprintf("Kundin/Kunde %d", idx+1), "slot-based testimonial placeholder", true case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].title"): return firstNonEmpty( testimonialLead(ctx), "Vertrauen durch saubere Zusammenarbeit", ), "slot-based testimonial title", true case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].description"): return fmt.Sprintf("%s ueberzeugt mit %s Prozessen, klaren Ergebnissen und einer Zusammenarbeit, die angenehm effizient bleibt.", company, toneAdj), "slot-based testimonial description", true case slot == "cta.text": if ctx.AddressMode == "du" { return "Jetzt unverbindlich anfragen", "slot-based cta", true } return "Jetzt unverbindlich anfragen", "slot-based cta", true default: return "", "", false } } func getMapString(values map[string]any, key string) string { if values == nil { return "" } raw, _ := values[key].(string) return strings.TrimSpace(raw) } func fallback(value, alt string) string { if strings.TrimSpace(value) == "" { return alt } return strings.TrimSpace(value) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func shortenSentence(value string, max int) string { trimmed := strings.TrimSpace(value) if trimmed == "" || max <= 0 { return "" } if len([]rune(trimmed)) <= max { return trimmed } runes := []rune(trimmed) return strings.TrimSpace(string(runes[:max])) + "..." } func repeatedSlotIndex(slot string) int { start := strings.Index(slot, "[") end := strings.Index(slot, "]") if start < 0 || end <= start+1 { return 0 } value := strings.TrimSpace(slot[start+1 : end]) var idx int _, _ = fmt.Sscanf(value, "%d", &idx) if idx < 0 { return 0 } return idx } func toneAdjective(tone string) string { switch strings.ToLower(strings.TrimSpace(tone)) { case "locker": return "lockerer" case "modern": return "moderner" case "premium": return "hochwertiger" case "freundlich": return "freundlicher" case "professionell": return "professioneller" default: return "klarer" } } func toneAdjForIndex(adj string, idx int) string { if idx%2 == 0 { return adj } return adj } func audienceFlavor(ctx suggestionContext) string { parts := make([]string, 0, 2) if strings.TrimSpace(ctx.LocaleStyle) != "" { parts = append(parts, ctx.LocaleStyle) } if strings.TrimSpace(ctx.MarketStyle) != "" { parts = append(parts, ctx.MarketStyle) } return strings.Join(parts, " / ") } func audienceLineForIndex(value string, idx int) string { if strings.TrimSpace(value) == "" || idx != 0 { return "" } return " fuer " + value } func titleCaseBusiness(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "Service" } return strings.ToUpper(string([]rune(trimmed)[0])) + string([]rune(trimmed)[1:]) } func testimonialLead(ctx suggestionContext) string { if strings.TrimSpace(ctx.WebsiteSummary) == "" { return "" } return shortenSentence(ctx.WebsiteSummary, 80) } func GenerateAllSuggestions(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { next := cloneSuggestionState(current) if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ TemplateID: req.TemplateID, DraftID: req.DraftID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, MasterPrompt: req.MasterPrompt, PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) if err != nil { return next } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "post_generate_result", "action", "generate_all", "generated_count", len(generated.ByFieldPath), "generated_sources", summarizeResultSources(generated), "sample_sources", sampleResultSources(generated, 5), ) for _, s := range generated.Suggestions { sliceSource := strings.TrimSpace(s.Source) canonicalSource := "" if canonical, ok := generated.ByFieldPath[strings.TrimSpace(s.FieldPath)]; ok { canonicalSource = strings.TrimSpace(canonical.Source) s = canonical } if existing, exists := next.ByFieldPath[s.FieldPath]; exists { if !shouldReplaceExistingSuggestion(existing, s) { continue } } stored := toDraftSuggestion(s, now) if explicitSource := strings.TrimSpace(s.Source); explicitSource != "" { stored.Source = explicitSource } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "apply_field_transition", "action", "generate_all", "field_path", strings.TrimSpace(s.FieldPath), "slice_source", firstNonEmpty(sliceSource, "unknown"), "canonical_source", firstNonEmpty(canonicalSource, "unknown"), "stored_source", firstNonEmpty(strings.TrimSpace(stored.Source), "unknown"), ) next.ByFieldPath[s.FieldPath] = stored } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "post_generate_apply_state", "action", "generate_all", "state_count", len(next.ByFieldPath), "state_sources", summarizeDraftSuggestionSources(next), "sample_sources", sampleDraftSuggestionSources(next, 5), ) next.UpdatedAt = now.UTC() return next } func RegenerateAllSuggestions(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState { next := cloneSuggestionState(current) next.ByFieldPath = map[string]domain.DraftSuggestion{} generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ TemplateID: req.TemplateID, DraftID: req.DraftID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, MasterPrompt: req.MasterPrompt, PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) if err != nil { return next } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "post_generate_result", "action", "regenerate_all", "generated_count", len(generated.ByFieldPath), "generated_sources", summarizeResultSources(generated), "sample_sources", sampleResultSources(generated, 5), ) for _, s := range generated.Suggestions { sliceSource := strings.TrimSpace(s.Source) canonicalSource := "" if canonical, ok := generated.ByFieldPath[strings.TrimSpace(s.FieldPath)]; ok { canonicalSource = strings.TrimSpace(canonical.Source) s = canonical } stored := toDraftSuggestion(s, now) if explicitSource := strings.TrimSpace(s.Source); explicitSource != "" { stored.Source = explicitSource } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "apply_field_transition", "action", "regenerate_all", "field_path", strings.TrimSpace(s.FieldPath), "slice_source", firstNonEmpty(sliceSource, "unknown"), "canonical_source", firstNonEmpty(canonicalSource, "unknown"), "stored_source", firstNonEmpty(strings.TrimSpace(stored.Source), "unknown"), ) next.ByFieldPath[s.FieldPath] = stored } mappingLogger().InfoContext(ctx, "autofill state transition", "component", "autofill", "step", "post_generate_apply_state", "action", "regenerate_all", "state_count", len(next.ByFieldPath), "state_sources", summarizeDraftSuggestionSources(next), "sample_sources", sampleDraftSuggestionSources(next, 5), ) next.UpdatedAt = now.UTC() return next } func RegenerateFieldSuggestion(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState { target := strings.TrimSpace(fieldPath) if target == "" { return cloneSuggestionState(current) } next := cloneSuggestionState(current) if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } generated, err := suggestionResultWithFallback(ctx, generator, SuggestionRequest{ TemplateID: req.TemplateID, DraftID: req.DraftID, Fields: req.Fields, GlobalData: req.GlobalData, DraftContext: req.DraftContext, MasterPrompt: req.MasterPrompt, PromptBlocks: req.PromptBlocks, Existing: req.Existing, IncludeFilled: true, }) if err != nil { return next } if suggestion, ok := generated.ByFieldPath[target]; ok { next.ByFieldPath[target] = toDraftSuggestion(suggestion, now) next.UpdatedAt = now.UTC() } return next } func ApplySuggestionsToEmptyFields(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) { values := cloneFieldValues(fieldValues) next := cloneSuggestionState(state) if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } for path, suggestion := range next.ByFieldPath { if strings.TrimSpace(values[path]) != "" { continue } if strings.TrimSpace(suggestion.Value) == "" { continue } values[path] = strings.TrimSpace(suggestion.Value) suggestion.Status = domain.DraftSuggestionStatusApplied suggestion.UpdatedAt = now.UTC() next.ByFieldPath[path] = suggestion } next.UpdatedAt = now.UTC() return values, next } func ApplyAllSuggestions(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) { values := cloneFieldValues(fieldValues) next := cloneSuggestionState(state) if next.ByFieldPath == nil { next.ByFieldPath = map[string]domain.DraftSuggestion{} } for path, suggestion := range next.ByFieldPath { if strings.TrimSpace(suggestion.Value) == "" { continue } values[path] = strings.TrimSpace(suggestion.Value) suggestion.Status = domain.DraftSuggestionStatusApplied suggestion.UpdatedAt = now.UTC() next.ByFieldPath[path] = suggestion } next.UpdatedAt = now.UTC() return values, next } func ApplySuggestionToField(fieldValues map[string]string, state domain.DraftSuggestionState, fieldPath string, now time.Time) (map[string]string, domain.DraftSuggestionState) { target := strings.TrimSpace(fieldPath) values := cloneFieldValues(fieldValues) next := cloneSuggestionState(state) if target == "" || next.ByFieldPath == nil { return values, next } suggestion, ok := next.ByFieldPath[target] if !ok || strings.TrimSpace(suggestion.Value) == "" { return values, next } values[target] = strings.TrimSpace(suggestion.Value) suggestion.Status = domain.DraftSuggestionStatusApplied suggestion.UpdatedAt = now.UTC() next.ByFieldPath[target] = suggestion next.UpdatedAt = now.UTC() return values, next } func cloneFieldValues(values map[string]string) map[string]string { if values == nil { return map[string]string{} } out := make(map[string]string, len(values)) for k, v := range values { out[k] = v } return out } func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggestionState { out := domain.DraftSuggestionState{ ByFieldPath: map[string]domain.DraftSuggestion{}, UpdatedAt: state.UpdatedAt, } for path, suggestion := range state.ByFieldPath { out.ByFieldPath[path] = suggestion } return out } func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion { ts := now.UTC() source := strings.TrimSpace(s.Source) if source == "" { source = "unknown" } return domain.DraftSuggestion{ FieldPath: strings.TrimSpace(s.FieldPath), Slot: strings.TrimSpace(s.Slot), Value: strings.TrimSpace(s.Value), Reason: strings.TrimSpace(s.Reason), Source: source, Status: domain.DraftSuggestionStatusSuggested, GeneratedAt: ts, UpdatedAt: ts, } } func shouldReplaceExistingSuggestion(existing domain.DraftSuggestion, generated Suggestion) bool { existingSource := strings.TrimSpace(existing.Source) generatedSource := strings.TrimSpace(generated.Source) if generatedSource == "" { return false } if generatedSource == domain.DraftSuggestionSourceFallbackRuleBased { return false } return existingSource == domain.DraftSuggestionSourceFallbackRuleBased } func suggestionResultWithFallback(ctx context.Context, generator SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) { if generator == nil { return NewRuleBasedSuggestionGenerator().Generate(ctx, req) } return generator.Generate(ctx, req) } func summarizeResultSources(result SuggestionResult) map[string]int { if len(result.ByFieldPath) == 0 { return map[string]int{} } out := map[string]int{} for _, suggestion := range result.ByFieldPath { source := strings.TrimSpace(suggestion.Source) if source == "" { source = "unknown" } out[source]++ } return out } func summarizeDraftSuggestionSources(state domain.DraftSuggestionState) map[string]int { if len(state.ByFieldPath) == 0 { return map[string]int{} } out := map[string]int{} for _, suggestion := range state.ByFieldPath { source := strings.TrimSpace(suggestion.Source) if source == "" { source = "unknown" } out[source]++ } return out } func sampleResultSources(result SuggestionResult, limit int) map[string]string { if limit <= 0 || len(result.ByFieldPath) == 0 { return map[string]string{} } paths := make([]string, 0, len(result.ByFieldPath)) for path := range result.ByFieldPath { paths = append(paths, path) } sort.Strings(paths) if len(paths) > limit { paths = paths[:limit] } out := make(map[string]string, len(paths)) for _, path := range paths { source := strings.TrimSpace(result.ByFieldPath[path].Source) if source == "" { source = "unknown" } out[path] = source } return out } func sampleDraftSuggestionSources(state domain.DraftSuggestionState, limit int) map[string]string { if limit <= 0 || len(state.ByFieldPath) == 0 { return map[string]string{} } paths := make([]string, 0, len(state.ByFieldPath)) for path := range state.ByFieldPath { paths = append(paths, path) } sort.Strings(paths) if len(paths) > limit { paths = paths[:limit] } out := make(map[string]string, len(paths)) for _, path := range paths { source := strings.TrimSpace(state.ByFieldPath[path].Source) if source == "" { source = "unknown" } out[path] = source } return out }