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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
package seo
import (
"fmt"
"strings"
"unicode"
)
// ProductInput is a catalog product snapshot for analysis.
type ProductInput struct {
ID string
ProductID string
Name string
ProcessedName string
Description string
ProcessedDesc string
MetaTitle string
MetaDescription string
Category string
Attributes map[string]any
ProcessedAttrs map[string]any
MappedData map[string]any
// BrandPrompt is optional brand-kit injection for AI meta (paid path).
BrandPrompt string
// Language is companies.language; injected as {{language}} (English label).
Language string
}
// CategoryInput is a category snapshot for analysis.
type CategoryInput struct {
ID string
UniqueID string
Name string
DescriptionTemplate map[string]any
}
const (
maxPeerIDsPerDup = 20
maxDupRecsPerTitle = 50
)
type titlePeer struct {
ID string
Label string
}
// Analyze builds recommendations from product + category snapshots (pure; no I/O).
func Analyze(products []ProductInput, categories []CategoryInput) []Recommendation {
out := make([]Recommendation, 0, len(products)*2+len(categories))
titleCounts := map[string][]titlePeer{}
titleTotals := map[string]int{}
for _, p := range products {
title := displayTitle(p)
key := normalizeTitleKey(title)
label := entityLabel(p)
if key != "" {
titleTotals[key]++
keep := maxDupRecsPerTitle
if maxPeerIDsPerDup > keep {
keep = maxPeerIDsPerDup
}
if len(titleCounts[key]) < keep {
titleCounts[key] = append(titleCounts[key], titlePeer{ID: p.ID, Label: label})
}
}
if strings.TrimSpace(p.MetaTitle) == "" {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingMetaTitle, p.ID),
Type: TypeMissingMetaTitle,
Severity: SeverityCritical,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing meta title",
Message: "Add a SERP title (≈5060 characters) so search results show a clear product name.",
Fixable: true,
FixModes: []string{ApplyModeTemplate, ApplyModeAI},
})
}
if strings.TrimSpace(p.MetaDescription) == "" {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingMetaDescription, p.ID),
Type: TypeMissingMetaDescription,
Severity: SeverityCritical,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing meta description",
Message: "Add a meta description (≈120155 characters) summarizing benefits and specs.",
Fixable: true,
FixModes: []string{ApplyModeTemplate, ApplyModeAI},
})
}
if title != "" && runeLen(title) <= ThinTitleMaxChars {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeThinTitle, p.ID),
Type: TypeThinTitle,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Thin product title",
Message: fmt.Sprintf("Title is only %d characters — expand with brand, model, or key attribute.", runeLen(title)),
Fixable: false,
Meta: map[string]any{"title_length": runeLen(title)},
})
}
if !hasImage(p) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeMissingImage, p.ID),
Type: TypeMissingImage,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Missing product image",
Message: "No primary image URL found in mapped/attribute fields — add an image for richer listings.",
Fixable: false,
})
}
if isWeakKeywords(p) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeWeakKeywords, p.ID),
Type: TypeWeakKeywords,
Severity: SeverityInfo,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: label,
Title: "Weak keywords",
Message: "Title/description look generic — include brand, category, or distinctive product terms.",
Fixable: true,
FixModes: []string{ApplyModeAI},
})
}
}
for key, group := range titleCounts {
total := titleTotals[key]
if total < 2 {
continue
}
peerN := len(group)
if peerN > maxPeerIDsPerDup {
peerN = maxPeerIDsPerDup
}
ids := make([]string, 0, peerN)
for i := 0; i < peerN; i++ {
ids = append(ids, group[i].ID)
}
emit := len(group)
if emit > maxDupRecsPerTitle {
emit = maxDupRecsPerTitle
}
for i := 0; i < emit; i++ {
p := group[i]
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s:%s", TypeDuplicateTitle, key, p.ID),
Type: TypeDuplicateTitle,
Severity: SeverityWarn,
EntityType: EntityProduct,
EntityID: p.ID,
EntityLabel: p.Label,
Title: "Duplicate title",
Message: fmt.Sprintf("%d products share this title — differentiate for unique search snippets.", total),
Fixable: false,
Meta: map[string]any{
"duplicate_count": total,
"peer_ids": ids,
"title_key": key,
},
})
}
}
for _, c := range categories {
label := c.Name
if label == "" {
label = c.UniqueID
}
if !categoryHasMetaFormula(c.DescriptionTemplate) {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeCategoryMissingMeta, c.ID),
Type: TypeCategoryMissingMeta,
Severity: SeverityWarn,
EntityType: EntityCategory,
EntityID: c.ID,
EntityLabel: label,
Title: "Category missing meta formulas",
Message: "Set meta title/description formulas on this categorys description template.",
Fixable: false,
Meta: map[string]any{"unique_id": c.UniqueID},
})
}
name := strings.TrimSpace(c.Name)
if name != "" && runeLen(name) <= ThinCategoryNameMaxChars {
out = append(out, Recommendation{
ID: fmt.Sprintf("%s:%s", TypeThinCategoryName, c.ID),
Type: TypeThinCategoryName,
Severity: SeverityInfo,
EntityType: EntityCategory,
EntityID: c.ID,
EntityLabel: label,
Title: "Thin category name",
Message: "Category name is very short — use a clearer label for breadcrumbs and filters.",
Fixable: false,
})
}
}
return out
}
// BuildChecklist aggregates recommendations into scored checklist rows + overall score.
func BuildChecklist(recs []Recommendation, productCount, categoryCount int) (checklist []ChecklistItem, overall float64) {
type meta struct {
label, severity, desc string
fixable bool
denomKind string // products | categories
}
defs := []struct {
typ string
meta meta
}{
{TypeMissingMetaTitle, meta{"Missing meta titles", SeverityCritical, "Products without meta_title", true, "products"}},
{TypeMissingMetaDescription, meta{"Missing meta descriptions", SeverityCritical, "Products without meta_description", true, "products"}},
{TypeThinTitle, meta{"Thin titles", SeverityWarn, "Product titles that are too short", false, "products"}},
{TypeDuplicateTitle, meta{"Duplicate titles", SeverityWarn, "Products sharing the same title", false, "products"}},
{TypeMissingImage, meta{"Missing images", SeverityWarn, "Products without a primary image", false, "products"}},
{TypeWeakKeywords, meta{"Weak keywords", SeverityInfo, "Generic titles/descriptions lacking distinctive terms", true, "products"}},
{TypeCategoryMissingMeta, meta{"Category meta formulas", SeverityWarn, "Categories without meta title/description formulas", false, "categories"}},
{TypeThinCategoryName, meta{"Thin category names", SeverityInfo, "Very short category names", false, "categories"}},
}
byType := map[string][]Recommendation{}
for _, r := range recs {
byType[r.Type] = append(byType[r.Type], r)
}
checklist = make([]ChecklistItem, 0, len(defs))
var scoreSum float64
var scoreN int
for _, d := range defs {
group := byType[d.typ]
affected := uniqueEntities(group)
denom := productCount
if d.meta.denomKind == "categories" {
denom = categoryCount
}
score := 100.0
if denom > 0 {
score = 100.0 * (1.0 - float64(affected)/float64(denom))
if score < 0 {
score = 0
}
} else if len(group) == 0 {
score = 100.0
} else {
score = 0
}
checklist = append(checklist, ChecklistItem{
Type: d.typ,
Label: d.meta.label,
Severity: d.meta.severity,
Count: len(group),
Affected: affected,
Score: round1(score),
Fixable: d.meta.fixable,
Description: d.meta.desc,
})
scoreSum += score
scoreN++
}
if scoreN > 0 {
overall = round1(scoreSum / float64(scoreN))
}
return checklist, overall
}
func uniqueEntities(recs []Recommendation) int {
seen := map[string]struct{}{}
for _, r := range recs {
seen[r.EntityType+":"+r.EntityID] = struct{}{}
}
return len(seen)
}
func displayTitle(p ProductInput) string {
for _, s := range []string{p.ProcessedName, p.Name} {
if t := strings.TrimSpace(s); t != "" {
return t
}
}
return ""
}
func entityLabel(p ProductInput) string {
if t := displayTitle(p); t != "" {
return t
}
if p.ProductID != "" {
return p.ProductID
}
return p.ID
}
func normalizeTitleKey(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ""
}
var b strings.Builder
prevSpace := false
for _, r := range s {
if unicode.IsSpace(r) {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
prevSpace = false
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
}
}
return strings.TrimSpace(b.String())
}
func hasImage(p ProductInput) bool {
keys := []string{
"image_url", "image", "main_image", "mainImage", "main_image_url",
"image_link", "imageLink", "thumbnail", "photo", "media_url",
}
bags := []map[string]any{p.ProcessedAttrs, p.Attributes, p.MappedData}
for _, bag := range bags {
if bag == nil {
continue
}
for _, k := range keys {
if v, ok := bag[k]; ok && nonEmptyStringish(v) {
return true
}
}
// Case-insensitive scan
for bk, v := range bag {
nk := strings.ToLower(strings.ReplaceAll(bk, "-", ""))
nk = strings.ReplaceAll(nk, "_", "")
if (nk == "imageurl" || nk == "mainimage" || nk == "mainimageurl" ||
nk == "imagelink" || nk == "thumbnail" || nk == "image") && nonEmptyStringish(v) {
return true
}
}
}
return false
}
func nonEmptyStringish(v any) bool {
switch t := v.(type) {
case string:
return strings.TrimSpace(t) != ""
case []any:
return len(t) > 0
case map[string]any:
return len(t) > 0
default:
return false
}
}
func categoryHasMetaFormula(tpl map[string]any) bool {
if tpl == nil {
return false
}
mt, _ := tpl["metaTitle"].(string)
md, _ := tpl["metaDescription"].(string)
// Also accept snake_case
if mt == "" {
mt, _ = tpl["meta_title"].(string)
}
if md == "" {
md, _ = tpl["meta_description"].(string)
}
return strings.TrimSpace(mt) != "" && strings.TrimSpace(md) != ""
}
// isWeakKeywords: short/generic title and description without brand/category cues.
func isWeakKeywords(p ProductInput) bool {
title := displayTitle(p)
desc := strings.TrimSpace(p.ProcessedDesc)
if desc == "" {
desc = strings.TrimSpace(p.Description)
}
metaT := strings.TrimSpace(p.MetaTitle)
metaD := strings.TrimSpace(p.MetaDescription)
corpus := strings.ToLower(strings.Join([]string{title, desc, metaT, metaD}, " "))
if strings.TrimSpace(corpus) == "" {
return true
}
// Distinctive if category or brand appears in title/meta.
cat := strings.ToLower(strings.TrimSpace(p.Category))
if cat != "" && len(cat) > 2 && strings.Contains(strings.ToLower(title+" "+metaT), cat) {
return false
}
brand := firstString(p.ProcessedAttrs, p.Attributes, p.MappedData, "brand", "Brand", "manufacturer", "Manufacturer")
if brand != "" && len(brand) > 1 && strings.Contains(strings.ToLower(title+" "+metaT), strings.ToLower(brand)) {
return false
}
// Weak if title is mostly stopwords / very short content overall.
words := tokenize(corpus)
if len(words) < 4 {
return true
}
meaningful := 0
for _, w := range words {
if !stopWord(w) && len(w) > 2 {
meaningful++
}
}
return meaningful < 3
}
func firstString(bags ...any) string {
var maps []map[string]any
var keys []string
for _, b := range bags {
switch t := b.(type) {
case map[string]any:
maps = append(maps, t)
case string:
keys = append(keys, t)
}
}
for _, m := range maps {
if m == nil {
continue
}
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
}
return ""
}
func tokenize(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.ToLower(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func stopWord(w string) bool {
switch w {
case "a", "an", "the", "and", "or", "for", "of", "to", "in", "on", "with",
"product", "item", "new", "best", "buy", "sale", "pack", "set":
return true
default:
return false
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func round1(v float64) float64 {
return float64(int(v*10+0.5)) / 10
}
+160
View File
@@ -0,0 +1,160 @@
package seo
import (
"fmt"
"testing"
)
func TestAnalyze_missingMetaAndImage(t *testing.T) {
products := []ProductInput{
{
ID: "p1",
Name: "Acme UltraWidget Pro 3000 Stainless",
Category: "Widgets",
ProcessedAttrs: map[string]any{"brand": "Acme"},
},
}
recs := Analyze(products, nil)
types := map[string]bool{}
for _, r := range recs {
types[r.Type] = true
if r.EntityID != "p1" && r.Type != TypeDuplicateTitle {
t.Fatalf("unexpected entity %s", r.EntityID)
}
}
for _, want := range []string{TypeMissingMetaTitle, TypeMissingMetaDescription, TypeMissingImage} {
if !types[want] {
t.Fatalf("expected type %s", want)
}
}
}
func TestAnalyze_duplicateAndThin(t *testing.T) {
products := []ProductInput{
{ID: "a", Name: "Short", MetaTitle: "x", MetaDescription: "y"},
{ID: "b", Name: "Short", MetaTitle: "x", MetaDescription: "y"},
}
recs := Analyze(products, nil)
var thin, dup int
for _, r := range recs {
switch r.Type {
case TypeThinTitle:
thin++
case TypeDuplicateTitle:
dup++
}
}
if thin < 2 {
t.Fatalf("expected thin titles, got %d", thin)
}
if dup < 2 {
t.Fatalf("expected duplicate titles, got %d", dup)
}
}
func TestAnalyze_duplicatePeerAndRecCaps(t *testing.T) {
n := maxDupRecsPerTitle + maxPeerIDsPerDup + 10
products := make([]ProductInput, 0, n)
for i := 0; i < n; i++ {
products = append(products, ProductInput{
ID: fmt.Sprintf("p%d", i),
Name: "Shared Title Product",
MetaTitle: "mt",
MetaDescription: "md",
})
}
recs := Analyze(products, nil)
var dup int
var peerLen int
for _, r := range recs {
if r.Type != TypeDuplicateTitle {
continue
}
dup++
peers, _ := r.Meta["peer_ids"].([]string)
if peerLen == 0 {
peerLen = len(peers)
}
if len(peers) > maxPeerIDsPerDup {
t.Fatalf("peer_ids len=%d want <=%d", len(peers), maxPeerIDsPerDup)
}
count, _ := r.Meta["duplicate_count"].(int)
if count != n {
t.Fatalf("duplicate_count=%d want %d", count, n)
}
}
if dup != maxDupRecsPerTitle {
t.Fatalf("duplicate recs=%d want %d", dup, maxDupRecsPerTitle)
}
if peerLen != maxPeerIDsPerDup {
t.Fatalf("peer_ids len=%d want %d", peerLen, maxPeerIDsPerDup)
}
}
func TestAnalyze_categoryMeta(t *testing.T) {
cats := []CategoryInput{
{ID: "c1", UniqueID: "cat-1", Name: "AB", DescriptionTemplate: map[string]any{}},
{ID: "c2", UniqueID: "cat-2", Name: "Appliances", DescriptionTemplate: map[string]any{
"metaTitle": "t", "metaDescription": "d",
}},
}
recs := Analyze(nil, cats)
var missing, thin int
for _, r := range recs {
if r.Type == TypeCategoryMissingMeta {
missing++
if r.EntityID != "c1" {
t.Fatalf("wrong category for missing meta: %s", r.EntityID)
}
}
if r.Type == TypeThinCategoryName {
thin++
}
}
if missing != 1 {
t.Fatalf("expected 1 missing meta formula, got %d", missing)
}
if thin != 1 {
t.Fatalf("expected 1 thin category name, got %d", thin)
}
}
func TestFillMetaTemplate(t *testing.T) {
title, desc := FillMetaTemplate(ProductInput{
Name: "Drill 18V",
Category: "Tools",
Description: "Cordless drill with battery pack for DIY projects.",
Attributes: map[string]any{"brand": "Bosch"},
})
if title == "" || desc == "" {
t.Fatal("expected non-empty meta")
}
if runeLen(title) > MetaTitleMaxChars {
t.Fatalf("title too long: %d", runeLen(title))
}
if runeLen(desc) > MetaDescriptionMaxChars {
t.Fatalf("desc too long: %d", runeLen(desc))
}
}
func TestBuildChecklist_score(t *testing.T) {
products := []ProductInput{
{ID: "1", Name: "Good Product Title Here", MetaTitle: "mt", MetaDescription: "md",
MappedData: map[string]any{"image_url": "https://example.com/a.jpg"}, Category: "Good Product Title Here"},
}
recs := Analyze(products, nil)
checklist, overall := BuildChecklist(recs, 1, 0)
if overall <= 0 {
t.Fatalf("expected positive overall, got %v", overall)
}
if len(checklist) != len(AllTypes()) {
t.Fatalf("checklist len %d want %d", len(checklist), len(AllTypes()))
}
}
func TestAllTypesStable(t *testing.T) {
types := AllTypes()
if len(types) != 8 {
t.Fatalf("want 8 types, got %d", len(types))
}
}
+21
View File
@@ -0,0 +1,21 @@
package seo
import "errors"
// Sentinel errors for SEO apply / lookups.
var (
ErrNotFound = errors.New("not found")
ErrInvalidMode = errors.New("mode must be template or ai")
)
// ClientError reports whether err is a known client-facing SEO validation error.
func ClientError(err error) (msg string, ok bool) {
switch {
case err == nil:
return "", false
case errors.Is(err, ErrInvalidMode):
return err.Error(), true
default:
return "", false
}
}
+311
View File
@@ -0,0 +1,311 @@
package seo
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
maxProductsScan = 2000
maxCategoriesScan = 500
maxRecsReturn = 200
)
// Service loads catalog rows and applies SEO fixes.
type Service struct {
Pool *pgxpool.Pool
Billing *billing.Service
Completer processing.Completer
// AI optional: resolves company BYOK before falling back to Completer.
AI *aiprovider.Service
// Prompts optional: company-editable SEO system/user templates.
Prompts *aiprompts.Service
}
// Recommendations returns a company-scoped SEO report.
func (s *Service) Recommendations(ctx context.Context, companyID uuid.UUID) (Report, error) {
products, err := s.loadProducts(ctx, companyID)
if err != nil {
return Report{}, err
}
categories, err := s.loadCategories(ctx, companyID)
if err != nil {
return Report{}, err
}
recs := Analyze(products, categories)
checklist, overall := BuildChecklist(recs, len(products), len(categories))
// Cap payload size but keep checklist accurate from full analyze.
limited := recs
if len(limited) > maxRecsReturn {
limited = prioritizeRecs(recs, maxRecsReturn)
}
canAI := false
if s.Billing != nil {
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
if err == nil {
canAI = ent.CanUseAI
}
}
return Report{
CompanyID: companyID.String(),
ProductCount: len(products),
CategoryCount: len(categories),
OverallScore: overall,
CanUseAI: canAI,
Checklist: checklist,
Recommendations: limited,
Types: AllTypes(),
}, nil
}
// Apply fills meta for one product (template = free; ai = paid/credits).
func (s *Service) Apply(ctx context.Context, companyID uuid.UUID, productID uuid.UUID, mode string) (ApplyResult, error) {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "" {
mode = ApplyModeTemplate
}
if mode != ApplyModeTemplate && mode != ApplyModeAI {
return ApplyResult{}, ErrInvalidMode
}
p, err := s.loadOneProduct(ctx, companyID, productID)
if err != nil {
return ApplyResult{}, err
}
var metaTitle, metaDesc string
credits := 0
tokens := 0
switch mode {
case ApplyModeTemplate:
metaTitle, metaDesc = FillMetaTemplate(p)
case ApplyModeAI:
if s.Billing == nil {
return ApplyResult{}, billing.ErrAIRequiresUpgrade
}
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.seo_ai_rewrite", "marketing.seo.ai_rewrite"); err != nil {
return ApplyResult{}, err
}
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
if err != nil {
return ApplyResult{}, err
}
if !ent.CanUseAI {
return ApplyResult{}, billing.ErrAIRequiresUpgrade
}
if ent.RemainingCredits < 1 {
return ApplyResult{}, billing.ErrInsufficientCredits
}
var completer processing.Completer
if s.AI != nil {
c, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
if rerr != nil {
return ApplyResult{}, rerr
}
completer = c
} else {
completer = s.Completer
}
if completer == nil {
completer = processing.HeuristicCompleter{}
}
if brand, berr := company.LoadBrand(ctx, s.Pool, companyID); berr == nil {
p.BrandPrompt = brand.PromptBlock()
}
p.Language = company.LoadLanguage(ctx, s.Pool, companyID)
var prompts aiprompts.Resolved
if s.Prompts != nil {
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeySEOMeta, p.Language); perr == nil {
prompts = resolved
}
}
mt, md, tok, err := FillMetaAI(ctx, completer, p, prompts)
if err != nil {
return ApplyResult{}, err
}
metaTitle, metaDesc, tokens = mt, md, tok
if err := s.Billing.ConsumeCredits(ctx, companyID, tokens, "seo_meta_ai"); err != nil {
return ApplyResult{}, err
}
credits = 1
if tokens > 0 {
credits += (tokens + 999) / 1000
}
}
ct, err := s.Pool.Exec(ctx, `
UPDATE processed_products
SET meta_title = $3, meta_description = $4, updated_at = now()
WHERE id = $1 AND company_id = $2`,
productID, companyID, metaTitle, metaDesc)
if err != nil {
return ApplyResult{}, err
}
if ct.RowsAffected() == 0 {
return ApplyResult{}, ErrNotFound
}
return ApplyResult{
ProductID: productID.String(),
Mode: mode,
MetaTitle: metaTitle,
MetaDescription: metaDesc,
CreditsCharged: credits,
}, nil
}
func (s *Service) loadProducts(ctx context.Context, companyID uuid.UUID) ([]ProductInput, error) {
rows, err := s.Pool.Query(ctx, `
SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''),
COALESCE(p.description, ''), COALESCE(p.processed_description, ''),
COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''),
COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb),
COALESCE(r.mapped_data, '{}'::jsonb)
FROM processed_products p
LEFT JOIN raw_products r ON r.id = p.raw_product_id
WHERE p.company_id = $1
ORDER BY p.updated_at DESC
LIMIT $2`, companyID, maxProductsScan)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ProductInput, 0)
for rows.Next() {
var p ProductInput
var attrs, procAttrs, mapped []byte
if err := rows.Scan(
&p.ID, &p.ProductID, &p.Name, &p.ProcessedName,
&p.Description, &p.ProcessedDesc,
&p.MetaTitle, &p.MetaDescription, &p.Category,
&attrs, &procAttrs, &mapped,
); err != nil {
return nil, err
}
p.Attributes = decodeMap(attrs)
p.ProcessedAttrs = decodeMap(procAttrs)
p.MappedData = decodeMap(mapped)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Service) loadOneProduct(ctx context.Context, companyID, id uuid.UUID) (ProductInput, error) {
var p ProductInput
var attrs, procAttrs, mapped []byte
err := s.Pool.QueryRow(ctx, `
SELECT p.id::text, COALESCE(p.product_id, ''), COALESCE(p.name, ''), COALESCE(p.processed_name, ''),
COALESCE(p.description, ''), COALESCE(p.processed_description, ''),
COALESCE(p.meta_title, ''), COALESCE(p.meta_description, ''), COALESCE(p.category, ''),
COALESCE(p.attributes, '{}'::jsonb), COALESCE(p.processed_attributes, '{}'::jsonb),
COALESCE(r.mapped_data, '{}'::jsonb)
FROM processed_products p
LEFT JOIN raw_products r ON r.id = p.raw_product_id
WHERE p.id = $1 AND p.company_id = $2`, id, companyID).Scan(
&p.ID, &p.ProductID, &p.Name, &p.ProcessedName,
&p.Description, &p.ProcessedDesc,
&p.MetaTitle, &p.MetaDescription, &p.Category,
&attrs, &procAttrs, &mapped,
)
if errors.Is(err, pgx.ErrNoRows) {
return ProductInput{}, ErrNotFound
}
if err != nil {
return ProductInput{}, err
}
p.Attributes = decodeMap(attrs)
p.ProcessedAttrs = decodeMap(procAttrs)
p.MappedData = decodeMap(mapped)
return p, nil
}
func (s *Service) loadCategories(ctx context.Context, companyID uuid.UUID) ([]CategoryInput, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id::text, COALESCE(unique_id, ''), COALESCE(name, ''), COALESCE(description_template, '{}'::jsonb)
FROM categories
WHERE company_id = $1
ORDER BY name
LIMIT $2`, companyID, maxCategoriesScan)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]CategoryInput, 0)
for rows.Next() {
var c CategoryInput
var tplBytes []byte
if err := rows.Scan(&c.ID, &c.UniqueID, &c.Name, &tplBytes); err != nil {
return nil, err
}
if len(tplBytes) > 0 {
_ = json.Unmarshal(tplBytes, &c.DescriptionTemplate)
}
out = append(out, c)
}
return out, rows.Err()
}
func decodeMap(b []byte) map[string]any {
if len(b) == 0 {
return map[string]any{}
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil || m == nil {
return map[string]any{}
}
return m
}
func prioritizeRecs(recs []Recommendation, limit int) []Recommendation {
severityRank := map[string]int{
SeverityCritical: 0,
SeverityWarn: 1,
SeverityInfo: 2,
}
// Stable partition by severity without full sort alloc if small.
buckets := [3][]Recommendation{}
for _, r := range recs {
i := severityRank[r.Severity]
if i < 0 || i > 2 {
i = 2
}
buckets[i] = append(buckets[i], r)
}
out := make([]Recommendation, 0, limit)
for _, b := range buckets {
for _, r := range b {
if len(out) >= limit {
return out
}
out = append(out, r)
}
}
return out
}
// EnsureCost seeds seo_meta_ai processing cost (idempotent).
func EnsureCost(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, `
INSERT INTO processing_costs (feature_name, cost_per_unit, description, is_active)
VALUES ('seo_meta_ai', 1, 'Credits per SEO AI meta fill', true)
ON CONFLICT (feature_name) DO NOTHING`)
return err
}
+159
View File
@@ -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()
}
+112
View File
@@ -0,0 +1,112 @@
package seo
// Recommendation type identifiers (stable API contract).
const (
TypeMissingMetaTitle = "missing_meta_title"
TypeMissingMetaDescription = "missing_meta_description"
TypeThinTitle = "thin_title"
TypeDuplicateTitle = "duplicate_title"
TypeMissingImage = "missing_image"
TypeWeakKeywords = "weak_keywords"
TypeCategoryMissingMeta = "category_missing_meta_formula"
TypeThinCategoryName = "thin_category_name"
)
// Severity levels for checklist ordering.
const (
SeverityCritical = "critical"
SeverityWarn = "warn"
SeverityInfo = "info"
)
// Entity kinds.
const (
EntityProduct = "product"
EntityCategory = "category"
)
// ApplyModeTemplate fills meta from product fields (free).
const ApplyModeTemplate = "template"
// ApplyModeAI rewrites meta with LLM (paid / credits).
const ApplyModeAI = "ai"
// ThinTitleMaxChars — titles at or below this length are "thin".
const ThinTitleMaxChars = 20
// ThinCategoryNameMaxChars — category names at or below this are thin.
const ThinCategoryNameMaxChars = 2
// MetaTitleMaxChars / MetaDescriptionMaxChars — SERP-friendly caps for template fill.
const (
MetaTitleMaxChars = 60
MetaDescriptionMaxChars = 155
)
// Recommendation is one actionable SEO finding.
type Recommendation struct {
ID string `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
EntityLabel string `json:"entity_label"`
Title string `json:"title"`
Message string `json:"message"`
Fixable bool `json:"fixable"`
FixModes []string `json:"fix_modes,omitempty"` // template | ai
Meta map[string]any `json:"meta,omitempty"`
}
// ChecklistItem aggregates score for one recommendation type.
type ChecklistItem struct {
Type string `json:"type"`
Label string `json:"label"`
Severity string `json:"severity"`
Count int `json:"count"`
Affected int `json:"affected"` // unique entities
Score float64 `json:"score"` // 0100 (100 = none of this issue)
Fixable bool `json:"fixable"`
Description string `json:"description"`
}
// Report is the GET /api/seo/recommendations payload.
type Report struct {
CompanyID string `json:"company_id"`
ProductCount int `json:"product_count"`
CategoryCount int `json:"category_count"`
OverallScore float64 `json:"overall_score"`
CanUseAI bool `json:"can_use_ai"`
Checklist []ChecklistItem `json:"checklist"`
Recommendations []Recommendation `json:"recommendations"`
Types []string `json:"types"`
}
// ApplyRequest is POST /api/seo/apply body.
type ApplyRequest struct {
ProductID string `json:"product_id"`
Mode string `json:"mode"` // template | ai
}
// ApplyResult is the apply response.
type ApplyResult struct {
ProductID string `json:"product_id"`
Mode string `json:"mode"`
MetaTitle string `json:"meta_title"`
MetaDescription string `json:"meta_description"`
CreditsCharged int `json:"credits_charged,omitempty"`
}
// AllTypes returns stable recommendation type keys (API contract).
func AllTypes() []string {
return []string{
TypeMissingMetaTitle,
TypeMissingMetaDescription,
TypeThinTitle,
TypeDuplicateTitle,
TypeMissingImage,
TypeWeakKeywords,
TypeCategoryMissingMeta,
TypeThinCategoryName,
}
}