2026-08-09 22:47:43 +02:00
|
|
|
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 == "" {
|
2026-08-16 23:42:00 +02:00
|
|
|
user = fmt.Sprintf("Name: %s\nCategory: %s\nDescription: %s",
|
2026-08-09 22:47:43 +02:00
|
|
|
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()
|
|
|
|
|
}
|