|
- package mapping
-
- import (
- "context"
- "encoding/json"
- "fmt"
- "regexp"
- "sort"
- "strings"
- "time"
-
- "qctextbuilder/internal/domain"
- "qctextbuilder/internal/llmruntime"
- )
-
- // AISlotMapper classifies template fields into semantic slots via the active
- // LLM provider. It is meant to run once per template (during onboarding) and
- // its results are persisted on the manifest fields.
- type AISlotMapper struct {
- settings SettingsReader
- runtimeFactory *llmruntime.Factory
- }
-
- func NewAISlotMapper(settings SettingsReader, runtimeFactory *llmruntime.Factory) *AISlotMapper {
- return &AISlotMapper{settings: settings, runtimeFactory: runtimeFactory}
- }
-
- type AISlotAssignment struct {
- FieldPath string `json:"fieldPath"`
- Slot string `json:"slot"`
- Confidence float64 `json:"confidence"`
- Reason string `json:"reason"`
- }
-
- var aiSlotRepeatedPattern = regexp.MustCompile(`^(service_items|team_items|testimonial_items)\[\d+\]\.(title|description|name)$`)
-
- var aiSlotSingletons = map[string]struct{}{
- "hero.title": {},
- "intro.title": {},
- "intro.description": {},
- "about.description": {},
- "cta.text": {},
- }
-
- func isValidSemanticSlot(slot string) bool {
- if slot == "" {
- return false
- }
- if _, ok := aiSlotSingletons[slot]; ok {
- return true
- }
- if !aiSlotRepeatedPattern.MatchString(slot) {
- return false
- }
- // team_items[N].title is not a valid combination — name is the only label role.
- switch {
- case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, ".title"):
- return false
- case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, ".name"):
- return false
- }
- return true
- }
-
- // MapFields returns one assignment per input field whose Slot is a valid
- // semantic slot. Fields the model decided to skip are omitted.
- func (m *AISlotMapper) MapFields(ctx context.Context, templateID int64, fields []domain.TemplateField) ([]AISlotAssignment, error) {
- started := time.Now()
- if m == nil || m.settings == nil || m.runtimeFactory == nil {
- return nil, fmt.Errorf("ai slot mapper is not configured")
- }
-
- textFields := make([]domain.TemplateField, 0, len(fields))
- for _, f := range fields {
- if !f.IsEnabled {
- continue
- }
- if !strings.EqualFold(strings.TrimSpace(f.FieldKind), "text") {
- continue
- }
- textFields = append(textFields, f)
- }
- if len(textFields) == 0 {
- return []AISlotAssignment{}, nil
- }
-
- settings, err := m.settings.GetSettings(ctx)
- if err != nil || settings == nil {
- return nil, fmt.Errorf("llm settings are not available")
- }
- provider := domain.NormalizeLLMProvider(settings.LLMActiveProvider)
- model := domain.NormalizeLLMModel(provider, settings.LLMActiveModel)
- if strings.TrimSpace(model) == "" {
- return nil, fmt.Errorf("no active llm model configured")
- }
- apiKey := domain.LLMAPIKeyForProvider(provider, *settings)
- if provider != domain.LLMProviderOllama && strings.TrimSpace(apiKey) == "" {
- return nil, fmt.Errorf("api key for provider %s is not configured", provider)
- }
- baseURL := strings.TrimSpace(settings.LLMBaseURL)
-
- client, err := m.runtimeFactory.ClientFor(provider)
- if err != nil {
- return nil, err
- }
-
- systemPrompt, userPrompt := buildAISlotPrompts(textFields)
- mappingLogger().InfoContext(ctx, "ai slot mapping",
- "component", "automapper",
- "step", "request",
- "status", "start",
- "provider", provider,
- "model", model,
- "template_id", templateID,
- "field_count", len(textFields),
- )
- temperature := 0.0
- maxTokens := domain.NormalizeLLMMaxTokens(settings.LLMMaxTokens)
- if maxTokens < 2048 {
- maxTokens = 2048
- }
- raw, err := client.Generate(ctx, llmruntime.Request{
- Provider: provider,
- Model: model,
- BaseURL: baseURL,
- APIKey: strings.TrimSpace(apiKey),
- Temperature: &temperature,
- MaxTokens: &maxTokens,
- SystemPrompt: systemPrompt,
- UserPrompt: userPrompt,
- })
- if err != nil {
- mappingLogger().WarnContext(ctx, "ai slot mapping",
- "component", "automapper",
- "step", "request",
- "status", "failed",
- "provider", provider,
- "model", model,
- "template_id", templateID,
- "error", shortErr(err),
- "duration_ms", time.Since(started).Milliseconds(),
- )
- return nil, fmt.Errorf("ai slot mapper request failed (provider=%s model=%s): %w", provider, model, err)
- }
-
- parsed, err := parseAISlotResponse(raw)
- if err != nil {
- mappingLogger().WarnContext(ctx, "ai slot mapping",
- "component", "automapper",
- "step", "parse",
- "status", "failed",
- "provider", provider,
- "model", model,
- "template_id", templateID,
- "error", shortErr(err),
- "response_snippet", providerLogSnippet(raw, 1500),
- "duration_ms", time.Since(started).Milliseconds(),
- )
- return nil, fmt.Errorf("ai slot mapper returned invalid json: %w", err)
- }
-
- allowed := make(map[string]struct{}, len(textFields))
- for _, f := range textFields {
- allowed[strings.TrimSpace(f.Path)] = struct{}{}
- }
- out := make([]AISlotAssignment, 0, len(parsed))
- seen := make(map[string]struct{}, len(parsed))
- for _, item := range parsed {
- path := strings.TrimSpace(item.FieldPath)
- if path == "" {
- continue
- }
- if _, ok := allowed[path]; !ok {
- continue
- }
- if _, dup := seen[path]; dup {
- continue
- }
- slot := strings.TrimSpace(item.Slot)
- if !isValidSemanticSlot(slot) {
- continue
- }
- seen[path] = struct{}{}
- out = append(out, AISlotAssignment{
- FieldPath: path,
- Slot: slot,
- Confidence: item.Confidence,
- Reason: strings.TrimSpace(item.Reason),
- })
- }
- sort.SliceStable(out, func(i, j int) bool { return out[i].FieldPath < out[j].FieldPath })
-
- mappingLogger().InfoContext(ctx, "ai slot mapping",
- "component", "automapper",
- "step", "result",
- "status", "success",
- "provider", provider,
- "model", model,
- "template_id", templateID,
- "input_count", len(textFields),
- "mapped_count", len(out),
- "duration_ms", time.Since(started).Milliseconds(),
- )
- return out, nil
- }
-
- func buildAISlotPrompts(fields []domain.TemplateField) (string, string) {
- type fieldPayload struct {
- Path string `json:"path"`
- KeyName string `json:"keyName"`
- Section string `json:"section"`
- Block string `json:"block,omitempty"`
- DisplayOrder int `json:"displayOrder"`
- SampleValue string `json:"sampleValue,omitempty"`
- }
- payload := make([]fieldPayload, 0, len(fields))
- for _, f := range fields {
- payload = append(payload, fieldPayload{
- Path: strings.TrimSpace(f.Path),
- KeyName: strings.TrimSpace(f.KeyName),
- Section: strings.TrimSpace(f.Section),
- Block: semanticExtractBlockID(f),
- DisplayOrder: f.DisplayOrder,
- SampleValue: truncateSample(f.SampleValue, 200),
- })
- }
- body := map[string]any{
- "fields": payload,
- }
- bodyJSON, _ := json.MarshalIndent(body, "", " ")
-
- system := strings.TrimSpace(`
- You are a website-template field mapper. You classify each text field of a QC
- website template into ONE semantic slot, or skip it.
-
- Allowed slots:
- - hero.title (one per template, topmost large heading)
- - intro.title, intro.description (one each, secondary heading/intro block)
- - about.description (one, longer "about us" paragraph)
- - cta.text (one, call-to-action button / highlight)
- - service_items[N].title (N = 0,1,2... one per service card)
- - service_items[N].description
- - team_items[N].name
- - team_items[N].description
- - testimonial_items[N].title
- - testimonial_items[N].name
- - testimonial_items[N].description
-
- Rules:
- 1. Use the section (e.g. "services", "testimonials") as a strong hint.
- 2. Use the block prefix (e.g. "c8987", "r1865") to group title+description from
- the same UI block together.
- 3. Use displayOrder to decide which block is hero vs intro vs about (lower order = earlier on page).
- 4. Use sampleValue length: short (<60 chars) = title/name; long (>120 chars) = description.
- 5. For repeated slots, pair each block to ONE item index; use the same N for
- title and description coming from the same block. Index from 0, increasing
- by block displayOrder.
- 6. If you cannot map a field to one of the listed slots, output "" (empty) — never invent slots.
- 7. Each fieldPath appears exactly once in the output.
-
- Return ONLY JSON, no markdown, no commentary:
- {"mappings":[{"fieldPath":"...","slot":"hero.title","confidence":0.9,"reason":"..."}]}
- `)
-
- user := "Classify the following fields. Respond with JSON only.\n\n" + string(bodyJSON)
- return system, user
- }
-
- func parseAISlotResponse(raw string) ([]AISlotAssignment, error) {
- content := strings.TrimSpace(raw)
- if content == "" {
- return nil, fmt.Errorf("empty response")
- }
- candidates := []string{content}
- if fenced := extractFencedJSON(content); fenced != "" {
- candidates = append([]string{fenced}, candidates...)
- }
- if obj := extractJSONObject(content); obj != "" {
- candidates = append(candidates, obj)
- }
-
- var firstErr error
- for _, candidate := range candidates {
- items, err := parseAISlotPayload(candidate)
- if err == nil {
- return items, nil
- }
- if firstErr == nil {
- firstErr = err
- }
- }
- if firstErr != nil {
- return nil, firstErr
- }
- return nil, fmt.Errorf("response is not a valid mapping json")
- }
-
- func parseAISlotPayload(raw string) ([]AISlotAssignment, error) {
- var root any
- if err := json.Unmarshal([]byte(raw), &root); err != nil {
- return nil, err
- }
- var itemsRaw []any
- switch value := root.(type) {
- case map[string]any:
- list, ok := value["mappings"].([]any)
- if !ok {
- // also tolerate {"suggestions":...} or any single array property
- for _, key := range []string{"suggestions", "results", "items"} {
- if arr, ok := value[key].([]any); ok {
- list = arr
- break
- }
- }
- }
- if list == nil {
- return nil, fmt.Errorf("object must contain \"mappings\" array")
- }
- itemsRaw = list
- case []any:
- itemsRaw = value
- default:
- return nil, fmt.Errorf("payload must be object or array")
- }
- out := make([]AISlotAssignment, 0, len(itemsRaw))
- for idx, item := range itemsRaw {
- m, ok := item.(map[string]any)
- if !ok {
- return nil, fmt.Errorf("entry #%d is not an object", idx+1)
- }
- path := strings.TrimSpace(anyToString(m["fieldPath"]))
- if path == "" {
- path = strings.TrimSpace(anyToString(m["path"]))
- }
- if path == "" {
- continue
- }
- confidence := 0.0
- if v, ok := m["confidence"].(float64); ok {
- confidence = v
- }
- out = append(out, AISlotAssignment{
- FieldPath: path,
- Slot: strings.TrimSpace(anyToString(m["slot"])),
- Confidence: confidence,
- Reason: strings.TrimSpace(anyToString(m["reason"])),
- })
- }
- return out, nil
- }
-
- func truncateSample(value string, limit int) string {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" || limit <= 0 {
- return ""
- }
- runes := []rune(trimmed)
- if len(runes) <= limit {
- return trimmed
- }
- return string(runes[:limit]) + "..."
- }
|