Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

363 lines
11KB

  1. package mapping
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "regexp"
  7. "sort"
  8. "strings"
  9. "time"
  10. "qctextbuilder/internal/domain"
  11. "qctextbuilder/internal/llmruntime"
  12. )
  13. // AISlotMapper classifies template fields into semantic slots via the active
  14. // LLM provider. It is meant to run once per template (during onboarding) and
  15. // its results are persisted on the manifest fields.
  16. type AISlotMapper struct {
  17. settings SettingsReader
  18. runtimeFactory *llmruntime.Factory
  19. }
  20. func NewAISlotMapper(settings SettingsReader, runtimeFactory *llmruntime.Factory) *AISlotMapper {
  21. return &AISlotMapper{settings: settings, runtimeFactory: runtimeFactory}
  22. }
  23. type AISlotAssignment struct {
  24. FieldPath string `json:"fieldPath"`
  25. Slot string `json:"slot"`
  26. Confidence float64 `json:"confidence"`
  27. Reason string `json:"reason"`
  28. }
  29. var aiSlotRepeatedPattern = regexp.MustCompile(`^(service_items|team_items|testimonial_items)\[\d+\]\.(title|description|name)$`)
  30. var aiSlotSingletons = map[string]struct{}{
  31. "hero.title": {},
  32. "intro.title": {},
  33. "intro.description": {},
  34. "about.description": {},
  35. "cta.text": {},
  36. }
  37. func isValidSemanticSlot(slot string) bool {
  38. if slot == "" {
  39. return false
  40. }
  41. if _, ok := aiSlotSingletons[slot]; ok {
  42. return true
  43. }
  44. if !aiSlotRepeatedPattern.MatchString(slot) {
  45. return false
  46. }
  47. // team_items[N].title is not a valid combination — name is the only label role.
  48. switch {
  49. case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, ".title"):
  50. return false
  51. case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, ".name"):
  52. return false
  53. }
  54. return true
  55. }
  56. // MapFields returns one assignment per input field whose Slot is a valid
  57. // semantic slot. Fields the model decided to skip are omitted.
  58. func (m *AISlotMapper) MapFields(ctx context.Context, templateID int64, fields []domain.TemplateField) ([]AISlotAssignment, error) {
  59. started := time.Now()
  60. if m == nil || m.settings == nil || m.runtimeFactory == nil {
  61. return nil, fmt.Errorf("ai slot mapper is not configured")
  62. }
  63. textFields := make([]domain.TemplateField, 0, len(fields))
  64. for _, f := range fields {
  65. if !f.IsEnabled {
  66. continue
  67. }
  68. if !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
  69. continue
  70. }
  71. textFields = append(textFields, f)
  72. }
  73. if len(textFields) == 0 {
  74. return []AISlotAssignment{}, nil
  75. }
  76. settings, err := m.settings.GetSettings(ctx)
  77. if err != nil || settings == nil {
  78. return nil, fmt.Errorf("llm settings are not available")
  79. }
  80. provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
  81. model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
  82. if strings.TrimSpace(model) == "" {
  83. return nil, fmt.Errorf("no active llm model configured")
  84. }
  85. apiKey := domain.LLMAPIKeyForProvider(provider, *settings)
  86. if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
  87. return nil, fmt.Errorf("api key for provider %s is not configured", provider)
  88. }
  89. baseURL := strings.TrimSpace(settings.LLMBaseURL)
  90. client, err := m.runtimeFactory.ClientFor(provider)
  91. if err != nil {
  92. return nil, err
  93. }
  94. systemPrompt, userPrompt := buildAISlotPrompts(textFields)
  95. mappingLogger().InfoContext(ctx, "ai slot mapping",
  96. "component", "automapper",
  97. "step", "request",
  98. "status", "start",
  99. "provider", provider,
  100. "model", model,
  101. "template_id", templateID,
  102. "field_count", len(textFields),
  103. )
  104. temperature := 0.0
  105. maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
  106. if maxTokens < 2048 {
  107. maxTokens = 2048
  108. }
  109. raw, err := client.Generate(ctx, llmruntime.Request{
  110. Provider: provider,
  111. Model: model,
  112. BaseURL: baseURL,
  113. APIKey: strings.TrimSpace(apiKey),
  114. Temperature: &temperature,
  115. MaxTokens: &maxTokens,
  116. SystemPrompt: systemPrompt,
  117. UserPrompt: userPrompt,
  118. })
  119. if err != nil {
  120. mappingLogger().WarnContext(ctx, "ai slot mapping",
  121. "component", "automapper",
  122. "step", "request",
  123. "status", "failed",
  124. "provider", provider,
  125. "model", model,
  126. "template_id", templateID,
  127. "error", shortErr(err),
  128. "duration_ms", time.Since(started).Milliseconds(),
  129. )
  130. return nil, fmt.Errorf("ai slot mapper request failed (provider=%s model=%s): %w", provider, model, err)
  131. }
  132. parsed, err := parseAISlotResponse(raw)
  133. if err != nil {
  134. mappingLogger().WarnContext(ctx, "ai slot mapping",
  135. "component", "automapper",
  136. "step", "parse",
  137. "status", "failed",
  138. "provider", provider,
  139. "model", model,
  140. "template_id", templateID,
  141. "error", shortErr(err),
  142. "response_snippet", providerLogSnippet(raw, 1500),
  143. "duration_ms", time.Since(started).Milliseconds(),
  144. )
  145. return nil, fmt.Errorf("ai slot mapper returned invalid json: %w", err)
  146. }
  147. allowed := make(map[string]struct{}, len(textFields))
  148. for _, f := range textFields {
  149. allowed[strings.TrimSpace(f.Path)] = struct{}{}
  150. }
  151. out := make([]AISlotAssignment, 0, len(parsed))
  152. seen := make(map[string]struct{}, len(parsed))
  153. for _, item := range parsed {
  154. path := strings.TrimSpace(item.FieldPath)
  155. if path == "" {
  156. continue
  157. }
  158. if _, ok := allowed[path]; !ok {
  159. continue
  160. }
  161. if _, dup := seen[path]; dup {
  162. continue
  163. }
  164. slot := strings.TrimSpace(item.Slot)
  165. if !isValidSemanticSlot(slot) {
  166. continue
  167. }
  168. seen[path] = struct{}{}
  169. out = append(out, AISlotAssignment{
  170. FieldPath: path,
  171. Slot: slot,
  172. Confidence: item.Confidence,
  173. Reason: strings.TrimSpace(item.Reason),
  174. })
  175. }
  176. sort.SliceStable(out, func(i, j int) bool { return out[i].FieldPath < out[j].FieldPath })
  177. mappingLogger().InfoContext(ctx, "ai slot mapping",
  178. "component", "automapper",
  179. "step", "result",
  180. "status", "success",
  181. "provider", provider,
  182. "model", model,
  183. "template_id", templateID,
  184. "input_count", len(textFields),
  185. "mapped_count", len(out),
  186. "duration_ms", time.Since(started).Milliseconds(),
  187. )
  188. return out, nil
  189. }
  190. func buildAISlotPrompts(fields []domain.TemplateField) (string, string) {
  191. type fieldPayload struct {
  192. Path string `json:"path"`
  193. KeyName string `json:"keyName"`
  194. Section string `json:"section"`
  195. Block string `json:"block,omitempty"`
  196. DisplayOrder int `json:"displayOrder"`
  197. SampleValue string `json:"sampleValue,omitempty"`
  198. }
  199. payload := make([]fieldPayload, 0, len(fields))
  200. for _, f := range fields {
  201. payload = append(payload, fieldPayload{
  202. Path: strings.TrimSpace(f.Path),
  203. KeyName: strings.TrimSpace(f.KeyName),
  204. Section: strings.TrimSpace(f.Section),
  205. Block: semanticExtractBlockID(f),
  206. DisplayOrder: f.DisplayOrder,
  207. SampleValue: truncateSample(f.SampleValue, 200),
  208. })
  209. }
  210. body := map[string]any{
  211. "fields": payload,
  212. }
  213. bodyJSON, _ := json.MarshalIndent(body, "", " ")
  214. system := strings.TrimSpace(`
  215. You are a website-template field mapper. You classify each text field of a QC
  216. website template into ONE semantic slot, or skip it.
  217. Allowed slots:
  218. - hero.title (one per template, topmost large heading)
  219. - intro.title, intro.description (one each, secondary heading/intro block)
  220. - about.description (one, longer "about us" paragraph)
  221. - cta.text (one, call-to-action button / highlight)
  222. - service_items[N].title (N = 0,1,2... one per service card)
  223. - service_items[N].description
  224. - team_items[N].name
  225. - team_items[N].description
  226. - testimonial_items[N].title
  227. - testimonial_items[N].name
  228. - testimonial_items[N].description
  229. Rules:
  230. 1. Use the section (e.g. "services", "testimonials") as a strong hint.
  231. 2. Use the block prefix (e.g. "c8987", "r1865") to group title+description from
  232. the same UI block together.
  233. 3. Use displayOrder to decide which block is hero vs intro vs about (lower order = earlier on page).
  234. 4. Use sampleValue length: short (<60 chars) = title/name; long (>120 chars) = description.
  235. 5. For repeated slots, pair each block to ONE item index; use the same N for
  236. title and description coming from the same block. Index from 0, increasing
  237. by block displayOrder.
  238. 6. If you cannot map a field to one of the listed slots, output "" (empty) — never invent slots.
  239. 7. Each fieldPath appears exactly once in the output.
  240. Return ONLY JSON, no markdown, no commentary:
  241. {"mappings":[{"fieldPath":"...","slot":"hero.title","confidence":0.9,"reason":"..."}]}
  242. `)
  243. user := "Classify the following fields. Respond with JSON only.\n\n" + string(bodyJSON)
  244. return system, user
  245. }
  246. func parseAISlotResponse(raw string) ([]AISlotAssignment, error) {
  247. content := strings.TrimSpace(raw)
  248. if content == "" {
  249. return nil, fmt.Errorf("empty response")
  250. }
  251. candidates := []string{content}
  252. if fenced := extractFencedJSON(content); fenced != "" {
  253. candidates = append([]string{fenced}, candidates...)
  254. }
  255. if obj := extractJSONObject(content); obj != "" {
  256. candidates = append(candidates, obj)
  257. }
  258. var firstErr error
  259. for _, candidate := range candidates {
  260. items, err := parseAISlotPayload(candidate)
  261. if err == nil {
  262. return items, nil
  263. }
  264. if firstErr == nil {
  265. firstErr = err
  266. }
  267. }
  268. if firstErr != nil {
  269. return nil, firstErr
  270. }
  271. return nil, fmt.Errorf("response is not a valid mapping json")
  272. }
  273. func parseAISlotPayload(raw string) ([]AISlotAssignment, error) {
  274. var root any
  275. if err := json.Unmarshal([]byte(raw), &root); err != nil {
  276. return nil, err
  277. }
  278. var itemsRaw []any
  279. switch value := root.(type) {
  280. case map[string]any:
  281. list, ok := value["mappings"].([]any)
  282. if !ok {
  283. // also tolerate {"suggestions":...} or any single array property
  284. for _, key := range []string{"suggestions", "results", "items"} {
  285. if arr, ok := value[key].([]any); ok {
  286. list = arr
  287. break
  288. }
  289. }
  290. }
  291. if list == nil {
  292. return nil, fmt.Errorf("object must contain \"mappings\" array")
  293. }
  294. itemsRaw = list
  295. case []any:
  296. itemsRaw = value
  297. default:
  298. return nil, fmt.Errorf("payload must be object or array")
  299. }
  300. out := make([]AISlotAssignment, 0, len(itemsRaw))
  301. for idx, item := range itemsRaw {
  302. m, ok := item.(map[string]any)
  303. if !ok {
  304. return nil, fmt.Errorf("entry #%d is not an object", idx+1)
  305. }
  306. path := strings.TrimSpace(anyToString(m["fieldPath"]))
  307. if path == "" {
  308. path = strings.TrimSpace(anyToString(m["path"]))
  309. }
  310. if path == "" {
  311. continue
  312. }
  313. confidence := 0.0
  314. if v, ok := m["confidence"].(float64); ok {
  315. confidence = v
  316. }
  317. out = append(out, AISlotAssignment{
  318. FieldPath: path,
  319. Slot: strings.TrimSpace(anyToString(m["slot"])),
  320. Confidence: confidence,
  321. Reason: strings.TrimSpace(anyToString(m["reason"])),
  322. })
  323. }
  324. return out, nil
  325. }
  326. func truncateSample(value string, limit int) string {
  327. trimmed := strings.TrimSpace(value)
  328. if trimmed == "" || limit <= 0 {
  329. return ""
  330. }
  331. runes := []rune(trimmed)
  332. if len(runes) <= limit {
  333. return trimmed
  334. }
  335. return string(runes[:limit]) + "..."
  336. }