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,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
|
||||
}
|
||||
Reference in New Issue
Block a user