Files
descrybe/apps/api/internal/processing/category.go
T
2026-08-16 21:35:48 +02:00

275 lines
7.7 KiB
Go

package processing
import (
"strconv"
"strings"
"unicode"
)
// categoryDisplayLabel returns the human category name for meta / synthesize /
// {{category}}. Prefers taxonomy CategoryName; never falls back to a digits-only
// unique_id, prompt/formula leakage, or the product title itself.
func categoryDisplayLabel(result StepResult) string {
if n := strings.TrimSpace(result.CategoryName); n != "" {
// CategoryName comes from categories.name — trust unless prompt leakage.
if !isPromptLabelTitle(n) {
return n
}
}
cat := strings.TrimSpace(result.Category)
if cat == "" || isDigitsOnlyCategoryID(cat) || isPromptLabelTitle(cat) {
return ""
}
// Unresolved non-uid token: never surface the product name as {{category}}.
if categoryTokenEqualsProductTitle(cat, result.ProcessedName, result.Name) {
return ""
}
return cat
}
// resolveCategoryDisplayName looks up unique_id → name; returns "" when unknown
// so callers do not treat "50" as a human label.
func resolveCategoryDisplayName(uid string, namesByUID map[string]string) string {
uid = strings.TrimSpace(uid)
if uid == "" || len(namesByUID) == 0 {
return ""
}
return strings.TrimSpace(namesByUID[uid])
}
func syncCategoryName(out *StepResult, namesByUID map[string]string) {
if out == nil {
return
}
if n := resolveCategoryDisplayName(out.Category, namesByUID); n != "" {
out.CategoryName = n
}
}
func isDigitsOnlyCategoryID(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
// categoryUniqueIDFromAny extracts a company category unique_id from feed/mapped
// shapes: plain string/number, nested {unique_id|category_unique_id|#text|…}, or
// a one-element array of those. Returns "" for empty/"none"/unusable values.
func categoryUniqueIDFromAny(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return normalizeCategoryUniqueID(t)
case float64:
if t == float64(int64(t)) {
return normalizeCategoryUniqueID(strconv.FormatInt(int64(t), 10))
}
return normalizeCategoryUniqueID(strconv.FormatFloat(t, 'f', -1, 64))
case float32:
return categoryUniqueIDFromAny(float64(t))
case int:
return normalizeCategoryUniqueID(strconv.Itoa(t))
case int64:
return normalizeCategoryUniqueID(strconv.FormatInt(t, 10))
case int32:
return normalizeCategoryUniqueID(strconv.FormatInt(int64(t), 10))
case jsonNumberStringer:
return normalizeCategoryUniqueID(t.String())
case map[string]any:
for _, k := range []string{
"unique_id", "category_unique_id", "categoryUniqueId",
"#text", "text", "id", "value", "code",
// Last: display name only (resolved to unique_id later via taxonomy).
"name",
} {
if s := categoryUniqueIDFromAny(t[k]); s != "" {
return s
}
}
return ""
case []any:
for _, item := range t {
if s := categoryUniqueIDFromAny(item); s != "" {
return s
}
}
return ""
default:
return ""
}
}
// jsonNumberStringer matches encoding/json.Number without importing encoding/json here.
type jsonNumberStringer interface {
String() string
}
func normalizeCategoryUniqueID(s string) string {
s = strings.TrimSpace(s)
if s == "" || s == "<nil>" || strings.EqualFold(s, "none") {
return ""
}
// Never treat enhance-prompt / formula scaffolding as a category unique_id.
if isPromptLabelTitle(s) {
return ""
}
return s
}
// categoryUniqueIDFromMaps returns the first usable category unique_id from maps
// (normalized/mapped/raw). Prefer canonical keys; mapped should win by call order.
func categoryUniqueIDFromMaps(maps ...map[string]any) string {
for _, m := range maps {
if m == nil {
continue
}
for _, k := range []string{
"category", "category_unique_id", "categoryUniqueId",
"Category", "product_type", "productType",
} {
if s := categoryUniqueIDFromAny(m[k]); s != "" {
return s
}
}
}
return ""
}
// coerceMappedCategoryUniqueID writes a string unique_id onto m["category"] when
// category (or category_unique_id) can be resolved — so RunSteps string paths stay deterministic.
func coerceMappedCategoryUniqueID(m map[string]any) string {
if m == nil {
return ""
}
cat := categoryUniqueIDFromMaps(m)
if cat == "" {
return ""
}
m["category"] = cat
if _, ok := m["category_unique_id"]; !ok {
m["category_unique_id"] = cat
}
return cat
}
// applyCategoryFromMapped sets StepResult.Category from feed/mapped unique_id when present.
// Does not clear an existing Category when maps lack a usable value (caller may preserve prior).
func applyCategoryFromMapped(out *StepResult, maps ...map[string]any) {
if out == nil {
return
}
cat := categoryUniqueIDFromMaps(maps...)
// Prompt/formula leakage never becomes Category. Product-title equals are
// scrubbed later (scrubCategoryPollution) so a title that matches a real
// taxonomy display name can still coerce to unique_id.
if cat == "" || isPromptLabelTitle(cat) {
return
}
out.Category = SanitizeText(cat)
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "mapped"
}
// resolveCompanyCategoryUniqueID maps a feed/vector/prior category token onto a
// company taxonomy unique_id. Accepts an already-valid unique_id, or a display
// name (case-insensitive) from namesByUID. When valid is non-empty, the result
// must be in that set. Returns "" when unresolvable.
func resolveCompanyCategoryUniqueID(raw string, namesByUID map[string]string, valid map[string]struct{}) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "none") {
return ""
}
if len(valid) > 0 {
if _, ok := valid[raw]; ok {
return raw
}
} else if _, ok := namesByUID[raw]; ok {
return raw
}
if len(namesByUID) == 0 {
return ""
}
for uid, name := range namesByUID {
uid = strings.TrimSpace(uid)
if uid == "" {
continue
}
if !strings.EqualFold(strings.TrimSpace(name), raw) {
continue
}
if len(valid) == 0 {
return uid
}
if _, ok := valid[uid]; ok {
return uid
}
}
return ""
}
// coerceCategoryToCompanyUniqueID rewrites out.Category from a display name (or
// other alias) onto the taxonomy unique_id before filterCategoryIfInvalid.
// Without this, feed/vector category names are wiped even when they match categories.name.
func coerceCategoryToCompanyUniqueID(out *StepResult, namesByUID map[string]string, valid map[string]struct{}) {
if out == nil {
return
}
cat := strings.TrimSpace(out.Category)
if cat == "" {
return
}
if isPromptLabelTitle(cat) {
out.Category = ""
out.CategoryName = ""
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "cleared_invalid"
return
}
resolved := resolveCompanyCategoryUniqueID(cat, namesByUID, valid)
if resolved == "" || resolved == cat {
return
}
out.Category = SanitizeText(resolved)
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
if _, ok := out.FieldSources["category"]; !ok {
out.FieldSources["category"] = "name_coerced"
}
}
// filterCategoryIfInvalid clears Category when the company has a known unique_id set
// and the value is not in that set. Empty valid set means "skip validation" (tests / no taxonomy).
// Call coerceCategoryToCompanyUniqueID first so display names are not dropped.
func filterCategoryIfInvalid(out *StepResult, valid map[string]struct{}) {
if out == nil || len(valid) == 0 {
return
}
cat := strings.TrimSpace(out.Category)
if cat == "" {
return
}
if _, ok := valid[cat]; ok {
return
}
out.Category = ""
out.CategoryName = ""
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "cleared_invalid"
out.Notes = append(out.Notes, "category: ignored (unknown unique_id for company)")
}