fix
This commit is contained in:
@@ -2,17 +2,36 @@ package processing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
@@ -116,15 +135,15 @@ func ProductCategorizeUser(name, description string, opts []categoryOption) stri
|
||||
// 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.
|
||||
func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in ProductInput, policy StepPolicy) {
|
||||
func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in ProductInput, policy StepPolicy) error {
|
||||
if out == nil || strings.TrimSpace(out.Category) != "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if !policy.AllowAI {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if e == nil || !e.CompleterEnabled() {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
opts := categoryOptionsFromNames(in.CategoryNamesByUID)
|
||||
if len(opts) == 0 {
|
||||
@@ -133,7 +152,7 @@ func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in Product
|
||||
"status": "skipped",
|
||||
"reason": "no_taxonomy",
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
@@ -149,7 +168,7 @@ func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in Product
|
||||
"status": "skipped",
|
||||
"reason": "empty_product",
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
system := ProductCategorizeSystem
|
||||
@@ -166,23 +185,44 @@ func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in Product
|
||||
"error": TruncateError(err),
|
||||
"tokens": comp.TotalTokens,
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
raw := categoryIDFromCategorizeJSON(obj)
|
||||
confidence, hasConfidence := categorizeConfidenceFromJSON(obj)
|
||||
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) {
|
||||
out.Notes = append(out.Notes, "ai_categorize: ignored (not in company taxonomy)")
|
||||
out.Notes = append(out.Notes, "ai_categorize: no category found (not in company taxonomy)")
|
||||
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
|
||||
"status": "rejected",
|
||||
"returned": raw,
|
||||
"tokens": comp.TotalTokens,
|
||||
})
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
||||
out.Category = SanitizeText(resolved)
|
||||
@@ -201,6 +241,50 @@ func tryAICategorize(ctx context.Context, e *Engine, out *StepResult, in Product
|
||||
"tokens": comp.TotalTokens,
|
||||
"options": len(opts),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
func categoryIDFromCategorizeJSON(obj map[string]any) string {
|
||||
@@ -233,9 +317,12 @@ func firstAvailableCategoryIDFromPrompt(user string) string {
|
||||
}
|
||||
|
||||
// runCategorizeStep applies vector then LLM taxonomy selection when Category empty.
|
||||
func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *StepResult, in ProductInput, categoryNames []string, policy StepPolicy) {
|
||||
// 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 {
|
||||
if out == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(out.Category) != "" {
|
||||
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
|
||||
@@ -243,7 +330,7 @@ func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *St
|
||||
"reason": "already_set",
|
||||
"category": out.Category,
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if !policy.AllowAI {
|
||||
out.Notes = append(out.Notes, "categorize: skipped (AI not allowed)")
|
||||
@@ -251,21 +338,24 @@ func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *St
|
||||
"status": "skipped",
|
||||
"reason": "entitlement_can_use_ai",
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
tryVectorCategorize(ctx, e, companyID, out, categoryNames, policy)
|
||||
if strings.TrimSpace(out.Category) != "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// Taxonomy picks and copy generation share one Completer; tag the role so the
|
||||
// admin inspector can tell them apart.
|
||||
tryAICategorize(aiaudit.WithCall(ctx, aiaudit.CallContext{Role: aiaudit.RoleCategorize}), e, out, in, policy)
|
||||
if err := tryAICategorize(aiaudit.WithCall(ctx, aiaudit.CallContext{Role: aiaudit.RoleCategorize}), e, out, in, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(out.Category) == "" {
|
||||
appendStepLog(out.GPTResponse, StepCategorize, map[string]any{
|
||||
"status": "unset",
|
||||
"reason": "no_vector_or_ai_match",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistMappedCategorySQL writes a taxonomy unique_id onto mapped_data.category
|
||||
|
||||
Reference in New Issue
Block a user