275 lines
7.7 KiB
Go
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)")
|
|
}
|