您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

520 行
16KB

  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. fallbackResult, fbErr := g.Fallback.Generate(ctx, req)
  238. if fbErr != nil {
  239. mappingLogger().WarnContext(ctx, "autofill fallback",
  240. "component", "autofill",
  241. "step", "qc_fallback",
  242. "status", "failed",
  243. "fallback_generator", generatorLabel(g.Fallback),
  244. "draft_id", strings.TrimSpace(req.DraftID),
  245. "template_id", req.TemplateID,
  246. "error", shortErr(fbErr),
  247. )
  248. mappingLogger().InfoContext(ctx, "autofill result",
  249. "component", "autofill",
  250. "step", "final",
  251. "status", "success",
  252. "source_path", "primary_only_fallback_failed",
  253. "suggestion_count", len(primaryResult.Suggestions),
  254. "draft_id", strings.TrimSpace(req.DraftID),
  255. "template_id", req.TemplateID,
  256. "duration_ms", time.Since(started).Milliseconds(),
  257. )
  258. return primaryResult, nil
  259. }
  260. mappingLogger().InfoContext(ctx, "autofill fallback",
  261. "component", "autofill",
  262. "step", "qc_fallback",
  263. "status", "success",
  264. "fallback_generator", generatorLabel(g.Fallback),
  265. "draft_id", strings.TrimSpace(req.DraftID),
  266. "template_id", req.TemplateID,
  267. "suggestion_count", len(fallbackResult.Suggestions),
  268. )
  269. fallbackResult = normalizeSuggestionResult(fallbackResult, req.Fields, req.Existing, req.IncludeFilled)
  270. merged := primaryResult
  271. if merged.ByFieldPath == nil {
  272. merged.ByFieldPath = map[string]Suggestion{}
  273. }
  274. for _, suggestion := range fallbackResult.Suggestions {
  275. if _, exists := merged.ByFieldPath[suggestion.FieldPath]; exists {
  276. continue
  277. }
  278. merged.Suggestions = append(merged.Suggestions, suggestion)
  279. merged.ByFieldPath[suggestion.FieldPath] = suggestion
  280. }
  281. sort.SliceStable(merged.Suggestions, func(i, j int) bool {
  282. return merged.Suggestions[i].FieldPath < merged.Suggestions[j].FieldPath
  283. })
  284. sourcePath := "primary_plus_fallback"
  285. if len(primaryResult.Suggestions) == 0 && len(fallbackResult.Suggestions) > 0 {
  286. sourcePath = "fallback_only"
  287. }
  288. ruleBasedCount := 0
  289. for _, suggestion := range merged.Suggestions {
  290. if strings.EqualFold(strings.TrimSpace(suggestion.Source), domain.DraftSuggestionSourceFallbackRuleBased) {
  291. ruleBasedCount++
  292. }
  293. }
  294. if ruleBasedCount > 0 {
  295. mappingLogger().InfoContext(ctx, "autofill fallback",
  296. "component", "autofill",
  297. "step", "rule_based_fallback",
  298. "status", "used",
  299. "draft_id", strings.TrimSpace(req.DraftID),
  300. "template_id", req.TemplateID,
  301. "suggestion_count", ruleBasedCount,
  302. )
  303. }
  304. mappingLogger().InfoContext(ctx, "autofill result",
  305. "component", "autofill",
  306. "step", "final",
  307. "status", "success",
  308. "source_path", sourcePath,
  309. "suggestion_count", len(merged.Suggestions),
  310. "draft_id", strings.TrimSpace(req.DraftID),
  311. "template_id", req.TemplateID,
  312. "duration_ms", time.Since(started).Milliseconds(),
  313. )
  314. return merged, nil
  315. }
  316. func generateFallback(ctx context.Context, fallback SuggestionGenerator, req SuggestionRequest) (SuggestionResult, error) {
  317. if fallback == nil {
  318. return SuggestionResult{}, fmt.Errorf("fallback suggestion generator is not configured")
  319. }
  320. started := time.Now()
  321. label := generatorLabel(fallback)
  322. mappingLogger().InfoContext(ctx, "autofill fallback",
  323. "component", "autofill",
  324. "step", "fallback_attempt",
  325. "status", "attempted",
  326. "fallback_generator", label,
  327. "draft_id", strings.TrimSpace(req.DraftID),
  328. "template_id", req.TemplateID,
  329. )
  330. result, err := fallback.Generate(ctx, req)
  331. if err != nil {
  332. mappingLogger().WarnContext(ctx, "autofill fallback",
  333. "component", "autofill",
  334. "step", "fallback_attempt",
  335. "status", "failed",
  336. "fallback_generator", label,
  337. "draft_id", strings.TrimSpace(req.DraftID),
  338. "template_id", req.TemplateID,
  339. "error", shortErr(err),
  340. "duration_ms", time.Since(started).Milliseconds(),
  341. )
  342. return SuggestionResult{}, err
  343. }
  344. mappingLogger().InfoContext(ctx, "autofill fallback",
  345. "component", "autofill",
  346. "step", "fallback_attempt",
  347. "status", "success",
  348. "fallback_generator", label,
  349. "draft_id", strings.TrimSpace(req.DraftID),
  350. "template_id", req.TemplateID,
  351. "suggestion_count", len(result.Suggestions),
  352. "duration_ms", time.Since(started).Milliseconds(),
  353. )
  354. return normalizeSuggestionResult(result, req.Fields, req.Existing, req.IncludeFilled), nil
  355. }
  356. func normalizeSuggestionResult(result SuggestionResult, fields []domain.TemplateField, existing map[string]string, includeFilled bool) SuggestionResult {
  357. allowed := make(map[string]SemanticSlotTarget)
  358. for _, target := range collectSuggestionTargets(fields, existing, includeFilled) {
  359. if _, exists := allowed[target.FieldPath]; exists {
  360. continue
  361. }
  362. allowed[target.FieldPath] = target
  363. }
  364. out := SuggestionResult{
  365. Suggestions: make([]Suggestion, 0, len(result.Suggestions)),
  366. ByFieldPath: map[string]Suggestion{},
  367. }
  368. for _, suggestion := range result.Suggestions {
  369. fieldPath := strings.TrimSpace(suggestion.FieldPath)
  370. if fieldPath == "" {
  371. continue
  372. }
  373. target, ok := allowed[fieldPath]
  374. if !ok {
  375. continue
  376. }
  377. value := strings.TrimSpace(suggestion.Value)
  378. if value == "" {
  379. continue
  380. }
  381. normalized := suggestion
  382. normalized.FieldPath = fieldPath
  383. if strings.TrimSpace(normalized.Slot) == "" {
  384. normalized.Slot = target.Slot
  385. }
  386. normalized.Value = value
  387. if strings.TrimSpace(normalized.Source) == "" {
  388. normalized.Source = domain.DraftSuggestionSourceFallbackRuleBased
  389. }
  390. if _, exists := out.ByFieldPath[fieldPath]; exists {
  391. continue
  392. }
  393. out.Suggestions = append(out.Suggestions, normalized)
  394. out.ByFieldPath[fieldPath] = normalized
  395. }
  396. sort.SliceStable(out.Suggestions, func(i, j int) bool {
  397. return out.Suggestions[i].FieldPath < out.Suggestions[j].FieldPath
  398. })
  399. return out
  400. }
  401. func collectSuggestionTargets(fields []domain.TemplateField, existing map[string]string, includeFilled bool) []SemanticSlotTarget {
  402. normalizedExisting := existing
  403. if normalizedExisting == nil {
  404. normalizedExisting = map[string]string{}
  405. }
  406. mappingResult := MapTemplateFieldsToSemanticSlots(fields)
  407. targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...)
  408. sort.SliceStable(targets, func(i, j int) bool {
  409. if targets[i].FieldPath == targets[j].FieldPath {
  410. return targets[i].Slot < targets[j].Slot
  411. }
  412. return targets[i].FieldPath < targets[j].FieldPath
  413. })
  414. out := make([]SemanticSlotTarget, 0, len(targets))
  415. seen := map[string]struct{}{}
  416. for _, target := range targets {
  417. if _, exists := seen[target.FieldPath]; exists {
  418. continue
  419. }
  420. if !includeFilled && strings.TrimSpace(normalizedExisting[target.FieldPath]) != "" {
  421. continue
  422. }
  423. out = append(out, target)
  424. seen[target.FieldPath] = struct{}{}
  425. }
  426. return out
  427. }
  428. func enabledPromptBlocks(blocks []domain.PromptBlockConfig) []map[string]string {
  429. out := make([]map[string]string, 0, len(blocks))
  430. for _, block := range blocks {
  431. if !block.Enabled {
  432. continue
  433. }
  434. entry := map[string]string{"id": strings.TrimSpace(block.ID)}
  435. if label := strings.TrimSpace(block.Label); label != "" {
  436. entry["label"] = label
  437. }
  438. if instruction := strings.TrimSpace(block.Instruction); instruction != "" {
  439. entry["instruction"] = instruction
  440. }
  441. out = append(out, entry)
  442. }
  443. return out
  444. }
  445. func llmDraftContextMap(ctx *domain.DraftContext) map[string]any {
  446. if ctx == nil {
  447. return map[string]any{}
  448. }
  449. return map[string]any{
  450. "businessType": strings.TrimSpace(ctx.LLM.BusinessType),
  451. "websiteUrl": strings.TrimSpace(ctx.LLM.WebsiteURL),
  452. "websiteSummary": strings.TrimSpace(ctx.LLM.WebsiteSummary),
  453. "styleProfile": map[string]string{
  454. "localeStyle": strings.TrimSpace(ctx.LLM.StyleProfile.LocaleStyle),
  455. "marketStyle": strings.TrimSpace(ctx.LLM.StyleProfile.MarketStyle),
  456. "addressMode": strings.TrimSpace(ctx.LLM.StyleProfile.AddressMode),
  457. "contentTone": strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone),
  458. "promptInstructions": strings.TrimSpace(ctx.LLM.StyleProfile.PromptInstructions),
  459. },
  460. }
  461. }
  462. func contentTone(ctx *domain.DraftContext) string {
  463. if ctx == nil {
  464. return ""
  465. }
  466. return strings.TrimSpace(ctx.LLM.StyleProfile.ContentTone)
  467. }
  468. func targetAudience(req SuggestionRequest) string {
  469. ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)
  470. parts := make([]string, 0, 4)
  471. if ctx.BusinessType != "" {
  472. parts = append(parts, "businessType="+ctx.BusinessType)
  473. }
  474. if ctx.LocaleStyle != "" {
  475. parts = append(parts, "locale="+ctx.LocaleStyle)
  476. }
  477. if ctx.MarketStyle != "" {
  478. parts = append(parts, "market="+ctx.MarketStyle)
  479. }
  480. if ctx.AddressMode != "" {
  481. parts = append(parts, "addressMode="+ctx.AddressMode)
  482. }
  483. return strings.Join(parts, ", ")
  484. }
  485. func normalizeLLMValue(raw any) string {
  486. switch value := raw.(type) {
  487. case string:
  488. return strings.TrimSpace(value)
  489. default:
  490. return ""
  491. }
  492. }