Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

278 rindas
8.3KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "qctextbuilder/internal/domain"
  8. "qctextbuilder/internal/llmruntime"
  9. )
  10. type SettingsReader interface {
  11. GetSettings(ctx context.Context) (*domain.AppSettings, error)
  12. }
  13. type ProviderAwareSuggestionGenerator struct {
  14. settings SettingsReader
  15. runtimeFactory *llmruntime.Factory
  16. }
  17. func NewProviderAwareSuggestionGenerator(settings SettingsReader, runtimeFactory *llmruntime.Factory) *ProviderAwareSuggestionGenerator {
  18. return &ProviderAwareSuggestionGenerator{
  19. settings: settings,
  20. runtimeFactory: runtimeFactory,
  21. }
  22. }
  23. func (g *ProviderAwareSuggestionGenerator) Generate(ctx context.Context, req SuggestionRequest) (SuggestionResult, error) {
  24. if g == nil || g.settings == nil || g.runtimeFactory == nil {
  25. return SuggestionResult{}, fmt.Errorf("provider-aware generator is not configured")
  26. }
  27. settings, err := g.settings.GetSettings(ctx)
  28. if err != nil || settings == nil {
  29. return SuggestionResult{}, fmt.Errorf("llm settings are not available")
  30. }
  31. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  32. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  33. if strings.TrimSpace(model) == "" {
  34. return SuggestionResult{}, fmt.Errorf("no active model configured")
  35. }
  36. apiKey := domain.LLMAPIKeyForProvider(provider, *settings)
  37. if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
  38. return SuggestionResult{}, fmt.Errorf("api key for provider %s is not configured in settings", provider)
  39. }
  40. targets := collectSuggestionTargets(req.Fields, req.Existing, req.IncludeFilled)
  41. if len(targets) == 0 {
  42. return SuggestionResult{Suggestions: []Suggestion{}, ByFieldPath: map[string]Suggestion{}}, nil
  43. }
  44. allowed := make(map[string]SemanticSlotTarget, len(targets))
  45. for _, target := range targets {
  46. allowed[target.FieldPath] = target
  47. }
  48. providerClient, err := g.runtimeFactory.ClientFor(provider)
  49. if err != nil {
  50. return SuggestionResult{}, err
  51. }
  52. systemPrompt, userPrompt := buildProviderPrompts(req, targets)
  53. temperature := domain.NormalizeLLMTemperature(settings.LLMTemperature)
  54. maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  55. raw, err := providerClient.Generate(ctx, llmruntime.Request{
  56. Provider: provider,
  57. Model: model,
  58. BaseURL: strings.TrimSpace(settings.LLMBaseURL),
  59. APIKey: strings.TrimSpace(apiKey),
  60. Temperature: &temperature,
  61. MaxTokens: &maxTokens,
  62. SystemPrompt: systemPrompt,
  63. UserPrompt: userPrompt,
  64. })
  65. if err != nil {
  66. return SuggestionResult{}, fmt.Errorf("provider request failed (provider=%s model=%s): %w", provider, model, err)
  67. }
  68. parsed, err := parseProviderSuggestions(raw)
  69. if err != nil {
  70. return SuggestionResult{}, fmt.Errorf("provider returned invalid suggestions json (provider=%s model=%s): %w", provider, model, err)
  71. }
  72. out := SuggestionResult{
  73. Suggestions: make([]Suggestion, 0, len(parsed)),
  74. ByFieldPath: map[string]Suggestion{},
  75. }
  76. for _, item := range parsed {
  77. fieldPath := strings.TrimSpace(item.FieldPath)
  78. target, ok := allowed[fieldPath]
  79. if !ok {
  80. continue
  81. }
  82. value := strings.TrimSpace(item.Value)
  83. if value == "" {
  84. continue
  85. }
  86. suggestion := Suggestion{
  87. FieldPath: fieldPath,
  88. Slot: firstNonEmpty(strings.TrimSpace(item.Slot), target.Slot),
  89. Value: value,
  90. Reason: firstNonEmpty(strings.TrimSpace(item.Reason), "provider suggestion"),
  91. Source: provider,
  92. }
  93. if _, exists := out.ByFieldPath[fieldPath]; exists {
  94. continue
  95. }
  96. out.Suggestions = append(out.Suggestions, suggestion)
  97. out.ByFieldPath[fieldPath] = suggestion
  98. }
  99. return out, nil
  100. }
  101. type providerSuggestion struct {
  102. FieldPath string `json:"fieldPath"`
  103. Slot string `json:"slot,omitempty"`
  104. Value string `json:"value"`
  105. Reason string `json:"reason,omitempty"`
  106. }
  107. func parseProviderSuggestions(raw string) ([]providerSuggestion, error) {
  108. content := strings.TrimSpace(raw)
  109. if content == "" {
  110. return nil, fmt.Errorf("empty provider response")
  111. }
  112. candidates := []string{content}
  113. if fence := extractFencedJSON(content); fence != "" {
  114. candidates = append([]string{fence}, candidates...)
  115. }
  116. if object := extractJSONObject(content); object != "" {
  117. candidates = append(candidates, object)
  118. }
  119. var firstErr error
  120. for _, candidate := range candidates {
  121. items, err := parseSuggestionsCandidate(candidate)
  122. if err == nil {
  123. return items, nil
  124. }
  125. if firstErr == nil {
  126. firstErr = err
  127. }
  128. }
  129. if firstErr != nil {
  130. return nil, firstErr
  131. }
  132. return nil, fmt.Errorf("provider response is not valid suggestions json")
  133. }
  134. func parseSuggestionsCandidate(raw string) ([]providerSuggestion, error) {
  135. var root any
  136. if err := json.Unmarshal([]byte(raw), &root); err != nil {
  137. return nil, fmt.Errorf("provider response is not valid json: %w", err)
  138. }
  139. var itemsRaw []any
  140. switch value := root.(type) {
  141. case map[string]any:
  142. suggestions, ok := value["suggestions"]
  143. if !ok {
  144. return nil, fmt.Errorf("provider json object must contain \"suggestions\" array")
  145. }
  146. list, ok := suggestions.([]any)
  147. if !ok {
  148. return nil, fmt.Errorf("provider \"suggestions\" must be an array")
  149. }
  150. itemsRaw = list
  151. case []any:
  152. itemsRaw = value
  153. default:
  154. return nil, fmt.Errorf("provider json payload must be an object or array")
  155. }
  156. if len(itemsRaw) == 0 {
  157. return nil, fmt.Errorf("provider returned an empty suggestions array")
  158. }
  159. out := make([]providerSuggestion, 0, len(itemsRaw))
  160. for idx, rawItem := range itemsRaw {
  161. itemMap, ok := rawItem.(map[string]any)
  162. if !ok {
  163. return nil, fmt.Errorf("suggestion #%d is not an object", idx+1)
  164. }
  165. fieldPath := strings.TrimSpace(anyToString(itemMap["fieldPath"]))
  166. if fieldPath == "" {
  167. return nil, fmt.Errorf("suggestion #%d has empty fieldPath", idx+1)
  168. }
  169. value := strings.TrimSpace(anyToString(itemMap["value"]))
  170. if value == "" {
  171. return nil, fmt.Errorf("suggestion #%d for fieldPath %q has empty value", idx+1, fieldPath)
  172. }
  173. out = append(out, providerSuggestion{
  174. FieldPath: fieldPath,
  175. Slot: strings.TrimSpace(anyToString(itemMap["slot"])),
  176. Value: value,
  177. Reason: strings.TrimSpace(anyToString(itemMap["reason"])),
  178. })
  179. }
  180. return out, nil
  181. }
  182. func extractFencedJSON(value string) string {
  183. const fence = "```"
  184. start := strings.Index(value, fence)
  185. for start >= 0 {
  186. rest := value[start+len(fence):]
  187. end := strings.Index(rest, fence)
  188. if end < 0 {
  189. return ""
  190. }
  191. block := strings.TrimSpace(rest[:end])
  192. block = strings.TrimPrefix(block, "json")
  193. block = strings.TrimPrefix(block, "JSON")
  194. block = strings.TrimSpace(block)
  195. if strings.HasPrefix(block, "{") || strings.HasPrefix(block, "[") {
  196. return block
  197. }
  198. nextOffset := start + len(fence) + end + len(fence)
  199. nextStart := strings.Index(value[nextOffset:], fence)
  200. if nextStart < 0 {
  201. break
  202. }
  203. start = nextOffset + nextStart
  204. }
  205. return ""
  206. }
  207. func extractJSONObject(value string) string {
  208. start := strings.IndexAny(value, "{[")
  209. if start < 0 {
  210. return ""
  211. }
  212. end := strings.LastIndexAny(value, "}]")
  213. if end <= start {
  214. return ""
  215. }
  216. return strings.TrimSpace(value[start : end+1])
  217. }
  218. func buildProviderPrompts(req SuggestionRequest, targets []SemanticSlotTarget) (string, string) {
  219. targetPayload := make([]map[string]string, 0, len(targets))
  220. for _, target := range targets {
  221. targetPayload = append(targetPayload, map[string]string{
  222. "fieldPath": strings.TrimSpace(target.FieldPath),
  223. "slot": strings.TrimSpace(target.Slot),
  224. })
  225. }
  226. contextPayload := map[string]any{
  227. "globalData": req.GlobalData,
  228. "draftContext": llmDraftContextMap(req.DraftContext),
  229. "masterPrompt": strings.TrimSpace(req.MasterPrompt),
  230. "promptBlocks": enabledPromptBlocks(req.PromptBlocks),
  231. "targets": targetPayload,
  232. }
  233. contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
  234. 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."
  235. user := "Generate suggestions for each target field using the provided context. Do not include markdown.\n\n" + string(contextJSON)
  236. return system, user
  237. }
  238. func anyToString(raw any) string {
  239. switch value := raw.(type) {
  240. case string:
  241. return value
  242. case float64:
  243. return fmt.Sprintf("%.0f", value)
  244. case bool:
  245. if value {
  246. return "true"
  247. }
  248. return "false"
  249. case nil:
  250. return ""
  251. default:
  252. return fmt.Sprintf("%v", value)
  253. }
  254. }