package mapping import ( "context" "fmt" "sort" "strings" "qctextbuilder/internal/domain" "qctextbuilder/internal/qcclient" ) type SuggestionGenerator interface { Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) } type RuleBasedSuggestionGenerator struct{} func NewRuleBasedSuggestionGenerator() *RuleBasedSuggestionGenerator { return &RuleBasedSuggestionGenerator{} } func (g *RuleBasedSuggestionGenerator) Generate(_ context.Context, req SuggestionRequest) (SuggestionResult, error) { result := SuggestFieldValuesRuleBased(req) return result, nil } type LLMSuggestionGenerator struct { qc qcclient.Client } func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator { return &LLMSuggestionGenerator{qc: qc} } func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { if g == nil || g.qc == nil { return SuggestionResult{}, fmt.Errorf("llm generator is not configured") } if req.TemplateID <= 0 { return SuggestionResult{}, fmt.Errorf("template id is required for llm suggestions") } fieldByPath := make(map[string]domain.TemplateField, len(req.Fields)) for _, field := range req.Fields { if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") { continue } fieldByPath[field.Path] = field } targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled) customTemplateData := map[string]any{ "_autofill": map[string]any{ "masterPrompt": strings.TrimSpace(req.MasterPrompt), "promptBlocks": enabledPromptBlocks(req.PromptBlocks), "draftContext": llmDraftContextMap(req.DraftContext), "semanticSlots": func() map[string]string { out := make(map[string]string, len(targets)) for _, target := range targets { out[target.FieldPath] = target.Slot } return out }(), }, } resp, _, err := g.qc.GenerateContent(ctx, qcclient.GenerateContentRequest{ TemplateID: req.TemplateID, GlobalData: req.GlobalData, Empty: true, ToneOfVoice: contentTone(req.DraftContext), TargetAudience: targetAudience(req), CustomTemplateData: customTemplateData, }) if err != nil { return SuggestionResult{}, err } out := SuggestionResult{ Suggestions: make([]Suggestion, 0, len(targets)), ByFieldPath: map[string]Suggestion{}, } for _, target := range targets { field, ok := fieldByPath[target.FieldPath] if !ok { continue } sectionData := resp[field.Section] if sectionData == nil { continue } raw, ok := sectionData[field.KeyName] if !ok { continue } value := normalizeLLMValue(raw) if value == "" { continue } suggestion := Suggestion{ FieldPath: target.FieldPath, Slot: target.Slot, Value: value, Reason: "llm suggestion from template content generation", Source: domain.DraftSuggestionSourceLLM, } out.Suggestions = append(out.Suggestions, suggestion) out.ByFieldPath[target.FieldPath] = suggestion } return out, nil } type CompositeSuggestionGenerator struct { Primary SuggestionGenerator Fallback SuggestionGenerator } func NewCompositeSuggestionGenerator(primary, fallback SuggestionGenerator) *CompositeSuggestionGenerator { return &CompositeSuggestionGenerator{ Primary: primary, Fallback: fallback, } } func (g *CompositeSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) { if g == nil { return SuggestionResult{}, fmt.Errorf("suggestion generator is not configured") } if g.Primary == nil { return generateFallback(ctx, g.Fallback, req) } primaryResult, err := g.Primary.Generate(ctx, req) if err != nil { return generateFallback(ctx, g.Fallback, req) } primaryResult = normalizeSuggestionResult(primaryResult, req.Fields, req.Existing, req.IncludeFilled) if g.Fallback == nil { return primaryResult, nil } fallbackResult, fbErr := g.Fallback.Generate(ctx, req) if fbErr != nil { return primaryResult, nil } fallbackResult = normalizeSuggestionResult(fallbackResult, req.Fields, req.Existing, req.IncludeFilled) merged := primaryResult if merged.ByFieldPath == nil { merged.ByFieldPath = map[string]Suggestion{} } for _, suggestion := range fallbackResult.Suggestions { if _, exists := merged.ByFieldPath[suggestion.FieldPath]; exists { continue } merged.Suggestions = append(merged.Suggestions, suggestion) merged.ByFieldPath[suggestion.FieldPath] = suggestion } sort.SliceStable(merged.Suggestions, func(i, j int) bool { return merged.Suggestions[i].FieldPath < merged.Suggestions[j].FieldPath }) return merged, nil } func generateFallback(ctx context.Context, fallback SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) { if fallback == nil { return SuggestionResult{}, fmt.Errorf("fallback suggestion generator is not configured") } result, err := fallback.Generate(ctx, req) if err != nil { return SuggestionResult{}, err } return normalizeSuggestionResult(result, req.Fields, req.Existing, req.IncludeFilled), nil } func normalizeSuggestionResult(result SuggestionResult, fields []domain.TemplateField, existing map[string]string, includeFilled bool) SuggestionResult { allowed := make(map[string]SemanticSlotTarget) for _, target := range collectSuggestionTargets(fields, existing, includeFilled) { if _, exists := allowed[target.FieldPath]; exists { continue } allowed[target.FieldPath] = target } out := SuggestionResult{ Suggestions: make([]Suggestion, 0, len(result.Suggestions)), ByFieldPath: map[string]Suggestion{}, } for _, suggestion := range result.Suggestions { fieldPath := strings.TrimSpace(suggestion.FieldPath) if fieldPath == "" { continue } target, ok := allowed[fieldPath] if !ok { continue } value := strings.TrimSpace(suggestion.Value) if value == "" { continue } normalized := suggestion normalized.FieldPath = fieldPath if strings.TrimSpace(normalized.Slot) == "" { normalized.Slot = target.Slot } normalized.Value = value if strings.TrimSpace(normalized.Source) == "" { normalized.Source = domain.DraftSuggestionSourceFallbackRuleBased } if _, exists := out.ByFieldPath[fieldPath]; exists { continue } out.Suggestions = append(out.Suggestions, normalized) out.ByFieldPath[fieldPath] = normalized } sort.SliceStable(out.Suggestions, func(i, j int) bool { return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath }) return out } func collectSuggestionTargets(fields []domain.TemplateField, existing map[string]string, includeFilled bool) []SemanticSlotTarget { normalizedExisting := existing if normalizedExisting == nil { normalizedExisting = map[string]string{} } mappingResult := MapTemplateFieldsToSemanticSlots(fields) 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 }) out := make([]SemanticSlotTarget, 0, len(targets)) seen := map[string]struct{}{} for _, target := range targets { if _, exists := seen[target.FieldPath]; exists { continue } if !includeFilled && strings.TrimSpace(normalizedExisting[target.FieldPath]) != "" { continue } out = append(out, target) seen[target.FieldPath] = struct{}{} } return out } func enabledPromptBlocks(blocks []domain.PromptBlockConfig) []map[string]string { out := make([]map[string]string, 0, len(blocks)) for _, block := range blocks { if !block.Enabled { continue } entry := map[string]string{"id": strings.TrimSpace(block.ID)} if label := strings.TrimSpace(block.Label); label != "" { entry["label"] = label } if instruction := strings.TrimSpace(block.Instruction); instruction != "" { entry["instruction"] = instruction } out = append(out, entry) } return out } func llmDraftContextMap(ctx *domain.DraftContext) map[string]any { if ctx == nil { return map[string]any{} } return map[string]any{ "businessType": strings.TrimSpace(ctx.LLM.BusinessType), "websiteUrl": strings.TrimSpace(ctx.LLM.WebsiteURL), "websiteSummary": strings.TrimSpace(ctx.LLM.WebsiteSummary), "styleProfile": map[string]string{ "localeStyle": strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle), "marketStyle": strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle), "addressMode": strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode), "contentTone": strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone), "promptInstructions": strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions), }, } } func contentTone(ctx *domain.DraftContext) string { if ctx == nil { return "" } return strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone) } func targetAudience(req SuggestionRequest) string { ctx := suggestionContextFrom(req.GlobalData, req.DraftContext) parts := make([]string, 0, 4) if ctx.BusinessType != "" { parts = append(parts, "businessType="+ctx.BusinessType) } if ctx.LocaleStyle != "" { parts = append(parts, "locale="+ctx.LocaleStyle) } if ctx.MarketStyle != "" { parts = append(parts, "market="+ctx.MarketStyle) } if ctx.AddressMode != "" { parts = append(parts, "addressMode="+ctx.AddressMode) } return strings.Join(parts, ", ") } func normalizeLLMValue(raw any) string { switch value := raw.(type) { case string: return strings.TrimSpace(value) default: return "" } }