Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

521 строка
16KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/url"
  7. "sort"
  8. "strings"
  9. "time"
  10. "qctextbuilder/internal/domain"
  11. "qctextbuilder/internal/llmruntime"
  12. )
  13. type SettingsReader interface {
  14. GetSettings(ctx context.Context) (*domain.AppSettings, error)
  15. }
  16. type ProviderAwareSuggestionGenerator struct {
  17. settings SettingsReader
  18. runtimeFactory *llmruntime.Factory
  19. }
  20. func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator {
  21. return &ProviderAwareSuggestionGenerator{
  22. settings: settings,
  23. runtimeFactory: runtimeFactory,
  24. }
  25. }
  26. func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  27. started := time.Now()
  28. if g == nil || g.settings == nil || g.runtimeFactory == nil {
  29. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  30. "component", "autofill",
  31. "step", "provider_aware_config",
  32. "status", "failed",
  33. "template_id", req.TemplateID,
  34. "error", "provider-aware generator is not configured",
  35. )
  36. return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured")
  37. }
  38. settings, err := g.settings.GetSettings(ctx)
  39. if err != nil || settings == nil {
  40. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  41. "component", "autofill",
  42. "step", "provider_aware_settings",
  43. "status", "failed",
  44. "template_id", req.TemplateID,
  45. "error", "llm settings are not available",
  46. "duration_ms", time.Since(started).Milliseconds(),
  47. )
  48. return SuggestionResult{}, fmt.Errorf("llm settings are not available")
  49. }
  50. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  51. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  52. baseURL := strings.TrimSpace(settings.LLMBaseURL)
  53. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  54. "component", "autofill",
  55. "step", "provider_aware_request",
  56. "status", "start",
  57. "provider", provider,
  58. "model", model,
  59. "template_id", req.TemplateID,
  60. "draft_id", strings.TrimSpace(req.DraftID),
  61. )
  62. if strings.TrimSpace(model) == "" {
  63. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  64. "component", "autofill",
  65. "step", "provider_aware_request",
  66. "status", "failed",
  67. "provider", provider,
  68. "model", model,
  69. "template_id", req.TemplateID,
  70. "draft_id", strings.TrimSpace(req.DraftID),
  71. "error", "no active model configured",
  72. "duration_ms", time.Since(started).Milliseconds(),
  73. )
  74. return SuggestionResult{}, fmt.Errorf("no active model configured")
  75. }
  76. apiKey := domain.LLMAPIKeyForProvider(provider, *settings)
  77. if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
  78. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  79. "component", "autofill",
  80. "step", "provider_aware_request",
  81. "status", "failed",
  82. "provider", provider,
  83. "model", model,
  84. "template_id", req.TemplateID,
  85. "draft_id", strings.TrimSpace(req.DraftID),
  86. "error", "missing api key",
  87. "duration_ms", time.Since(started).Milliseconds(),
  88. )
  89. return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured in settings", provider)
  90. }
  91. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  92. if len(targets) == 0 {
  93. return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil
  94. }
  95. allowed := make(map[string]SemanticSlotTarget, len(targets))
  96. for _, target := range targets {
  97. allowed[target.FieldPath] = target
  98. }
  99. providerClient, err := g.runtimeFactory.ClientFor(provider)
  100. if err != nil {
  101. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  102. "component", "autofill",
  103. "step", "provider_aware_request",
  104. "status", "failed",
  105. "provider", provider,
  106. "model", model,
  107. "template_id", req.TemplateID,
  108. "draft_id", strings.TrimSpace(req.DraftID),
  109. "error", shortErr(err),
  110. "duration_ms", time.Since(started).Milliseconds(),
  111. )
  112. return SuggestionResult{}, err
  113. }
  114. systemPrompt, userPrompt := buildProviderPrompts(req, targets)
  115. temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature)
  116. maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  117. mappingLogger().DebugContext(ctx, "provider-aware suggestion",
  118. "component", "autofill",
  119. "step", "provider_aware_request_payload",
  120. "status", "start",
  121. "provider", provider,
  122. "model", model,
  123. "template_id", req.TemplateID,
  124. "draft_id", strings.TrimSpace(req.DraftID),
  125. "base_url", llmruntimeSafeBaseURL(baseURL),
  126. "system_prompt_chars", len(systemPrompt),
  127. "system_prompt_snippet", providerLogSnippet(systemPrompt, 1500),
  128. "user_prompt_chars", len(userPrompt),
  129. "user_prompt_snippet", providerLogSnippet(userPrompt, 2000),
  130. )
  131. raw, err := providerClient.Generate(ctx, llmruntime.Request{
  132. Provider: provider,
  133. Model: model,
  134. BaseURL: baseURL,
  135. APIKey: strings.TrimSpace(apiKey),
  136. Temperature: &temperature,
  137. MaxTokens: &maxTokens,
  138. SystemPrompt: systemPrompt,
  139. UserPrompt: userPrompt,
  140. })
  141. if err != nil {
  142. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  143. "component", "autofill",
  144. "step", "provider_aware_request",
  145. "status", "failed",
  146. "provider", provider,
  147. "model", model,
  148. "template_id", req.TemplateID,
  149. "draft_id", strings.TrimSpace(req.DraftID),
  150. "error", shortErr(err),
  151. "duration_ms", time.Since(started).Milliseconds(),
  152. )
  153. return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err)
  154. }
  155. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  156. "component", "autofill",
  157. "step", "provider_aware_request",
  158. "status", "success",
  159. "provider", provider,
  160. "model", model,
  161. "template_id", req.TemplateID,
  162. "draft_id", strings.TrimSpace(req.DraftID),
  163. "response_chars", len(strings.TrimSpace(raw)),
  164. )
  165. mappingLogger().DebugContext(ctx, "provider-aware suggestion",
  166. "component", "autofill",
  167. "step", "provider_aware_request",
  168. "status", "success",
  169. "provider", provider,
  170. "model", model,
  171. "template_id", req.TemplateID,
  172. "draft_id", strings.TrimSpace(req.DraftID),
  173. "response_snippet", providerLogSnippet(raw, 4000),
  174. )
  175. mappingLogger().DebugContext(ctx, "provider-aware suggestion",
  176. "component", "autofill",
  177. "step", "provider_parse_input",
  178. "status", "start",
  179. "provider", provider,
  180. "model", model,
  181. "template_id", req.TemplateID,
  182. "draft_id", strings.TrimSpace(req.DraftID),
  183. "extracted_content_chars", len(strings.TrimSpace(raw)),
  184. "extracted_content_snippet", providerLogSnippet(raw, 4000),
  185. )
  186. parsed, err := parseProviderSuggestions(raw)
  187. if err != nil {
  188. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  189. "component", "autofill",
  190. "step", "provider_parse",
  191. "status", "failed",
  192. "provider", provider,
  193. "model", model,
  194. "template_id", req.TemplateID,
  195. "draft_id", strings.TrimSpace(req.DraftID),
  196. "error", shortErr(err),
  197. "duration_ms", time.Since(started).Milliseconds(),
  198. )
  199. return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err)
  200. }
  201. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  202. "component", "autofill",
  203. "step", "provider_parse",
  204. "status", "success",
  205. "provider", provider,
  206. "model", model,
  207. "template_id", req.TemplateID,
  208. "draft_id", strings.TrimSpace(req.DraftID),
  209. "parsed_count", len(parsed),
  210. )
  211. mappingLogger().DebugContext(ctx, "provider-aware suggestion",
  212. "component", "autofill",
  213. "step", "provider_parse",
  214. "status", "success",
  215. "provider", provider,
  216. "model", model,
  217. "template_id", req.TemplateID,
  218. "draft_id", strings.TrimSpace(req.DraftID),
  219. "parsed_sample", providerSuggestionSample(parsed, 5),
  220. )
  221. out := SuggestionResult{
  222. Suggestions: make([]Suggestion, 0, len(parsed)),
  223. ByFieldPath: map[string]Suggestion{},
  224. }
  225. for _, item := range parsed {
  226. fieldPath := strings.TrimSpace(item.FieldPath)
  227. target, ok := allowed[fieldPath]
  228. if !ok {
  229. continue
  230. }
  231. value := strings.TrimSpace(item.Value)
  232. if value == "" {
  233. continue
  234. }
  235. suggestion := Suggestion{
  236. FieldPath: fieldPath,
  237. Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot),
  238. Value: value,
  239. Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"),
  240. Source: provider,
  241. }
  242. if _, exists := out.ByFieldPath[fieldPath]; exists {
  243. continue
  244. }
  245. out.Suggestions = append(out.Suggestions, suggestion)
  246. out.ByFieldPath[fieldPath] = suggestion
  247. }
  248. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  249. "component", "autofill",
  250. "step", "provider_aware_result",
  251. "status", "success",
  252. "provider", provider,
  253. "model", model,
  254. "template_id", req.TemplateID,
  255. "draft_id", strings.TrimSpace(req.DraftID),
  256. "suggestion_count", len(out.Suggestions),
  257. "sources", summarizeResultSources(out),
  258. "duration_ms", time.Since(started).Milliseconds(),
  259. )
  260. mappingLogger().DebugContext(ctx, "provider-aware suggestion",
  261. "component", "autofill",
  262. "step", "provider_aware_result",
  263. "status", "success",
  264. "provider", provider,
  265. "model", model,
  266. "template_id", req.TemplateID,
  267. "draft_id", strings.TrimSpace(req.DraftID),
  268. "suggestion_sample_sources", sampleResultSources(out, 5),
  269. "suggestion_sample", suggestionLogSample(out, 5),
  270. )
  271. return out, nil
  272. }
  273. type providerSuggestion struct {
  274. FieldPath string `json:"fieldPath"`
  275. Slot string `json:"slot,omitempty"`
  276. Value string `json:"value"`
  277. Reason string `json:"reason,omitempty"`
  278. }
  279. func parseProviderSuggestions(raw string) ([]providerSuggestion, error) {
  280. content := strings.TrimSpace(raw)
  281. if content == "" {
  282. return nil, fmt.Errorf("empty provider response")
  283. }
  284. candidates := []string{content}
  285. if fence := extractFencedJSON(content); fence != "" {
  286. candidates = append([]string{fence}, candidates...)
  287. }
  288. if object := extractJSONObject(content); object != "" {
  289. candidates = append(candidates, object)
  290. }
  291. var firstErr error
  292. for _, candidate := range candidates {
  293. items, err := parseSuggestionsCandidate(candidate)
  294. if err == nil {
  295. return items, nil
  296. }
  297. if firstErr == nil {
  298. firstErr = err
  299. }
  300. }
  301. if firstErr != nil {
  302. return nil, firstErr
  303. }
  304. return nil, fmt.Errorf("provider response is not valid suggestions json")
  305. }
  306. func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) {
  307. var root any
  308. if err := json.Unmarshal([]byte(raw), &root); err != nil {
  309. return nil, fmt.Errorf("provider response is not valid json: %w", err)
  310. }
  311. var itemsRaw []any
  312. switch value := root.(type) {
  313. case map[string]any:
  314. suggestions, ok := value["suggestions"]
  315. if !ok {
  316. return nil, fmt.Errorf("provider json object must contain \"suggestions\" array")
  317. }
  318. list, ok := suggestions.([]any)
  319. if !ok {
  320. return nil, fmt.Errorf("provider \"suggestions\" must be an array")
  321. }
  322. itemsRaw = list
  323. case []any:
  324. itemsRaw = value
  325. default:
  326. return nil, fmt.Errorf("provider json payload must be an object or array")
  327. }
  328. if len(itemsRaw) == 0 {
  329. return nil, fmt.Errorf("provider returned an empty suggestions array")
  330. }
  331. out := make([]providerSuggestion, 0, len(itemsRaw))
  332. for idx, rawItem := range itemsRaw {
  333. itemMap, ok := rawItem.(map[string]any)
  334. if !ok {
  335. return nil, fmt.Errorf("suggestion #%d is not an object", idx+1)
  336. }
  337. fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"]))
  338. if fieldPath == "" {
  339. return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1)
  340. }
  341. value := strings.TrimSpace(anyToString(itemMap["value"]))
  342. if value == "" {
  343. return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath)
  344. }
  345. out = append(out, providerSuggestion{
  346. FieldPath: fieldPath,
  347. Slot: strings.TrimSpace(anyToString(itemMap["slot"])),
  348. Value: value,
  349. Reason: strings.TrimSpace(anyToString(itemMap["reason"])),
  350. })
  351. }
  352. return out, nil
  353. }
  354. func extractFencedJSON(value string) string {
  355. const fence = "```"
  356. start := strings.Index(value, fence)
  357. for start >= 0 {
  358. rest := value[start+len(fence):]
  359. end := strings.Index(rest, fence)
  360. if end < 0 {
  361. return ""
  362. }
  363. block := strings.TrimSpace(rest[:end])
  364. block = strings.TrimPrefix(block, "json")
  365. block = strings.TrimPrefix(block, "JSON")
  366. block = strings.TrimSpace(block)
  367. if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") {
  368. return block
  369. }
  370. nextOffset := start + len(fence) + end + len(fence)
  371. nextStart := strings.Index(value[nextOffset:], fence)
  372. if nextStart < 0 {
  373. break
  374. }
  375. start = nextOffset + nextStart
  376. }
  377. return ""
  378. }
  379. func extractJSONObject(value string) string {
  380. start := strings.IndexAny(value, "{[")
  381. if start < 0 {
  382. return ""
  383. }
  384. end := strings.LastIndexAny(value, "}]")
  385. if end <= start {
  386. return ""
  387. }
  388. return strings.TrimSpace(value[start : end+1])
  389. }
  390. func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) {
  391. targetPayload := make([]map[string]string, 0, len(targets))
  392. for _, target := range targets {
  393. targetPayload = append(targetPayload, map[string]string{
  394. "fieldPath": strings.TrimSpace(target.FieldPath),
  395. "slot": strings.TrimSpace(target.Slot),
  396. })
  397. }
  398. contextPayload := map[string]any{
  399. "globalData": req.GlobalData,
  400. "draftContext": llmDraftContextMap(req.DraftContext),
  401. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  402. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  403. "targets": targetPayload,
  404. }
  405. contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
  406. system := "You generate website text suggestions. Return JSON only. Format: {\"suggestions\":[{\"fieldPath\":\"...\",\"slot\":\"...\",\"value\":\"...\",\"reason\":\"...\"}]}. Use only provided field paths. Keep values concise and in input language."
  407. user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON)
  408. return system, user
  409. }
  410. func anyToString(raw any) string {
  411. switch value := raw.(type) {
  412. case string:
  413. return value
  414. case float64:
  415. return fmt.Sprintf("%.0f", value)
  416. case bool:
  417. if value {
  418. return "true"
  419. }
  420. return "false"
  421. case nil:
  422. return ""
  423. default:
  424. return fmt.Sprintf("%v", value)
  425. }
  426. }
  427. func providerLogSnippet(value string, limit int) string {
  428. trimmed := strings.TrimSpace(value)
  429. if trimmed == "" || limit <= 0 {
  430. return ""
  431. }
  432. runes := []rune(trimmed)
  433. if len(runes) <= limit {
  434. return trimmed
  435. }
  436. return strings.TrimSpace(string(runes[:limit])) + "...(truncated)"
  437. }
  438. func providerSuggestionSample(items []providerSuggestion, limit int) []map[string]string {
  439. if len(items) == 0 || limit <= 0 {
  440. return []map[string]string{}
  441. }
  442. if len(items) > limit {
  443. items = items[:limit]
  444. }
  445. out := make([]map[string]string, 0, len(items))
  446. for _, item := range items {
  447. out = append(out, map[string]string{
  448. "fieldPath": strings.TrimSpace(item.FieldPath),
  449. "slot": strings.TrimSpace(item.Slot),
  450. "value": providerLogSnippet(item.Value, 200),
  451. "reason": providerLogSnippet(item.Reason, 120),
  452. })
  453. }
  454. return out
  455. }
  456. func suggestionLogSample(result SuggestionResult, limit int) []map[string]string {
  457. if limit <= 0 || len(result.ByFieldPath) == 0 {
  458. return []map[string]string{}
  459. }
  460. paths := make([]string, 0, len(result.ByFieldPath))
  461. for path := range result.ByFieldPath {
  462. paths = append(paths, path)
  463. }
  464. sort.Strings(paths)
  465. if len(paths) > limit {
  466. paths = paths[:limit]
  467. }
  468. out := make([]map[string]string, 0, len(paths))
  469. for _, path := range paths {
  470. item := result.ByFieldPath[path]
  471. out = append(out, map[string]string{
  472. "fieldPath": strings.TrimSpace(item.FieldPath),
  473. "source": strings.TrimSpace(item.Source),
  474. "slot": strings.TrimSpace(item.Slot),
  475. "value": providerLogSnippet(item.Value, 200),
  476. })
  477. }
  478. return out
  479. }
  480. func llmruntimeSafeBaseURL(value string) string {
  481. trimmed := strings.TrimSpace(value)
  482. if trimmed == "" {
  483. return ""
  484. }
  485. parsed, err := url.Parse(trimmed)
  486. if err != nil || parsed.Scheme == "" || parsed.Host == "" {
  487. return trimmed
  488. }
  489. parsed.User = nil
  490. parsed.RawQuery = ""
  491. parsed.Fragment = ""
  492. return strings.TrimRight(parsed.String(), "/")
  493. }