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