Files
descrybe/apps/api/internal/seo/templates.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

160 lines
4.4 KiB
Go

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()
}