Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

588 linhas
18KB

  1. package mapping
  2. import (
  3. "context"
  4. "fmt"
  5. "sort"
  6. "strings"
  7. "time"
  8. "qctextbuilder/internal/domain"
  9. "qctextbuilder/internal/qcclient"
  10. )
  11. type SuggestionGenerator interface {
  12. Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error)
  13. }
  14. type RuleBasedSuggestionGenerator struct{}
  15. func NewRuleBasedSuggestionGenerator() *RuleBasedSuggestionGenerator {
  16. return &RuleBasedSuggestionGenerator{}
  17. }
  18. func (g *RuleBasedSuggestionGenerator) Generate(_ context.Context, req SuggestionRequest) (SuggestionResult, error) {
  19. result := SuggestFieldValuesRuleBased(req)
  20. mappingLogger().Info("rule-based suggestion used",
  21. "component", "autofill",
  22. "step", "rule_based_fallback",
  23. "status", "success",
  24. "draft_id", strings.TrimSpace(req.DraftID),
  25. "template_id", req.TemplateID,
  26. "suggestion_count", len(result.Suggestions),
  27. )
  28. return result, nil
  29. }
  30. type LLMSuggestionGenerator struct {
  31. qc qcclient.Client
  32. source string
  33. }
  34. func NewLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator {
  35. return &LLMSuggestionGenerator{
  36. qc: qc,
  37. source: domain.DraftSuggestionSourceLLM,
  38. }
  39. }
  40. func NewQCLLMSuggestionGenerator(qc qcclient.Client) *LLMSuggestionGenerator {
  41. return &LLMSuggestionGenerator{
  42. qc: qc,
  43. source: domain.DraftSuggestionSourceQCLLM,
  44. }
  45. }
  46. func (g *LLMSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  47. started := time.Now()
  48. source := ""
  49. if g != nil {
  50. source = strings.TrimSpace(g.source)
  51. }
  52. step := "qc_fallback_request"
  53. if source == domain.DraftSuggestionSourceLLM {
  54. step = "llm_request"
  55. }
  56. mappingLogger().InfoContext(ctx, "llm suggestion request",
  57. "component", "autofill",
  58. "step", step,
  59. "status", "start",
  60. "source", source,
  61. "draft_id", strings.TrimSpace(req.DraftID),
  62. "template_id", req.TemplateID,
  63. )
  64. if g == nil || g.qc == nil {
  65. mappingLogger().WarnContext(ctx, "llm suggestion request",
  66. "component", "autofill",
  67. "step", step,
  68. "status", "failed",
  69. "source", source,
  70. "draft_id", strings.TrimSpace(req.DraftID),
  71. "template_id", req.TemplateID,
  72. "error", "llm generator is not configured",
  73. "duration_ms", time.Since(started).Milliseconds(),
  74. )
  75. return SuggestionResult{}, fmt.Errorf("llm generator is not configured")
  76. }
  77. if req.TemplateID <= 0 {
  78. mappingLogger().WarnContext(ctx, "llm suggestion request",
  79. "component", "autofill",
  80. "step", step,
  81. "status", "failed",
  82. "source", source,
  83. "draft_id", strings.TrimSpace(req.DraftID),
  84. "template_id", req.TemplateID,
  85. "error", "template id is required for llm suggestions",
  86. "duration_ms", time.Since(started).Milliseconds(),
  87. )
  88. return SuggestionResult{}, fmt.Errorf("template id is required for llm suggestions")
  89. }
  90. fieldByPath := make(map[string]domain.TemplateField, len(req.Fields))
  91. for _, field := range req.Fields {
  92. if !field.IsEnabled || !strings.EqualFold(strings.TrimSpace(field.FieldKind), "text") {
  93. continue
  94. }
  95. fieldByPath[field.Path] = field
  96. }
  97. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  98. customTemplateData := map[string]any{
  99. "_autofill": map[string]any{
  100. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  101. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  102. "draftContext": llmDraftContextMap(req.DraftContext),
  103. "semanticSlots": func() map[string]string {
  104. out := make(map[string]string, len(targets))
  105. for _, target := range targets {
  106. out[target.FieldPath] = target.Slot
  107. }
  108. return out
  109. }(),
  110. },
  111. }
  112. resp, _, err := g.qc.GenerateContent(ctx, qcclient.GenerateContentRequest{
  113. TemplateID: req.TemplateID,
  114. GlobalData: req.GlobalData,
  115. Empty: true,
  116. ToneOfVoice: contentTone(req.DraftContext),
  117. TargetAudience: targetAudience(req),
  118. CustomTemplateData: customTemplateData,
  119. })
  120. if err != nil {
  121. mappingLogger().WarnContext(ctx, "llm suggestion request",
  122. "component", "autofill",
  123. "step", step,
  124. "status", "failed",
  125. "source", source,
  126. "draft_id", strings.TrimSpace(req.DraftID),
  127. "template_id", req.TemplateID,
  128. "error", shortErr(err),
  129. "duration_ms", time.Since(started).Milliseconds(),
  130. )
  131. return SuggestionResult{}, err
  132. }
  133. out := SuggestionResult{
  134. Suggestions: make([]Suggestion, 0, len(targets)),
  135. ByFieldPath: map[string]Suggestion{},
  136. }
  137. for _, target := range targets {
  138. field, ok := fieldByPath[target.FieldPath]
  139. if !ok {
  140. continue
  141. }
  142. sectionData := resp[field.Section]
  143. if sectionData == nil {
  144. continue
  145. }
  146. raw, ok := sectionData[field.KeyName]
  147. if !ok {
  148. continue
  149. }
  150. value := normalizeLLMValue(raw)
  151. if value == "" {
  152. continue
  153. }
  154. suggestion := Suggestion{
  155. FieldPath: target.FieldPath,
  156. Slot: target.Slot,
  157. Value: value,
  158. Reason: "llm suggestion from template content generation",
  159. Source: firstNonEmpty(strings.TrimSpace(g.source), domain.DraftSuggestionSourceLLM),
  160. }
  161. out.Suggestions = append(out.Suggestions, suggestion)
  162. out.ByFieldPath[target.FieldPath] = suggestion
  163. }
  164. mappingLogger().InfoContext(ctx, "llm suggestion request",
  165. "component", "autofill",
  166. "step", step,
  167. "status", "success",
  168. "source", source,
  169. "draft_id", strings.TrimSpace(req.DraftID),
  170. "template_id", req.TemplateID,
  171. "suggestion_count", len(out.Suggestions),
  172. "duration_ms", time.Since(started).Milliseconds(),
  173. )
  174. return out, nil
  175. }
  176. type CompositeSuggestionGenerator struct {
  177. Primary SuggestionGenerator
  178. Fallback SuggestionGenerator
  179. }
  180. func NewCompositeSuggestionGenerator(primary, fallback SuggestionGenerator) *CompositeSuggestionGenerator {
  181. return &CompositeSuggestionGenerator{
  182. Primary: primary,
  183. Fallback: fallback,
  184. }
  185. }
  186. func (g *CompositeSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  187. started := time.Now()
  188. if g == nil {
  189. return SuggestionResult{}, fmt.Errorf("suggestion generator is not configured")
  190. }
  191. if g.Primary == nil {
  192. mappingLogger().InfoContext(ctx, "autofill fallback",
  193. "component", "autofill",
  194. "step", "fallback_attempt",
  195. "status", "attempted",
  196. "fallback_generator", generatorLabel(g.Fallback),
  197. "draft_id", strings.TrimSpace(req.DraftID),
  198. "template_id", req.TemplateID,
  199. )
  200. return generateFallback(ctx, g.Fallback, req)
  201. }
  202. primaryResult, err := g.Primary.Generate(ctx, req)
  203. if err != nil {
  204. mappingLogger().WarnContext(ctx, "autofill fallback",
  205. "component", "autofill",
  206. "step", "primary_failed",
  207. "status", "failed",
  208. "primary_generator", generatorLabel(g.Primary),
  209. "draft_id", strings.TrimSpace(req.DraftID),
  210. "template_id", req.TemplateID,
  211. "error", shortErr(err),
  212. )
  213. mappingLogger().InfoContext(ctx, "autofill fallback",
  214. "component", "autofill",
  215. "step", "qc_fallback",
  216. "status", "attempted",
  217. "fallback_generator", generatorLabel(g.Fallback),
  218. "draft_id", strings.TrimSpace(req.DraftID),
  219. "template_id", req.TemplateID,
  220. )
  221. return generateFallback(ctx, g.Fallback, req)
  222. }
  223. primaryResult = normalizeSuggestionResult(primaryResult, req.Fields, req.Existing, req.IncludeFilled)
  224. if g.Fallback == nil {
  225. mappingLogger().InfoContext(ctx, "autofill result",
  226. "component", "autofill",
  227. "step", "final",
  228. "status", "success",
  229. "source_path", "primary_only",
  230. "suggestion_count", len(primaryResult.Suggestions),
  231. "draft_id", strings.TrimSpace(req.DraftID),
  232. "template_id", req.TemplateID,
  233. "duration_ms", time.Since(started).Milliseconds(),
  234. )
  235. return primaryResult, nil
  236. }
  237. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  238. missingFieldPaths := missingSuggestionFieldPaths(targets, primaryResult.ByFieldPath)
  239. if len(missingFieldPaths) == 0 {
  240. mappingLogger().InfoContext(ctx, "autofill result",
  241. "component", "autofill",
  242. "step", "final",
  243. "status", "success",
  244. "source_path", "primary_only",
  245. "suggestion_count", len(primaryResult.Suggestions),
  246. "draft_id", strings.TrimSpace(req.DraftID),
  247. "template_id", req.TemplateID,
  248. "duration_ms", time.Since(started).Milliseconds(),
  249. )
  250. return primaryResult, nil
  251. }
  252. fallbackReq := narrowedSuggestionRequest(req, missingFieldPaths)
  253. fallbackResult, fbErr := g.Fallback.Generate(ctx, fallbackReq)
  254. if fbErr != nil {
  255. mappingLogger().WarnContext(ctx, "autofill fallback",
  256. "component", "autofill",
  257. "step", "qc_fallback",
  258. "status", "failed",
  259. "fallback_generator", generatorLabel(g.Fallback),
  260. "draft_id", strings.TrimSpace(req.DraftID),
  261. "template_id", req.TemplateID,
  262. "missing_target_count", len(missingFieldPaths),
  263. "error", shortErr(fbErr),
  264. )
  265. mappingLogger().InfoContext(ctx, "autofill result",
  266. "component", "autofill",
  267. "step", "final",
  268. "status", "success",
  269. "source_path", "primary_only_fallback_failed",
  270. "suggestion_count", len(primaryResult.Suggestions),
  271. "draft_id", strings.TrimSpace(req.DraftID),
  272. "template_id", req.TemplateID,
  273. "duration_ms", time.Since(started).Milliseconds(),
  274. )
  275. return primaryResult, nil
  276. }
  277. mappingLogger().InfoContext(ctx, "autofill fallback",
  278. "component", "autofill",
  279. "step", "qc_fallback",
  280. "status", "success",
  281. "fallback_generator", generatorLabel(g.Fallback),
  282. "draft_id", strings.TrimSpace(req.DraftID),
  283. "template_id", req.TemplateID,
  284. "missing_target_count", len(missingFieldPaths),
  285. "suggestion_count", len(fallbackResult.Suggestions),
  286. )
  287. fallbackResult = normalizeSuggestionResult(fallbackResult, fallbackReq.Fields, fallbackReq.Existing, fallbackReq.IncludeFilled)
  288. merged := primaryResult
  289. if merged.ByFieldPath == nil {
  290. merged.ByFieldPath = map[string]Suggestion{}
  291. }
  292. for _, suggestion := range fallbackResult.Suggestions {
  293. if _, exists := merged.ByFieldPath[suggestion.FieldPath]; exists {
  294. continue
  295. }
  296. merged.Suggestions = append(merged.Suggestions, suggestion)
  297. merged.ByFieldPath[suggestion.FieldPath] = suggestion
  298. }
  299. sort.SliceStable(merged.Suggestions, func(i, j int) bool {
  300. return merged.Suggestions[i].FieldPath < merged.Suggestions[j].FieldPath
  301. })
  302. sourcePath := "primary_plus_fallback"
  303. if len(primaryResult.Suggestions) == 0 && len(fallbackResult.Suggestions) > 0 {
  304. sourcePath = "fallback_only"
  305. }
  306. ruleBasedCount := 0
  307. for _, suggestion := range merged.Suggestions {
  308. if strings.EqualFold(strings.TrimSpace(suggestion.Source), domain.DraftSuggestionSourceFallbackRuleBased) {
  309. ruleBasedCount++
  310. }
  311. }
  312. if ruleBasedCount > 0 {
  313. mappingLogger().InfoContext(ctx, "autofill fallback",
  314. "component", "autofill",
  315. "step", "rule_based_fallback",
  316. "status", "used",
  317. "draft_id", strings.TrimSpace(req.DraftID),
  318. "template_id", req.TemplateID,
  319. "suggestion_count", ruleBasedCount,
  320. )
  321. }
  322. mappingLogger().InfoContext(ctx, "autofill result",
  323. "component", "autofill",
  324. "step", "final",
  325. "status", "success",
  326. "source_path", sourcePath,
  327. "suggestion_count", len(merged.Suggestions),
  328. "draft_id", strings.TrimSpace(req.DraftID),
  329. "template_id", req.TemplateID,
  330. "duration_ms", time.Since(started).Milliseconds(),
  331. )
  332. return merged, nil
  333. }
  334. func missingSuggestionFieldPaths(targets []SemanticSlotTarget, byFieldPath map[string]Suggestion) []string {
  335. if byFieldPath == nil {
  336. byFieldPath = map[string]Suggestion{}
  337. }
  338. missing := make([]string, 0, len(targets))
  339. seen := map[string]struct{}{}
  340. for _, target := range targets {
  341. path := strings.TrimSpace(target.FieldPath)
  342. if path == "" {
  343. continue
  344. }
  345. if _, ok := seen[path]; ok {
  346. continue
  347. }
  348. seen[path] = struct{}{}
  349. if _, ok := byFieldPath[path]; ok {
  350. continue
  351. }
  352. missing = append(missing, path)
  353. }
  354. return missing
  355. }
  356. func narrowedSuggestionRequest(req SuggestionRequest, targetFieldPaths []string) SuggestionRequest {
  357. allowed := map[string]struct{}{}
  358. for _, path := range targetFieldPaths {
  359. trimmed := strings.TrimSpace(path)
  360. if trimmed == "" {
  361. continue
  362. }
  363. allowed[trimmed] = struct{}{}
  364. }
  365. filteredFields := make([]domain.TemplateField, 0, len(req.Fields))
  366. for _, field := range req.Fields {
  367. if _, ok := allowed[strings.TrimSpace(field.Path)]; !ok {
  368. continue
  369. }
  370. filteredFields = append(filteredFields, field)
  371. }
  372. filteredExisting := make(map[string]string, len(req.Existing))
  373. for path, value := range req.Existing {
  374. if _, ok := allowed[strings.TrimSpace(path)]; !ok {
  375. continue
  376. }
  377. filteredExisting[path] = value
  378. }
  379. req.Fields = filteredFields
  380. req.Existing = filteredExisting
  381. return req
  382. }
  383. func generateFallback(ctx context.Context, fallback SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) {
  384. if fallback == nil {
  385. return SuggestionResult{}, fmt.Errorf("fallback suggestion generator is not configured")
  386. }
  387. started := time.Now()
  388. label := generatorLabel(fallback)
  389. mappingLogger().InfoContext(ctx, "autofill fallback",
  390. "component", "autofill",
  391. "step", "fallback_attempt",
  392. "status", "attempted",
  393. "fallback_generator", label,
  394. "draft_id", strings.TrimSpace(req.DraftID),
  395. "template_id", req.TemplateID,
  396. )
  397. result, err := fallback.Generate(ctx, req)
  398. if err != nil {
  399. mappingLogger().WarnContext(ctx, "autofill fallback",
  400. "component", "autofill",
  401. "step", "fallback_attempt",
  402. "status", "failed",
  403. "fallback_generator", label,
  404. "draft_id", strings.TrimSpace(req.DraftID),
  405. "template_id", req.TemplateID,
  406. "error", shortErr(err),
  407. "duration_ms", time.Since(started).Milliseconds(),
  408. )
  409. return SuggestionResult{}, err
  410. }
  411. mappingLogger().InfoContext(ctx, "autofill fallback",
  412. "component", "autofill",
  413. "step", "fallback_attempt",
  414. "status", "success",
  415. "fallback_generator", label,
  416. "draft_id", strings.TrimSpace(req.DraftID),
  417. "template_id", req.TemplateID,
  418. "suggestion_count", len(result.Suggestions),
  419. "duration_ms", time.Since(started).Milliseconds(),
  420. )
  421. return normalizeSuggestionResult(result, req.Fields, req.Existing, req.IncludeFilled), nil
  422. }
  423. func normalizeSuggestionResult(result SuggestionResult, fields []domain.TemplateField, existing map[string]string, includeFilled bool) SuggestionResult {
  424. allowed := make(map[string]SemanticSlotTarget)
  425. for _, target := range collectSuggestionTargets(fields, existing, includeFilled) {
  426. if _, exists := allowed[target.FieldPath]; exists {
  427. continue
  428. }
  429. allowed[target.FieldPath] = target
  430. }
  431. out := SuggestionResult{
  432. Suggestions: make([]Suggestion, 0, len(result.Suggestions)),
  433. ByFieldPath: map[string]Suggestion{},
  434. }
  435. for _, suggestion := range result.Suggestions {
  436. fieldPath := strings.TrimSpace(suggestion.FieldPath)
  437. if fieldPath == "" {
  438. continue
  439. }
  440. target, ok := allowed[fieldPath]
  441. if !ok {
  442. continue
  443. }
  444. value := strings.TrimSpace(suggestion.Value)
  445. if value == "" {
  446. continue
  447. }
  448. normalized := suggestion
  449. normalized.FieldPath = fieldPath
  450. if strings.TrimSpace(normalized.Slot) == "" {
  451. normalized.Slot = target.Slot
  452. }
  453. normalized.Value = value
  454. normalized.Source = strings.TrimSpace(normalized.Source)
  455. if _, exists := out.ByFieldPath[fieldPath]; exists {
  456. continue
  457. }
  458. out.Suggestions = append(out.Suggestions, normalized)
  459. out.ByFieldPath[fieldPath] = normalized
  460. }
  461. sort.SliceStable(out.Suggestions, func(i, j int) bool {
  462. return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath
  463. })
  464. return out
  465. }
  466. func collectSuggestionTargets(fields []domain.TemplateField, existing map[string]string, includeFilled bool) []SemanticSlotTarget {
  467. normalizedExisting := existing
  468. if normalizedExisting == nil {
  469. normalizedExisting = map[string]string{}
  470. }
  471. mappingResult := MapTemplateFieldsToSemanticSlots(fields)
  472. targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...)
  473. sort.SliceStable(targets, func(i, j int) bool {
  474. if targets[i].FieldPath == targets[j].FieldPath {
  475. return targets[i].Slot < targets[j].Slot
  476. }
  477. return targets[i].FieldPath < targets[j].FieldPath
  478. })
  479. out := make([]SemanticSlotTarget, 0, len(targets))
  480. seen := map[string]struct{}{}
  481. for _, target := range targets {
  482. if _, exists := seen[target.FieldPath]; exists {
  483. continue
  484. }
  485. if !includeFilled && strings.TrimSpace(normalizedExisting[target.FieldPath]) != "" {
  486. continue
  487. }
  488. out = append(out, target)
  489. seen[target.FieldPath] = struct{}{}
  490. }
  491. return out
  492. }
  493. func enabledPromptBlocks(blocks []domain.PromptBlockConfig) []map[string]string {
  494. out := make([]map[string]string, 0, len(blocks))
  495. for _, block := range blocks {
  496. if !block.Enabled {
  497. continue
  498. }
  499. entry := map[string]string{"id": strings.TrimSpace(block.ID)}
  500. if label := strings.TrimSpace(block.Label); label != "" {
  501. entry["label"] = label
  502. }
  503. if instruction := strings.TrimSpace(block.Instruction); instruction != "" {
  504. entry["instruction"] = instruction
  505. }
  506. out = append(out, entry)
  507. }
  508. return out
  509. }
  510. func llmDraftContextMap(ctx *domain.DraftContext) map[string]any {
  511. if ctx == nil {
  512. return map[string]any{}
  513. }
  514. return map[string]any{
  515. "businessType": strings.TrimSpace(ctx.LLM.BusinessType),
  516. "websiteUrl": strings.TrimSpace(ctx.LLM.WebsiteURL),
  517. "websiteSummary": strings.TrimSpace(ctx.LLM.WebsiteSummary),
  518. "styleProfile": map[string]string{
  519. "localeStyle": strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle),
  520. "marketStyle": strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle),
  521. "addressMode": strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode),
  522. "contentTone": strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone),
  523. "promptInstructions": strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions),
  524. },
  525. }
  526. }
  527. func contentTone(ctx *domain.DraftContext) string {
  528. if ctx == nil {
  529. return ""
  530. }
  531. return strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  532. }
  533. func targetAudience(req SuggestionRequest) string {
  534. ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)
  535. parts := make([]string, 0, 4)
  536. if ctx.BusinessType != "" {
  537. parts = append(parts, "businessType="+ctx.BusinessType)
  538. }
  539. if ctx.LocaleStyle != "" {
  540. parts = append(parts, "locale="+ctx.LocaleStyle)
  541. }
  542. if ctx.MarketStyle != "" {
  543. parts = append(parts, "market="+ctx.MarketStyle)
  544. }
  545. if ctx.AddressMode != "" {
  546. parts = append(parts, "addressMode="+ctx.AddressMode)
  547. }
  548. return strings.Join(parts, ", ")
  549. }
  550. func normalizeLLMValue(raw any) string {
  551. switch value := raw.(type) {
  552. case string:
  553. return strings.TrimSpace(value)
  554. default:
  555. return ""
  556. }
  557. }