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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user