Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

493 řádky
15KB

  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().InfoContext(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. "response_snippet", providerLogSnippet(raw, 4000),
  165. )
  166. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  167. "component", "autofill",
  168. "step", "provider_parse_input",
  169. "status", "start",
  170. "provider", provider,
  171. "model", model,
  172. "template_id", req.TemplateID,
  173. "draft_id", strings.TrimSpace(req.DraftID),
  174. "extracted_content_chars", len(strings.TrimSpace(raw)),
  175. "extracted_content_snippet", providerLogSnippet(raw, 4000),
  176. )
  177. parsed, err := parseProviderSuggestions(raw)
  178. if err != nil {
  179. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  180. "component", "autofill",
  181. "step", "provider_parse",
  182. "status", "failed",
  183. "provider", provider,
  184. "model", model,
  185. "template_id", req.TemplateID,
  186. "draft_id", strings.TrimSpace(req.DraftID),
  187. "error", shortErr(err),
  188. "duration_ms", time.Since(started).Milliseconds(),
  189. )
  190. return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err)
  191. }
  192. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  193. "component", "autofill",
  194. "step", "provider_parse",
  195. "status", "success",
  196. "provider", provider,
  197. "model", model,
  198. "template_id", req.TemplateID,
  199. "draft_id", strings.TrimSpace(req.DraftID),
  200. "parsed_count", len(parsed),
  201. "parsed_sample", providerSuggestionSample(parsed, 5),
  202. )
  203. out := SuggestionResult{
  204. Suggestions: make([]Suggestion, 0, len(parsed)),
  205. ByFieldPath: map[string]Suggestion{},
  206. }
  207. for _, item := range parsed {
  208. fieldPath := strings.TrimSpace(item.FieldPath)
  209. target, ok := allowed[fieldPath]
  210. if !ok {
  211. continue
  212. }
  213. value := strings.TrimSpace(item.Value)
  214. if value == "" {
  215. continue
  216. }
  217. suggestion := Suggestion{
  218. FieldPath: fieldPath,
  219. Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot),
  220. Value: value,
  221. Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"),
  222. Source: provider,
  223. }
  224. if _, exists := out.ByFieldPath[fieldPath]; exists {
  225. continue
  226. }
  227. out.Suggestions = append(out.Suggestions, suggestion)
  228. out.ByFieldPath[fieldPath] = suggestion
  229. }
  230. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  231. "component", "autofill",
  232. "step", "provider_aware_result",
  233. "status", "success",
  234. "provider", provider,
  235. "model", model,
  236. "template_id", req.TemplateID,
  237. "draft_id", strings.TrimSpace(req.DraftID),
  238. "suggestion_count", len(out.Suggestions),
  239. "suggestion_sample_sources", sampleResultSources(out, 5),
  240. "suggestion_sample", suggestionLogSample(out, 5),
  241. "duration_ms", time.Since(started).Milliseconds(),
  242. )
  243. return out, nil
  244. }
  245. type providerSuggestion struct {
  246. FieldPath string `json:"fieldPath"`
  247. Slot string `json:"slot,omitempty"`
  248. Value string `json:"value"`
  249. Reason string `json:"reason,omitempty"`
  250. }
  251. func parseProviderSuggestions(raw string) ([]providerSuggestion, error) {
  252. content := strings.TrimSpace(raw)
  253. if content == "" {
  254. return nil, fmt.Errorf("empty provider response")
  255. }
  256. candidates := []string{content}
  257. if fence := extractFencedJSON(content); fence != "" {
  258. candidates = append([]string{fence}, candidates...)
  259. }
  260. if object := extractJSONObject(content); object != "" {
  261. candidates = append(candidates, object)
  262. }
  263. var firstErr error
  264. for _, candidate := range candidates {
  265. items, err := parseSuggestionsCandidate(candidate)
  266. if err == nil {
  267. return items, nil
  268. }
  269. if firstErr == nil {
  270. firstErr = err
  271. }
  272. }
  273. if firstErr != nil {
  274. return nil, firstErr
  275. }
  276. return nil, fmt.Errorf("provider response is not valid suggestions json")
  277. }
  278. func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) {
  279. var root any
  280. if err := json.Unmarshal([]byte(raw), &root); err != nil {
  281. return nil, fmt.Errorf("provider response is not valid json: %w", err)
  282. }
  283. var itemsRaw []any
  284. switch value := root.(type) {
  285. case map[string]any:
  286. suggestions, ok := value["suggestions"]
  287. if !ok {
  288. return nil, fmt.Errorf("provider json object must contain \"suggestions\" array")
  289. }
  290. list, ok := suggestions.([]any)
  291. if !ok {
  292. return nil, fmt.Errorf("provider \"suggestions\" must be an array")
  293. }
  294. itemsRaw = list
  295. case []any:
  296. itemsRaw = value
  297. default:
  298. return nil, fmt.Errorf("provider json payload must be an object or array")
  299. }
  300. if len(itemsRaw) == 0 {
  301. return nil, fmt.Errorf("provider returned an empty suggestions array")
  302. }
  303. out := make([]providerSuggestion, 0, len(itemsRaw))
  304. for idx, rawItem := range itemsRaw {
  305. itemMap, ok := rawItem.(map[string]any)
  306. if !ok {
  307. return nil, fmt.Errorf("suggestion #%d is not an object", idx+1)
  308. }
  309. fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"]))
  310. if fieldPath == "" {
  311. return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1)
  312. }
  313. value := strings.TrimSpace(anyToString(itemMap["value"]))
  314. if value == "" {
  315. return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath)
  316. }
  317. out = append(out, providerSuggestion{
  318. FieldPath: fieldPath,
  319. Slot: strings.TrimSpace(anyToString(itemMap["slot"])),
  320. Value: value,
  321. Reason: strings.TrimSpace(anyToString(itemMap["reason"])),
  322. })
  323. }
  324. return out, nil
  325. }
  326. func extractFencedJSON(value string) string {
  327. const fence = "```"
  328. start := strings.Index(value, fence)
  329. for start >= 0 {
  330. rest := value[start+len(fence):]
  331. end := strings.Index(rest, fence)
  332. if end < 0 {
  333. return ""
  334. }
  335. block := strings.TrimSpace(rest[:end])
  336. block = strings.TrimPrefix(block, "json")
  337. block = strings.TrimPrefix(block, "JSON")
  338. block = strings.TrimSpace(block)
  339. if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") {
  340. return block
  341. }
  342. nextOffset := start + len(fence) + end + len(fence)
  343. nextStart := strings.Index(value[nextOffset:], fence)
  344. if nextStart < 0 {
  345. break
  346. }
  347. start = nextOffset + nextStart
  348. }
  349. return ""
  350. }
  351. func extractJSONObject(value string) string {
  352. start := strings.IndexAny(value, "{[")
  353. if start < 0 {
  354. return ""
  355. }
  356. end := strings.LastIndexAny(value, "}]")
  357. if end <= start {
  358. return ""
  359. }
  360. return strings.TrimSpace(value[start : end+1])
  361. }
  362. func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) {
  363. targetPayload := make([]map[string]string, 0, len(targets))
  364. for _, target := range targets {
  365. targetPayload = append(targetPayload, map[string]string{
  366. "fieldPath": strings.TrimSpace(target.FieldPath),
  367. "slot": strings.TrimSpace(target.Slot),
  368. })
  369. }
  370. contextPayload := map[string]any{
  371. "globalData": req.GlobalData,
  372. "draftContext": llmDraftContextMap(req.DraftContext),
  373. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  374. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  375. "targets": targetPayload,
  376. }
  377. contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
  378. 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."
  379. user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON)
  380. return system, user
  381. }
  382. func anyToString(raw any) string {
  383. switch value := raw.(type) {
  384. case string:
  385. return value
  386. case float64:
  387. return fmt.Sprintf("%.0f", value)
  388. case bool:
  389. if value {
  390. return "true"
  391. }
  392. return "false"
  393. case nil:
  394. return ""
  395. default:
  396. return fmt.Sprintf("%v", value)
  397. }
  398. }
  399. func providerLogSnippet(value string, limit int) string {
  400. trimmed := strings.TrimSpace(value)
  401. if trimmed == "" || limit <= 0 {
  402. return ""
  403. }
  404. runes := []rune(trimmed)
  405. if len(runes) <= limit {
  406. return trimmed
  407. }
  408. return strings.TrimSpace(string(runes[:limit])) + "...(truncated)"
  409. }
  410. func providerSuggestionSample(items []providerSuggestion, limit int) []map[string]string {
  411. if len(items) == 0 || limit <= 0 {
  412. return []map[string]string{}
  413. }
  414. if len(items) > limit {
  415. items = items[:limit]
  416. }
  417. out := make([]map[string]string, 0, len(items))
  418. for _, item := range items {
  419. out = append(out, map[string]string{
  420. "fieldPath": strings.TrimSpace(item.FieldPath),
  421. "slot": strings.TrimSpace(item.Slot),
  422. "value": providerLogSnippet(item.Value, 200),
  423. "reason": providerLogSnippet(item.Reason, 120),
  424. })
  425. }
  426. return out
  427. }
  428. func suggestionLogSample(result SuggestionResult, limit int) []map[string]string {
  429. if limit <= 0 || len(result.ByFieldPath) == 0 {
  430. return []map[string]string{}
  431. }
  432. paths := make([]string, 0, len(result.ByFieldPath))
  433. for path := range result.ByFieldPath {
  434. paths = append(paths, path)
  435. }
  436. sort.Strings(paths)
  437. if len(paths) > limit {
  438. paths = paths[:limit]
  439. }
  440. out := make([]map[string]string, 0, len(paths))
  441. for _, path := range paths {
  442. item := result.ByFieldPath[path]
  443. out = append(out, map[string]string{
  444. "fieldPath": strings.TrimSpace(item.FieldPath),
  445. "source": strings.TrimSpace(item.Source),
  446. "slot": strings.TrimSpace(item.Slot),
  447. "value": providerLogSnippet(item.Value, 200),
  448. })
  449. }
  450. return out
  451. }
  452. func llmruntimeSafeBaseURL(value string) string {
  453. trimmed := strings.TrimSpace(value)
  454. if trimmed == "" {
  455. return ""
  456. }
  457. parsed, err := url.Parse(trimmed)
  458. if err != nil || parsed.Scheme == "" || parsed.Host == "" {
  459. return trimmed
  460. }
  461. parsed.User = nil
  462. parsed.RawQuery = ""
  463. parsed.Fragment = ""
  464. return strings.TrimRight(parsed.String(), "/")
  465. }