package seo import ( "context" "fmt" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" "github.com/descrybe/descrybe-v2/apps/api/internal/company" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" ) // FillMetaTemplate builds meta title/description from product fields (free, rule-based). func FillMetaTemplate(p ProductInput) (title, description string) { name := displayTitle(p) if name == "" { name = "Product" } cat := strings.TrimSpace(p.Category) brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer") parts := make([]string, 0, 3) if brand != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(brand)) { parts = append(parts, brand) } parts = append(parts, name) if cat != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(cat)) { parts = append(parts, cat) } title = truncateRunes(strings.Join(parts, " | "), MetaTitleMaxChars) body := strings.TrimSpace(p.ProcessedDesc) if body == "" { body = strings.TrimSpace(p.Description) } body = stripTags(body) if body == "" { var bits []string if brand != "" { bits = append(bits, brand) } bits = append(bits, name) if cat != "" { bits = append(bits, "in "+cat) } body = strings.Join(bits, " ") + ". Shop quality products with clear specs and fast delivery." } description = truncateRunes(collapseSpace(body), MetaDescriptionMaxChars) return title, description } // FillMetaAI uses Completer to rewrite SEO meta (paid path). func FillMetaAI(ctx context.Context, completer processing.Completer, p ProductInput, prompts aiprompts.Resolved) (title, description string, tokens int, err error) { if completer == nil { return "", "", 0, fmt.Errorf("ai completer not configured") } name := displayTitle(p) desc := strings.TrimSpace(p.ProcessedDesc) if desc == "" { desc = strings.TrimSpace(p.Description) } brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer") sysTpl := strings.TrimSpace(prompts.SystemTemplate) userTpl := strings.TrimSpace(prompts.UserTemplate) if def, ok := aiprompts.DefaultFor(aiprompts.KeySEOMeta); ok { if sysTpl == "" { sysTpl = def.SystemTemplate } if userTpl == "" { userTpl = def.UserTemplate } } vars := aiprompts.Vars{ "name": name, "description": truncateRunes(desc, processing.MaxProductDescRunes), "category": strings.TrimSpace(p.Category), "brand": brand, "brand_voice": processing.CompactBrandPrompt(p.BrandPrompt), "language": company.LanguageLabel(p.Language), } system := strings.TrimSpace(aiprompts.Render(sysTpl, vars)) user := strings.TrimSpace(aiprompts.Render(userTpl, vars)) if user == "" { user = fmt.Sprintf("Name: %s\nCategory: %s\nDesc: %s", name, p.Category, truncateRunes(desc, processing.MaxProductDescRunes)) } comp, obj, cerr := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{ MaxTokens: processing.MaxTokensSEO, Temperature: processing.DefaultStructuredTemp, }) tokens = comp.TotalTokens if cerr != nil && obj == nil { if comp.Text == "" { return "", "", tokens, cerr } // Parse failed — fall back to template mt, md := FillMetaTemplate(p) return mt, md, tokens, nil } mt, md := metaFieldsFromObj(obj) if mt == "" || md == "" { t2, d2 := FillMetaTemplate(p) if mt == "" { mt = t2 } if md == "" { md = d2 } } return truncateRunes(mt, MetaTitleMaxChars), truncateRunes(md, MetaDescriptionMaxChars), tokens, nil } func metaFieldsFromObj(obj map[string]any) (title, desc string) { if obj == nil { return "", "" } title, _ = obj["meta_title"].(string) desc, _ = obj["meta_description"].(string) if title == "" { title, _ = obj["metaTitle"].(string) } if desc == "" { desc, _ = obj["metaDescription"].(string) } return strings.TrimSpace(title), strings.TrimSpace(desc) } func truncateRunes(s string, max int) string { s = collapseSpace(s) r := []rune(s) if max <= 0 || len(r) <= max { return s } if max <= 3 { return string(r[:max]) } return string(r[:max-1]) + "…" } func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") } func stripTags(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } return b.String() }