25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

391 lines
12KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "time"
  8. "qctextbuilder/internal/domain"
  9. "qctextbuilder/internal/llmruntime"
  10. )
  11. type SettingsReader interface {
  12. GetSettings(ctx context.Context) (*domain.AppSettings, error)
  13. }
  14. type ProviderAwareSuggestionGenerator struct {
  15. settings SettingsReader
  16. runtimeFactory *llmruntime.Factory
  17. }
  18. func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator {
  19. return &ProviderAwareSuggestionGenerator{
  20. settings: settings,
  21. runtimeFactory: runtimeFactory,
  22. }
  23. }
  24. func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  25. started := time.Now()
  26. if g == nil || g.settings == nil || g.runtimeFactory == nil {
  27. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  28. "component", "autofill",
  29. "step", "provider_aware_config",
  30. "status", "failed",
  31. "template_id", req.TemplateID,
  32. "error", "provider-aware generator is not configured",
  33. )
  34. return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured")
  35. }
  36. settings, err := g.settings.GetSettings(ctx)
  37. if err != nil || settings == nil {
  38. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  39. "component", "autofill",
  40. "step", "provider_aware_settings",
  41. "status", "failed",
  42. "template_id", req.TemplateID,
  43. "error", "llm settings are not available",
  44. "duration_ms", time.Since(started).Milliseconds(),
  45. )
  46. return SuggestionResult{}, fmt.Errorf("llm settings are not available")
  47. }
  48. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  49. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  50. baseURL := strings.TrimSpace(settings.LLMBaseURL)
  51. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  52. "component", "autofill",
  53. "step", "provider_aware_request",
  54. "status", "start",
  55. "provider", provider,
  56. "model", model,
  57. "template_id", req.TemplateID,
  58. "draft_id", strings.TrimSpace(req.DraftID),
  59. )
  60. if strings.TrimSpace(model) == "" {
  61. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  62. "component", "autofill",
  63. "step", "provider_aware_request",
  64. "status", "failed",
  65. "provider", provider,
  66. "model", model,
  67. "template_id", req.TemplateID,
  68. "draft_id", strings.TrimSpace(req.DraftID),
  69. "error", "no active model configured",
  70. "duration_ms", time.Since(started).Milliseconds(),
  71. )
  72. return SuggestionResult{}, fmt.Errorf("no active model configured")
  73. }
  74. apiKey := domain.LLMAPIKeyForProvider(provider, *settings)
  75. if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
  76. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  77. "component", "autofill",
  78. "step", "provider_aware_request",
  79. "status", "failed",
  80. "provider", provider,
  81. "model", model,
  82. "template_id", req.TemplateID,
  83. "draft_id", strings.TrimSpace(req.DraftID),
  84. "error", "missing api key",
  85. "duration_ms", time.Since(started).Milliseconds(),
  86. )
  87. return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured in settings", provider)
  88. }
  89. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  90. if len(targets) == 0 {
  91. return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil
  92. }
  93. allowed := make(map[string]SemanticSlotTarget, len(targets))
  94. for _, target := range targets {
  95. allowed[target.FieldPath] = target
  96. }
  97. providerClient, err := g.runtimeFactory.ClientFor(provider)
  98. if err != nil {
  99. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  100. "component", "autofill",
  101. "step", "provider_aware_request",
  102. "status", "failed",
  103. "provider", provider,
  104. "model", model,
  105. "template_id", req.TemplateID,
  106. "draft_id", strings.TrimSpace(req.DraftID),
  107. "error", shortErr(err),
  108. "duration_ms", time.Since(started).Milliseconds(),
  109. )
  110. return SuggestionResult{}, err
  111. }
  112. systemPrompt, userPrompt := buildProviderPrompts(req, targets)
  113. temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature)
  114. maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  115. raw, err := providerClient.Generate(ctx, llmruntime.Request{
  116. Provider: provider,
  117. Model: model,
  118. BaseURL: baseURL,
  119. APIKey: strings.TrimSpace(apiKey),
  120. Temperature: &temperature,
  121. MaxTokens: &maxTokens,
  122. SystemPrompt: systemPrompt,
  123. UserPrompt: userPrompt,
  124. })
  125. if err != nil {
  126. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  127. "component", "autofill",
  128. "step", "provider_aware_request",
  129. "status", "failed",
  130. "provider", provider,
  131. "model", model,
  132. "template_id", req.TemplateID,
  133. "draft_id", strings.TrimSpace(req.DraftID),
  134. "error", shortErr(err),
  135. "duration_ms", time.Since(started).Milliseconds(),
  136. )
  137. return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err)
  138. }
  139. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  140. "component", "autofill",
  141. "step", "provider_aware_request",
  142. "status", "success",
  143. "provider", provider,
  144. "model", model,
  145. "template_id", req.TemplateID,
  146. "draft_id", strings.TrimSpace(req.DraftID),
  147. "response_chars", len(strings.TrimSpace(raw)),
  148. )
  149. parsed, err := parseProviderSuggestions(raw)
  150. if err != nil {
  151. mappingLogger().WarnContext(ctx, "provider-aware suggestion",
  152. "component", "autofill",
  153. "step", "provider_parse",
  154. "status", "failed",
  155. "provider", provider,
  156. "model", model,
  157. "template_id", req.TemplateID,
  158. "draft_id", strings.TrimSpace(req.DraftID),
  159. "error", shortErr(err),
  160. "duration_ms", time.Since(started).Milliseconds(),
  161. )
  162. return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err)
  163. }
  164. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  165. "component", "autofill",
  166. "step", "provider_parse",
  167. "status", "success",
  168. "provider", provider,
  169. "model", model,
  170. "template_id", req.TemplateID,
  171. "draft_id", strings.TrimSpace(req.DraftID),
  172. "parsed_count", len(parsed),
  173. )
  174. out := SuggestionResult{
  175. Suggestions: make([]Suggestion, 0, len(parsed)),
  176. ByFieldPath: map[string]Suggestion{},
  177. }
  178. for _, item := range parsed {
  179. fieldPath := strings.TrimSpace(item.FieldPath)
  180. target, ok := allowed[fieldPath]
  181. if !ok {
  182. continue
  183. }
  184. value := strings.TrimSpace(item.Value)
  185. if value == "" {
  186. continue
  187. }
  188. suggestion := Suggestion{
  189. FieldPath: fieldPath,
  190. Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot),
  191. Value: value,
  192. Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"),
  193. Source: provider,
  194. }
  195. if _, exists := out.ByFieldPath[fieldPath]; exists {
  196. continue
  197. }
  198. out.Suggestions = append(out.Suggestions, suggestion)
  199. out.ByFieldPath[fieldPath] = suggestion
  200. }
  201. mappingLogger().InfoContext(ctx, "provider-aware suggestion",
  202. "component", "autofill",
  203. "step", "provider_aware_result",
  204. "status", "success",
  205. "provider", provider,
  206. "model", model,
  207. "template_id", req.TemplateID,
  208. "draft_id", strings.TrimSpace(req.DraftID),
  209. "suggestion_count", len(out.Suggestions),
  210. "duration_ms", time.Since(started).Milliseconds(),
  211. )
  212. return out, nil
  213. }
  214. type providerSuggestion struct {
  215. FieldPath string `json:"fieldPath"`
  216. Slot string `json:"slot,omitempty"`
  217. Value string `json:"value"`
  218. Reason string `json:"reason,omitempty"`
  219. }
  220. func parseProviderSuggestions(raw string) ([]providerSuggestion, error) {
  221. content := strings.TrimSpace(raw)
  222. if content == "" {
  223. return nil, fmt.Errorf("empty provider response")
  224. }
  225. candidates := []string{content}
  226. if fence := extractFencedJSON(content); fence != "" {
  227. candidates = append([]string{fence}, candidates...)
  228. }
  229. if object := extractJSONObject(content); object != "" {
  230. candidates = append(candidates, object)
  231. }
  232. var firstErr error
  233. for _, candidate := range candidates {
  234. items, err := parseSuggestionsCandidate(candidate)
  235. if err == nil {
  236. return items, nil
  237. }
  238. if firstErr == nil {
  239. firstErr = err
  240. }
  241. }
  242. if firstErr != nil {
  243. return nil, firstErr
  244. }
  245. return nil, fmt.Errorf("provider response is not valid suggestions json")
  246. }
  247. func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) {
  248. var root any
  249. if err := json.Unmarshal([]byte(raw), &root); err != nil {
  250. return nil, fmt.Errorf("provider response is not valid json: %w", err)
  251. }
  252. var itemsRaw []any
  253. switch value := root.(type) {
  254. case map[string]any:
  255. suggestions, ok := value["suggestions"]
  256. if !ok {
  257. return nil, fmt.Errorf("provider json object must contain \"suggestions\" array")
  258. }
  259. list, ok := suggestions.([]any)
  260. if !ok {
  261. return nil, fmt.Errorf("provider \"suggestions\" must be an array")
  262. }
  263. itemsRaw = list
  264. case []any:
  265. itemsRaw = value
  266. default:
  267. return nil, fmt.Errorf("provider json payload must be an object or array")
  268. }
  269. if len(itemsRaw) == 0 {
  270. return nil, fmt.Errorf("provider returned an empty suggestions array")
  271. }
  272. out := make([]providerSuggestion, 0, len(itemsRaw))
  273. for idx, rawItem := range itemsRaw {
  274. itemMap, ok := rawItem.(map[string]any)
  275. if !ok {
  276. return nil, fmt.Errorf("suggestion #%d is not an object", idx+1)
  277. }
  278. fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"]))
  279. if fieldPath == "" {
  280. return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1)
  281. }
  282. value := strings.TrimSpace(anyToString(itemMap["value"]))
  283. if value == "" {
  284. return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath)
  285. }
  286. out = append(out, providerSuggestion{
  287. FieldPath: fieldPath,
  288. Slot: strings.TrimSpace(anyToString(itemMap["slot"])),
  289. Value: value,
  290. Reason: strings.TrimSpace(anyToString(itemMap["reason"])),
  291. })
  292. }
  293. return out, nil
  294. }
  295. func extractFencedJSON(value string) string {
  296. const fence = "```"
  297. start := strings.Index(value, fence)
  298. for start >= 0 {
  299. rest := value[start+len(fence):]
  300. end := strings.Index(rest, fence)
  301. if end < 0 {
  302. return ""
  303. }
  304. block := strings.TrimSpace(rest[:end])
  305. block = strings.TrimPrefix(block, "json")
  306. block = strings.TrimPrefix(block, "JSON")
  307. block = strings.TrimSpace(block)
  308. if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") {
  309. return block
  310. }
  311. nextOffset := start + len(fence) + end + len(fence)
  312. nextStart := strings.Index(value[nextOffset:], fence)
  313. if nextStart < 0 {
  314. break
  315. }
  316. start = nextOffset + nextStart
  317. }
  318. return ""
  319. }
  320. func extractJSONObject(value string) string {
  321. start := strings.IndexAny(value, "{[")
  322. if start < 0 {
  323. return ""
  324. }
  325. end := strings.LastIndexAny(value, "}]")
  326. if end <= start {
  327. return ""
  328. }
  329. return strings.TrimSpace(value[start : end+1])
  330. }
  331. func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) {
  332. targetPayload := make([]map[string]string, 0, len(targets))
  333. for _, target := range targets {
  334. targetPayload = append(targetPayload, map[string]string{
  335. "fieldPath": strings.TrimSpace(target.FieldPath),
  336. "slot": strings.TrimSpace(target.Slot),
  337. })
  338. }
  339. contextPayload := map[string]any{
  340. "globalData": req.GlobalData,
  341. "draftContext": llmDraftContextMap(req.DraftContext),
  342. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  343. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  344. "targets": targetPayload,
  345. }
  346. contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
  347. 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."
  348. user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON)
  349. return system, user
  350. }
  351. func anyToString(raw any) string {
  352. switch value := raw.(type) {
  353. case string:
  354. return value
  355. case float64:
  356. return fmt.Sprintf("%.0f", value)
  357. case bool:
  358. if value {
  359. return "true"
  360. }
  361. return "false"
  362. case nil:
  363. return ""
  364. default:
  365. return fmt.Sprintf("%v", value)
  366. }
  367. }