Files
descrybe/apps/api/internal/processing/categorize_ai.go
T

376 lines
12 KiB
Go
Raw Normal View History

2026-08-16 23:07:32 +02:00
package processing
import (
"context"
2026-08-23 23:14:57 +02:00
"encoding/json"
"errors"
"fmt"
2026-08-16 23:07:32 +02:00
"log"
"sort"
2026-08-23 23:14:57 +02:00
"strconv"
2026-08-16 23:07:32 +02:00
"strings"
2026-08-23 22:03:57 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
2026-08-16 23:07:32 +02:00
)
2026-08-23 23:14:57 +02:00
// ErrCategoryNotFound fails a product whose category could not be determined:
// the model's pick was outside the taxonomy, or it scored below the confidence
// floor. Processing stops for that product instead of enhancing it under a
// category nobody believes in.
var ErrCategoryNotFound = errors.New("product category could not be determined")
2026-08-16 23:07:32 +02:00
// MaxCategorizeOptions caps the taxonomy list injected into the categorize LLM
// prompt (token/cost bound). Prefer stable sort by display name then unique_id.
const MaxCategorizeOptions = 200
2026-08-23 23:14:57 +02:00
// DefaultMinCategorizeConfidence is the floor for accepting an LLM category pick.
//
// The model always answers with SOME id from the list, so a low-confidence reply
// like {"categoryId":"1","confidence":0.08} is the model saying "I do not know" —
// taking it at face value files a camping chair under Generators and then drives
// that category's title/description formula, producing confidently wrong copy.
// Below this floor the product fails with ErrCategoryNotFound instead.
const DefaultMinCategorizeConfidence = 0.75
2026-08-16 23:07:32 +02:00
// MaxTokensCategorize budgets a short JSON reply {"categoryId","confidence"}.
// code-fast / reasoning-style models often burn internal tokens before JSON;
// 256 frequently finishes with empty content and forces a length-cap retry.
const MaxTokensCategorize = 4096
// categoryOption is one company taxonomy row for the categorize prompt.
type categoryOption struct {
UniqueID string
Name string
}
// categoryOptionsFromNames builds a stable, capped list from unique_id → name.
// Empty map → no AI categorize (nothing valid to choose).
func categoryOptionsFromNames(namesByUID map[string]string) []categoryOption {
if len(namesByUID) == 0 {
return nil
}
out := make([]categoryOption, 0, len(namesByUID))
for uid, name := range namesByUID {
uid = strings.TrimSpace(uid)
if uid == "" || strings.EqualFold(uid, "none") {
continue
}
name = strings.TrimSpace(name)
if name == "" {
name = uid
}
out = append(out, categoryOption{UniqueID: uid, Name: name})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
}
return out[i].UniqueID < out[j].UniqueID
})
if len(out) > MaxCategorizeOptions {
out = out[:MaxCategorizeOptions]
}
return out
}
// categoryUniqueIDsList returns sorted unique_ids for vector SuggestCategory candidates.
func categoryUniqueIDsList(namesByUID map[string]string) []string {
opts := categoryOptionsFromNames(namesByUID)
if len(opts) == 0 {
return nil
}
out := make([]string, len(opts))
for i, o := range opts {
out[i] = o.UniqueID
}
return out
}
// formatAvailableCategoriesList mirrors legacy Descrybe "Available Categories:"
// bullets: "Name (ID: unique_id)".
func formatAvailableCategoriesList(opts []categoryOption) string {
if len(opts) == 0 {
return ""
}
var b strings.Builder
for _, o := range opts {
b.WriteString("- ")
b.WriteString(SanitizeText(o.Name))
b.WriteString(" (ID: ")
b.WriteString(SanitizeText(o.UniqueID))
b.WriteString(")\n")
}
return strings.TrimSpace(b.String())
}
2026-08-16 23:42:00 +02:00
// ProductCategorizeSystem is the built-in system prompt for taxonomy selection
// (aiprompts.RoleCategorize — pick company unique_id; separate from enhance overlay).
2026-08-16 23:07:32 +02:00
const ProductCategorizeSystem = `Product categorization expert.
Rules:
- Reply with ONLY a single JSON object (no markdown, no prose, no reasoning)
- Schema: {"categoryId":"string","confidence":0.0}
- categoryId MUST be one of the Available Categories IDs exactly (the value in parentheses after ID:)
- Never invent IDs or names; inventing IDs fails categorization
- Prefer the most specific category that matches the product
Example:
{"categoryId":"50","confidence":0.9}`
// ProductCategorizeUser builds the user prompt with product context + taxonomy list.
func ProductCategorizeUser(name, description string, opts []categoryOption) string {
var b strings.Builder
b.WriteString("Select the best category for this product from Available Categories only.\n\n")
b.WriteString("Product Information:\n")
b.WriteString("Name: ")
b.WriteString(SanitizeText(truncateRunes(name, 200)))
b.WriteString("\nDesc: ")
b.WriteString(SanitizeText(truncateRunes(description, MaxProductDescRunes)))
b.WriteString("\n\nAvailable Categories:\n")
b.WriteString(formatAvailableCategoriesList(opts))
b.WriteString("\n\nImportant:\n")
b.WriteString("- Return categoryId as the exact ID from the list\n")
b.WriteString("- Do not invent categories outside the list\n")
return b.String()
}
// tryAICategorize asks the Completer to pick a company taxonomy unique_id when
// Category is still empty after mapped/prior/vector. Never invents outside taxonomy:
// responses are coerced then filtered to namesByUID / valid unique_ids.
2026-08-23 23:14:57 +02:00
func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in ProductInput, policy StepPolicy) error {
2026-08-16 23:07:32 +02:00
if out == nil || strings.TrimSpace(out.Category) != "" {
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
if !policy.AllowAI {
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
if e == nil || !e.CompleterEnabled() {
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
opts := categoryOptionsFromNames(in.CategoryNamesByUID)
if len(opts) == 0 {
out.Notes = append(out.Notes, "ai_categorize: skipped (no company taxonomy)")
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "skipped",
"reason": "no_taxonomy",
})
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
name := strings.TrimSpace(out.Name)
if name == "" {
name = strings.TrimSpace(out.ProcessedName)
}
desc := strings.TrimSpace(out.Description)
if desc == "" {
desc = strings.TrimSpace(out.ProcessedDescription)
}
if name == "" && desc == "" {
out.Notes = append(out.Notes, "ai_categorize: skipped (empty product text)")
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "skipped",
"reason": "empty_product",
})
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
system := ProductCategorizeSystem
user := ProductCategorizeUser(name, desc, opts)
comp, obj, err := CompleteJSON(ctx, e.Completer, system, user, CompleteOptions{
MaxTokens: MaxTokensCategorize,
Temperature: DefaultStructuredTemp,
})
out.TotalTokens += comp.TotalTokens
if err != nil {
out.Notes = append(out.Notes, "ai_categorize: "+TruncateError(err))
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "failed",
"error": TruncateError(err),
"tokens": comp.TotalTokens,
})
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
raw := categoryIDFromCategorizeJSON(obj)
2026-08-23 23:14:57 +02:00
confidence, hasConfidence := categorizeConfidenceFromJSON(obj)
2026-08-16 23:07:32 +02:00
valid := make(map[string]struct{}, len(opts))
for _, o := range opts {
valid[o.UniqueID] = struct{}{}
}
resolved := resolveCompanyCategoryUniqueID(raw, in.CategoryNamesByUID, valid)
if resolved == "" || isUnusableCategoryValue(resolved, out.ProcessedName, out.Name) {
2026-08-23 23:14:57 +02:00
out.Notes = append(out.Notes, "ai_categorize: no category found (not in company taxonomy)")
2026-08-16 23:07:32 +02:00
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "rejected",
"returned": raw,
"tokens": comp.TotalTokens,
})
2026-08-23 23:14:57 +02:00
return fmt.Errorf("%w: model returned %q, which is not in the company taxonomy",
ErrCategoryNotFound, truncateRunes(raw, 60))
}
// A confident-looking id with a low score is the model guessing. Refuse it
// rather than driving the wrong category's formula with it.
if min := policy.CategorizeConfidence(); hasConfidence && confidence < min {
out.Notes = append(out.Notes, fmt.Sprintf(
"ai_categorize: no category found (confidence %.2f below %.2f)", confidence, min))
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "low_confidence",
"returned": raw,
"resolved": resolved,
"confidence": confidence,
"minimum": min,
"tokens": comp.TotalTokens,
})
log.Printf("processing: category rejected uid=%s confidence=%.2f min=%.2f reason=low_confidence",
resolved, confidence, min)
return fmt.Errorf("%w: best match %q scored %.2f, below the %.2f minimum",
ErrCategoryNotFound, truncateRunes(resolved, 60), confidence, min)
2026-08-16 23:07:32 +02:00
}
out.Category = SanitizeText(resolved)
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "llm"
syncCategoryName(out, in.CategoryNamesByUID)
out.Notes = append(out.Notes, "category: llm")
log.Printf("processing: category choice uid=%s name=%s source=llm", out.Category, out.CategoryName)
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "ok",
"category": out.Category,
"name": out.CategoryName,
"source": "llm",
"tokens": comp.TotalTokens,
"options": len(opts),
})
2026-08-23 23:14:57 +02:00
if hasConfidence {
out.Notes = append(out.Notes, fmt.Sprintf("ai_categorize: confidence %.2f", confidence))
}
return nil
}
// categorizeConfidenceFromJSON reads the model's self-reported score. ok=false when
// the field is missing or unparseable — a model that reports nothing is not
// penalised, only one that reports a low score.
func categorizeConfidenceFromJSON(obj map[string]any) (float64, bool) {
if obj == nil {
return 0, false
}
for _, k := range []string{"confidence", "score", "certainty"} {
switch v := obj[k].(type) {
case float64:
return clampConfidence(v), true
case int:
return clampConfidence(float64(v)), true
case json.Number:
if f, err := v.Float64(); err == nil {
return clampConfidence(f), true
}
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return clampConfidence(f), true
}
}
}
return 0, false
}
// clampConfidence folds a 0-100 style score onto 0-1 and bounds it.
func clampConfidence(v float64) float64 {
if v > 1 && v <= 100 {
v /= 100
}
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
2026-08-16 23:07:32 +02:00
}
func categoryIDFromCategorizeJSON(obj map[string]any) string {
if obj == nil {
return ""
}
for _, k := range []string{"categoryId", "category_id", "categoryUniqueId", "category_unique_id", "unique_id", "id", "category"} {
if s := categoryUniqueIDFromAny(obj[k]); s != "" {
return s
}
}
return ""
}
// firstAvailableCategoryIDFromPrompt extracts the first "(ID: …)" token from a
// categorize user prompt (HeuristicCompleter / weak-model fallback).
func firstAvailableCategoryIDFromPrompt(user string) string {
const marker = "(id:"
lower := strings.ToLower(user)
at := strings.Index(lower, marker)
if at < 0 {
return ""
}
rest := strings.TrimSpace(user[at+len(marker):])
end := strings.Index(rest, ")")
if end <= 0 {
return ""
}
return strings.TrimSpace(rest[:end])
}
// runCategorizeStep applies vector then LLM taxonomy selection when Category empty.
2026-08-23 23:14:57 +02:00
// runCategorizeStep returns ErrCategoryNotFound when the model was asked for a
// category and could not give a usable one — the caller fails the product rather
// than enhancing it under a guess.
func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *StepResult, in ProductInput, categoryNames []string, policy StepPolicy) error {
2026-08-16 23:07:32 +02:00
if out == nil {
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
if strings.TrimSpace(out.Category) != "" {
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "skipped",
"reason": "already_set",
"category": out.Category,
})
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
if !policy.AllowAI {
out.Notes = append(out.Notes, "categorize: skipped (AI not allowed)")
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "skipped",
"reason": "entitlement_can_use_ai",
})
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
tryVectorCategorize(ctx, e, companyID, out, categoryNames, policy)
if strings.TrimSpace(out.Category) != "" {
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
2026-08-23 22:03:57 +02:00
// Taxonomy picks and copy generation share one Completer; tag the role so the
// admin inspector can tell them apart.
2026-08-23 23:14:57 +02:00
if err := tryAICategorize(aiaudit.WithCall(ctx, aiaudit.CallContext{Role: aiaudit.RoleCategorize}), e, out, in, policy); err != nil {
return err
}
2026-08-16 23:07:32 +02:00
if strings.TrimSpace(out.Category) == "" {
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
"status": "unset",
"reason": "no_vector_or_ai_match",
})
}
2026-08-23 23:14:57 +02:00
return nil
2026-08-16 23:07:32 +02:00
}
// persistMappedCategorySQL writes a taxonomy unique_id onto mapped_data.category
// when the mapped value is still empty (so Products UI + reprocess stick).
const persistMappedCategorySQL = `
UPDATE raw_products
SET mapped_data = jsonb_set(
COALESCE(mapped_data, '{}'::jsonb),
'{category}',
to_jsonb($3::text),
true
),
updated_at = now()
WHERE id = $1 AND company_id = $2
AND COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') = ''
AND COALESCE(NULLIF(trim($3), ''), '') <> ''
AND lower(trim($3)) <> 'none'`