Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

330 lignes
9.7KB

  1. package mapping
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. "qctextbuilder/internal/domain"
  8. "qctextbuilder/internal/qcclient"
  9. )
  10. type SuggestionGenerator interface {
  11. Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error)
  12. }
  13. type RuleBasedSuggestionGenerator struct{}
  14. func NewRuleBasedSuggestionGenerator() *RuleBasedSuggestionGenerator {
  15. return &RuleBasedSuggestionGenerator{}
  16. }
  17. func (g *RuleBasedSuggestionGenerator) Generate(_ context.Context, req SuggestionRequest) (SuggestionResult, error) {
  18. result := SuggestFieldValuesRuleBased(req)
  19. return result, nil
  20. }
  21. type LLMSuggestionGenerator struct {
  22. qc qcclient.Client
  23. source string
  24. }
  25. func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator {
  26. return &LLMSuggestionGenerator{
  27. qc: qc,
  28. source: domain.DraftSuggestionSourceLLM,
  29. }
  30. }
  31. func NewQCLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator {
  32. return &LLMSuggestionGenerator{
  33. qc: qc,
  34. source: domain.DraftSuggestionSourceQCLLM,
  35. }
  36. }
  37. func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  38. if g == nil || g.qc == nil {
  39. return SuggestionResult{}, fmt.Errorf("llm generator is not configured")
  40. }
  41. if req.TemplateID <= 0 {
  42. return SuggestionResult{}, fmt.Errorf("template id is required for llm suggestions")
  43. }
  44. fieldByPath := make(map[string]domain.TemplateField, len(req.Fields))
  45. for _, field := range req.Fields {
  46. if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") {
  47. continue
  48. }
  49. fieldByPath[field.Path] = field
  50. }
  51. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  52. customTemplateData := map[string]any{
  53. "_autofill": map[string]any{
  54. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  55. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  56. "draftContext": llmDraftContextMap(req.DraftContext),
  57. "semanticSlots": func() map[string]string {
  58. out := make(map[string]string, len(targets))
  59. for _, target := range targets {
  60. out[target.FieldPath] = target.Slot
  61. }
  62. return out
  63. }(),
  64. },
  65. }
  66. resp, _, err := g.qc.GenerateContent(ctx, qcclient.GenerateContentRequest{
  67. TemplateID: req.TemplateID,
  68. GlobalData: req.GlobalData,
  69. Empty: true,
  70. ToneOfVoice: contentTone(req.DraftContext),
  71. TargetAudience: targetAudience(req),
  72. CustomTemplateData: customTemplateData,
  73. })
  74. if err != nil {
  75. return SuggestionResult{}, err
  76. }
  77. out := SuggestionResult{
  78. Suggestions: make([]Suggestion, 0, len(targets)),
  79. ByFieldPath: map[string]Suggestion{},
  80. }
  81. for _, target := range targets {
  82. field, ok := fieldByPath[target.FieldPath]
  83. if !ok {
  84. continue
  85. }
  86. sectionData := resp[field.Section]
  87. if sectionData == nil {
  88. continue
  89. }
  90. raw, ok := sectionData[field.KeyName]
  91. if !ok {
  92. continue
  93. }
  94. value := normalizeLLMValue(raw)
  95. if value == "" {
  96. continue
  97. }
  98. suggestion := Suggestion{
  99. FieldPath: target.FieldPath,
  100. Slot: target.Slot,
  101. Value: value,
  102. Reason: "llm suggestion from template content generation",
  103. Source: firstNonEmpty(strings.TrimSpace(g.source), domain.DraftSuggestionSourceLLM),
  104. }
  105. out.Suggestions = append(out.Suggestions, suggestion)
  106. out.ByFieldPath[target.FieldPath] = suggestion
  107. }
  108. return out, nil
  109. }
  110. type CompositeSuggestionGenerator struct {
  111. Primary SuggestionGenerator
  112. Fallback SuggestionGenerator
  113. }
  114. func NewCompositeSuggestionGenerator(primary, fallback SuggestionGenerator) *CompositeSuggestionGenerator {
  115. return &CompositeSuggestionGenerator{
  116. Primary: primary,
  117. Fallback: fallback,
  118. }
  119. }
  120. func (g *CompositeSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  121. if g == nil {
  122. return SuggestionResult{}, fmt.Errorf("suggestion generator is not configured")
  123. }
  124. if g.Primary == nil {
  125. return generateFallback(ctx, g.Fallback, req)
  126. }
  127. primaryResult, err := g.Primary.Generate(ctx, req)
  128. if err != nil {
  129. return generateFallback(ctx, g.Fallback, req)
  130. }
  131. primaryResult = normalizeSuggestionResult(primaryResult, req.Fields, req.Existing, req.IncludeFilled)
  132. if g.Fallback == nil {
  133. return primaryResult, nil
  134. }
  135. fallbackResult, fbErr := g.Fallback.Generate(ctx, req)
  136. if fbErr != nil {
  137. return primaryResult, nil
  138. }
  139. fallbackResult = normalizeSuggestionResult(fallbackResult, req.Fields, req.Existing, req.IncludeFilled)
  140. merged := primaryResult
  141. if merged.ByFieldPath == nil {
  142. merged.ByFieldPath = map[string]Suggestion{}
  143. }
  144. for _, suggestion := range fallbackResult.Suggestions {
  145. if _, exists := merged.ByFieldPath[suggestion.FieldPath]; exists {
  146. continue
  147. }
  148. merged.Suggestions = append(merged.Suggestions, suggestion)
  149. merged.ByFieldPath[suggestion.FieldPath] = suggestion
  150. }
  151. sort.SliceStable(merged.Suggestions, func(i, j int) bool {
  152. return merged.Suggestions[i].FieldPath < merged.Suggestions[j].FieldPath
  153. })
  154. return merged, nil
  155. }
  156. func generateFallback(ctx context.Context, fallback SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) {
  157. if fallback == nil {
  158. return SuggestionResult{}, fmt.Errorf("fallback suggestion generator is not configured")
  159. }
  160. result, err := fallback.Generate(ctx, req)
  161. if err != nil {
  162. return SuggestionResult{}, err
  163. }
  164. return normalizeSuggestionResult(result, req.Fields, req.Existing, req.IncludeFilled), nil
  165. }
  166. func normalizeSuggestionResult(result SuggestionResult, fields []domain.TemplateField, existing map[string]string, includeFilled bool) SuggestionResult {
  167. allowed := make(map[string]SemanticSlotTarget)
  168. for _, target := range collectSuggestionTargets(fields, existing, includeFilled) {
  169. if _, exists := allowed[target.FieldPath]; exists {
  170. continue
  171. }
  172. allowed[target.FieldPath] = target
  173. }
  174. out := SuggestionResult{
  175. Suggestions: make([]Suggestion, 0, len(result.Suggestions)),
  176. ByFieldPath: map[string]Suggestion{},
  177. }
  178. for _, suggestion := range result.Suggestions {
  179. fieldPath := strings.TrimSpace(suggestion.FieldPath)
  180. if fieldPath == "" {
  181. continue
  182. }
  183. target, ok := allowed[fieldPath]
  184. if !ok {
  185. continue
  186. }
  187. value := strings.TrimSpace(suggestion.Value)
  188. if value == "" {
  189. continue
  190. }
  191. normalized := suggestion
  192. normalized.FieldPath = fieldPath
  193. if strings.TrimSpace(normalized.Slot) == "" {
  194. normalized.Slot = target.Slot
  195. }
  196. normalized.Value = value
  197. if strings.TrimSpace(normalized.Source) == "" {
  198. normalized.Source = domain.DraftSuggestionSourceFallbackRuleBased
  199. }
  200. if _, exists := out.ByFieldPath[fieldPath]; exists {
  201. continue
  202. }
  203. out.Suggestions = append(out.Suggestions, normalized)
  204. out.ByFieldPath[fieldPath] = normalized
  205. }
  206. sort.SliceStable(out.Suggestions, func(i, j int) bool {
  207. return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath
  208. })
  209. return out
  210. }
  211. func collectSuggestionTargets(fields []domain.TemplateField, existing map[string]string, includeFilled bool) []SemanticSlotTarget {
  212. normalizedExisting := existing
  213. if normalizedExisting == nil {
  214. normalizedExisting = map[string]string{}
  215. }
  216. mappingResult := MapTemplateFieldsToSemanticSlots(fields)
  217. targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...)
  218. sort.SliceStable(targets, func(i, j int) bool {
  219. if targets[i].FieldPath == targets[j].FieldPath {
  220. return targets[i].Slot < targets[j].Slot
  221. }
  222. return targets[i].FieldPath < targets[j].FieldPath
  223. })
  224. out := make([]SemanticSlotTarget, 0, len(targets))
  225. seen := map[string]struct{}{}
  226. for _, target := range targets {
  227. if _, exists := seen[target.FieldPath]; exists {
  228. continue
  229. }
  230. if !includeFilled && strings.TrimSpace(normalizedExisting[target.FieldPath]) != "" {
  231. continue
  232. }
  233. out = append(out, target)
  234. seen[target.FieldPath] = struct{}{}
  235. }
  236. return out
  237. }
  238. func enabledPromptBlocks(blocks []domain.PromptBlockConfig) []map[string]string {
  239. out := make([]map[string]string, 0, len(blocks))
  240. for _, block := range blocks {
  241. if !block.Enabled {
  242. continue
  243. }
  244. entry := map[string]string{"id": strings.TrimSpace(block.ID)}
  245. if label := strings.TrimSpace(block.Label); label != "" {
  246. entry["label"] = label
  247. }
  248. if instruction := strings.TrimSpace(block.Instruction); instruction != "" {
  249. entry["instruction"] = instruction
  250. }
  251. out = append(out, entry)
  252. }
  253. return out
  254. }
  255. func llmDraftContextMap(ctx *domain.DraftContext) map[string]any {
  256. if ctx == nil {
  257. return map[string]any{}
  258. }
  259. return map[string]any{
  260. "businessType": strings.TrimSpace(ctx.LLM.BusinessType),
  261. "websiteUrl": strings.TrimSpace(ctx.LLM.WebsiteURL),
  262. "websiteSummary": strings.TrimSpace(ctx.LLM.WebsiteSummary),
  263. "styleProfile": map[string]string{
  264. "localeStyle": strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle),
  265. "marketStyle": strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle),
  266. "addressMode": strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode),
  267. "contentTone": strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone),
  268. "promptInstructions": strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions),
  269. },
  270. }
  271. }
  272. func contentTone(ctx *domain.DraftContext) string {
  273. if ctx == nil {
  274. return ""
  275. }
  276. return strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  277. }
  278. func targetAudience(req SuggestionRequest) string {
  279. ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)
  280. parts := make([]string, 0, 4)
  281. if ctx.BusinessType != "" {
  282. parts = append(parts, "businessType="+ctx.BusinessType)
  283. }
  284. if ctx.LocaleStyle != "" {
  285. parts = append(parts, "locale="+ctx.LocaleStyle)
  286. }
  287. if ctx.MarketStyle != "" {
  288. parts = append(parts, "market="+ctx.MarketStyle)
  289. }
  290. if ctx.AddressMode != "" {
  291. parts = append(parts, "addressMode="+ctx.AddressMode)
  292. }
  293. return strings.Join(parts, ", ")
  294. }
  295. func normalizeLLMValue(raw any) string {
  296. switch value := raw.(type) {
  297. case string:
  298. return strings.TrimSpace(value)
  299. default:
  300. return ""
  301. }
  302. }