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

1471 lines
50 KiB
Go
Raw Normal View History

package processing
import (
"context"
"encoding/json"
"fmt"
2026-08-16 21:35:48 +02:00
"log"
2026-08-16 16:57:36 +02:00
"sort"
"strings"
2026-08-16 21:35:48 +02:00
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
)
// StepPolicy controls entitlement-gated steps (AI / EPREL).
type StepPolicy struct {
AllowAI bool
AllowEPREL bool
}
// RunSteps executes the multi-step product pipeline.
// OpenAI enhance runs only when Completer is configured, Enabled(), and policy.AllowAI.
// EPREL runs only when enricher enabled and policy.AllowEPREL.
func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput, processingType string, categoryNames []string, policy StepPolicy) (StepResult, error) {
steps := resolveSteps(processingType)
out := StepResult{
Attributes: map[string]any{},
ProcessedAttributes: map[string]any{},
FieldSources: map[string]any{},
EPREL: map[string]any{},
GPTResponse: map[string]any{"steps": []any{}},
Notes: []string{},
}
normalized := map[string]any{}
attrs := map[string]any{}
for _, step := range steps {
switch step {
case StepNormalize:
normalized = NormalizeMapped(in.Mapped, in.Raw)
out.Name = preferredProductTitle(in.GTIN,
stringFromAny(normalized["name"]),
stringFromAny(normalized["title"]),
in.Name,
in.PriorProcessedName,
)
2026-08-16 16:57:36 +02:00
out.Description = preferredProductDescription(out.Name,
stringFromAny(normalized["description"]),
in.Description,
in.PriorProcessedDescription,
)
2026-08-16 16:57:36 +02:00
// mapped_data.category / category_unique_id (unique_id codes) win.
applyCategoryFromMapped(&out, normalized, in.Mapped, in.Raw)
// otherwise keep existing processed category so enhance_only / reprocess
// cannot blank A1 legacy categories.
preserveCategoryIfEmpty(&out, in.PriorCategory)
out.FieldSources["normalize"] = "mapped+raw"
appendStepLog(out.GPTResponse, StepNormalize, map[string]any{
"keys": len(normalized),
})
case StepParseSpecs:
specVal := normalized["specifications"]
if specVal == nil {
specVal = in.Mapped["specifications"]
}
if specVal == nil {
specVal = in.Raw["specifications"]
}
parsed := ParseSpecifications(specVal)
// Also accept pre-mapped attributes map
if am, ok := normalized["attributes"].(map[string]any); ok {
for k, v := range ParseSpecifications(am) {
if _, exists := parsed[k]; !exists {
parsed[k] = v
}
}
}
2026-08-16 16:57:36 +02:00
attrs = SanitizeProductAttributes(parsed)
// Map feed/spec labels onto category_attributes (formula) keys early.
if allowed := enhanceAllowedAttrKeys(in, out.Category); allowed != nil {
attrs = MapAttrsOntoAllowedKeys(attrs, allowed)
}
out.Attributes = attrs
out.ProcessedAttributes = attrs
out.FieldSources["attributes"] = "specifications"
appendStepLog(out.GPTResponse, StepParseSpecs, map[string]any{
"count": len(attrs),
})
case StepFillFields:
normalized = FillMissingFields(normalized, attrs)
if len(in.StandardFields) > 0 {
normalized = FillMissingStandardFields(normalized, in.Raw, in.StandardFields)
}
out.Name = preferredProductTitle(in.GTIN,
stringFromAny(normalized["name"]),
stringFromAny(normalized["title"]),
out.Name,
in.Name,
in.PriorProcessedName,
)
2026-08-16 16:57:36 +02:00
out.Description = preferredProductDescription(out.Name,
stringFromAny(normalized["description"]),
out.Description,
in.Description,
)
2026-08-16 16:57:36 +02:00
applyCategoryFromMapped(&out, normalized, in.Mapped, in.Raw)
2026-08-16 12:23:14 +02:00
// Promote characteristic fields only — never core product identity/content
// (those belong on the V1 item root: title, description, ean, images, …).
promote := []string{"brand", "width", "height", "depth", "weight", "product_model", "warranty"}
for _, f := range in.StandardFields {
2026-08-16 12:23:14 +02:00
k := strings.TrimSpace(f.Key)
if k == "" || isReservedProductKey(k) || isInvalidAttributeKey(k) {
continue
}
2026-08-16 12:23:14 +02:00
promote = append(promote, k)
}
seen := map[string]bool{}
for _, k := range promote {
if seen[k] {
continue
}
seen[k] = true
if v := stringFromAny(normalized[k]); v != "" {
if _, exists := attrs[k]; !exists {
attrs[k] = v
}
out.FieldSources[k] = "fill_fields"
}
}
2026-08-16 12:23:14 +02:00
attrs = SanitizeProductAttributes(attrs)
2026-08-16 16:57:36 +02:00
// Remap again after category may have resolved in applyCategoryFromMapped.
if allowed := enhanceAllowedAttrKeys(in, out.Category); allowed != nil {
attrs = MapAttrsOntoAllowedKeys(attrs, allowed)
}
out.Attributes = attrs
out.ProcessedAttributes = attrs
appendStepLog(out.GPTResponse, StepFillFields, map[string]any{
"brand": stringFromAny(normalized["brand"]),
})
preserveCategoryIfEmpty(&out, in.PriorCategory)
case StepEPREL:
if !policy.AllowEPREL {
out.Notes = append(out.Notes, "eprel: skipped (not allowed for this job)")
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{
"status": "skipped",
"reason": "entitlement_can_use_eprel",
})
break
}
2026-08-16 16:57:36 +02:00
// Also scan parsed attrs — eprel_id may only appear after parse_specs.
id := eprel.ExtractID(normalized, in.Mapped, in.Raw, attrs)
if id == "" {
out.Notes = append(out.Notes, "eprel: no id")
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{"status": "skipped", "reason": "no_id"})
break
}
enricher := e.EPREL
if enricher == nil {
enricher = eprel.Disabled{}
}
if !enricher.Enabled() {
out.Notes = append(out.Notes, "eprel: enricher disabled")
2026-08-16 16:57:36 +02:00
out.EPREL = map[string]any{"id": id, "status": "skipped"}
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{"status": "skipped", "eprel_id": id, "reason": "disabled"})
break
}
data, err := enricher.Fetch(ctx, id)
if err != nil {
out.Notes = append(out.Notes, "eprel: "+TruncateError(err))
attrs["eprel_id"] = id
out.Attributes = attrs
out.ProcessedAttributes = attrs
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{"status": "failed", "error": TruncateError(err)})
// Non-fatal: continue pipeline
break
}
if data == nil {
attrs["eprel_id"] = id
out.Attributes = attrs
out.ProcessedAttributes = attrs
2026-08-16 16:57:36 +02:00
out.EPREL = map[string]any{"id": id, "status": "empty"}
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{"status": "empty", "eprel_id": id})
break
}
attrs = eprel.MergeInto(attrs, data)
2026-08-16 16:57:36 +02:00
// Promote energy class onto the characteristic attr key when missing
// (feeds often omit it; EPREL API is the source of truth).
if data.EnergyClass != "" {
if cur := strings.TrimSpace(fmt.Sprint(attrs["energy_class"])); cur == "" || cur == "<nil>" {
attrs["energy_class"] = data.EnergyClass
}
}
out.Attributes = attrs
out.ProcessedAttributes = attrs
out.EPREL = map[string]any{
2026-08-16 16:57:36 +02:00
"id": data.ID,
"label": data.Label,
"pdf": data.PDF,
"energy_class": data.EnergyClass,
"energy_scale": data.EnergyScale,
}
out.FieldSources["eprel"] = "eprel_api"
appendStepLog(out.GPTResponse, StepEPREL, map[string]any{"status": "ok", "eprel_id": data.ID})
2026-08-16 23:07:32 +02:00
case StepCategorize:
// Vector (if enabled) then LLM taxonomy pick when category still empty.
runCategorizeStep(ctx, e, companyID, &out, in, categoryNames, policy)
case StepAIEnhance:
preservePriorEnhanceHash := func() {
if in.PriorEnhanceHash != "" {
out.FieldSources[FieldEnhanceInputHash] = in.PriorEnhanceHash
}
}
if !policy.AllowAI {
out.ProcessedName = out.Name
out.ProcessedDescription = out.Description
out.Notes = append(out.Notes, "ai_enhance: skipped (Free plan — upgrade for AI titles/descriptions)")
2026-08-16 21:35:48 +02:00
log.Printf("processing: ai_enhance skip reason=entitlement_can_use_ai")
preservePriorEnhanceHash()
appendStepLog(out.GPTResponse, StepAIEnhance, map[string]any{
"status": "skipped",
"reason": "entitlement_can_use_ai",
})
break
}
if !e.CompleterEnabled() {
out.ProcessedName = out.Name
out.ProcessedDescription = out.Description
out.Notes = append(out.Notes, "ai_enhance: skipped (platform OpenAI unset; configure admin settings or company BYOK)")
2026-08-16 21:35:48 +02:00
log.Printf("processing: ai_enhance skip reason=openai_not_configured")
preservePriorEnhanceHash()
appendStepLog(out.GPTResponse, StepAIEnhance, map[string]any{
"status": "skipped",
"reason": "openai_not_configured",
})
break
}
langs := in.ContentLanguages
if len(langs) == 0 {
langs = []string{in.Language}
}
if len(langs) == 0 {
langs = []string{company.DefaultLanguage}
}
primary := in.Language
if primary == "" {
primary = langs[0]
}
2026-08-16 16:57:36 +02:00
syncCategoryName(&out, in.CategoryNamesByUID)
displayCat := categoryDisplayLabel(out)
ensureEnergyClassFromEPREL(attrs)
enhanceAttrs := AttrsForEnhance(attrs, enhanceAllowedAttrKeys(in, out.Category))
localized := company.LocalizedContent{}
if in.PriorLocalized != nil {
for k, v := range in.PriorLocalized {
localized[k] = v
}
}
anyFailed := false
anyOK := false
allUnchanged := true
langMetas := make([]any, 0, len(langs))
for _, lang := range langs {
tpl := in.EnhanceByLang[lang]
if tpl.System == "" && tpl.User == "" && lang == primary {
tpl = PromptTemplates{System: in.EnhanceSystemTemplate, User: in.EnhanceUserTemplate}
}
catPrompt := in.CategoryEnhancePrompt
if lang != primary || catPrompt == "" {
2026-08-16 16:57:36 +02:00
catPrompt = categoryEnhancePromptFor(in.CategoryPromptsByLang, out.Category, lang, primary)
}
2026-08-16 16:57:36 +02:00
titleTpl, descTpl := categoryFormulasFor(in, out.Category)
priorFields := company.FieldsForLanguage(in.PriorLocalized, lang)
priorHash := priorFields.EnhanceInputHash
priorName := priorFields.ProcessedName
priorDesc := priorFields.ProcessedDescription
if lang == primary {
if priorHash == "" {
priorHash = in.PriorEnhanceHash
}
if priorName == "" {
priorName = in.PriorProcessedName
}
if priorDesc == "" {
priorDesc = in.PriorProcessedDescription
}
}
name, desc, tokens, raw, err := e.enhance(ctx, ProductInput{
GTIN: in.GTIN,
Name: out.Name,
Description: out.Description,
Mapped: normalized,
BrandPrompt: in.BrandPrompt,
Language: lang,
EnhanceSystemTemplate: tpl.System,
EnhanceUserTemplate: tpl.User,
CategoryEnhancePrompt: catPrompt,
2026-08-16 16:57:36 +02:00
TitleTemplate: titleTpl,
DescriptionTemplate: descTpl,
PriorEnhanceHash: priorHash,
PriorProcessedName: priorName,
PriorProcessedDescription: priorDesc,
2026-08-16 21:35:48 +02:00
JobID: in.JobID,
CompanyID: in.CompanyID,
RawProductID: in.RawProductID,
CategoryUniqueID: out.Category,
2026-08-16 16:57:36 +02:00
}, displayCat, enhanceAttrs)
out.TotalTokens += tokens
status := enhanceStatusFromMeta(raw)
meta := map[string]any{"language": lang, "raw": raw}
if err != nil {
anyFailed = true
allUnchanged = false
meta["status"] = "failed"
meta["error"] = TruncateError(err)
out.Notes = append(out.Notes, "ai_enhance: "+TruncateError(err))
if lang == primary {
name, desc = out.Name, out.Description
} else if priorName != "" || priorDesc != "" {
name, desc = priorName, priorDesc
} else {
langMetas = append(langMetas, meta)
continue
}
} else if status == "unchanged" {
meta["status"] = "unchanged"
2026-08-16 21:35:48 +02:00
} else if status == "refused" || status == "synthesized" || status == "parse_failed" {
// Soft quality failure: keep usable copy / synth, never count as real enhance ok.
allUnchanged = false
meta["status"] = status
if reason := enhanceReasonFromMeta(raw); reason != "" {
out.Notes = append(out.Notes, "ai_enhance: "+status+" ("+reason+")")
} else {
out.Notes = append(out.Notes, "ai_enhance: "+status)
}
} else {
allUnchanged = false
anyOK = true
meta["status"] = status
}
2026-08-16 16:57:36 +02:00
// Prefer title-aware selection + synthesize before deciding hash persistence.
name = preferredProductTitle(in.GTIN, name, out.Name, priorName, in.Name)
desc = preferredProductDescription(name, desc, out.Description, priorDesc)
2026-08-16 19:21:49 +02:00
outerSynth := false
2026-08-16 21:35:48 +02:00
if descriptionNeedsEnhanceRepair(desc, descTpl, name) {
2026-08-16 19:21:49 +02:00
if synth := synthesizeProductDescription(name, displayCat, lang, enhanceAttrs, descTpl); synth != "" {
2026-08-16 16:57:36 +02:00
desc = synth
2026-08-16 19:21:49 +02:00
outerSynth = true
2026-08-16 16:57:36 +02:00
}
}
2026-08-16 21:35:48 +02:00
weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name)
2026-08-16 16:57:36 +02:00
// Only persist enhance_input_hash for quality ok / hash-skip unchanged.
2026-08-16 19:21:49 +02:00
// Never copy input_hash from error/passthrough/thin/synthesized meta.
2026-08-16 16:57:36 +02:00
persistHash := ""
rawHash := enhanceHashFromMeta(raw)
switch {
case err != nil:
persistHash = ""
2026-08-16 21:35:48 +02:00
case status == "synthesized" || status == "refused" || outerSynth:
2026-08-16 19:21:49 +02:00
persistHash = ""
allUnchanged = false
2026-08-16 16:57:36 +02:00
case status == "unchanged" && !weakDesc:
persistHash = rawHash
case status == "ok" && !weakDesc:
persistHash = rawHash
default:
2026-08-16 21:35:48 +02:00
// thin ok / parse_failed / formula mismatch / skipped — do not poison reprocess skip
2026-08-16 16:57:36 +02:00
persistHash = ""
if status == "ok" && weakDesc {
allUnchanged = false
}
}
localized[lang] = company.LocalizedFields{
ProcessedName: name,
ProcessedDescription: desc,
2026-08-16 16:57:36 +02:00
EnhanceInputHash: persistHash,
MetaTitle: company.FieldsForLanguage(localized, lang).MetaTitle,
MetaDescription: company.FieldsForLanguage(localized, lang).MetaDescription,
}
// Preserve existing meta when re-enhancing titles only.
2026-08-16 16:57:36 +02:00
// Never keep a bare "| <unique_id>" poisoned meta_title.
// When dropping poisoned title, also drop empty/weak stub meta_description.
if prev := company.FieldsForLanguage(in.PriorLocalized, lang); prev.MetaTitle != "" || prev.MetaDescription != "" {
f := localized[lang]
2026-08-16 16:57:36 +02:00
poisonedTitle := isPoisonedMetaTitle(prev.MetaTitle)
if f.MetaTitle == "" && !poisonedTitle {
f.MetaTitle = prev.MetaTitle
}
if f.MetaDescription == "" {
2026-08-16 16:57:36 +02:00
weakStub := poisonedTitle && isWeakPriorEnhanceDescription(prev.MetaDescription, name, priorName)
if !weakStub {
f.MetaDescription = prev.MetaDescription
}
}
localized[lang] = f
}
langMetas = append(langMetas, meta)
if lang == primary {
out.ProcessedName = name
out.ProcessedDescription = desc
if name != "" {
out.Name = name
}
if desc != "" {
out.Description = desc
}
2026-08-16 16:57:36 +02:00
if persistHash != "" {
out.FieldSources[FieldEnhanceInputHash] = persistHash
} else {
delete(out.FieldSources, FieldEnhanceInputHash)
}
}
}
out.LocalizedContent = localized
if anyFailed && !anyOK {
if out.ProcessedName == "" {
out.ProcessedName = out.Name
}
if out.ProcessedDescription == "" {
out.ProcessedDescription = out.Description
}
2026-08-16 21:35:48 +02:00
// Timeout/error/empty/formula-miss: always synthesize a factual fallback when a title exists.
_, failDescTpl := categoryFormulasFor(in, out.Category)
if out.ProcessedName != "" && descriptionNeedsEnhanceRepair(out.ProcessedDescription, failDescTpl, out.ProcessedName, out.Name) {
2026-08-16 19:21:49 +02:00
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
2026-08-16 16:57:36 +02:00
out.ProcessedDescription = synth
2026-08-16 21:35:48 +02:00
if out.Description == "" || descriptionNeedsEnhanceRepair(out.Description, failDescTpl, out.Name, out.ProcessedName) {
2026-08-16 16:57:36 +02:00
out.Description = synth
}
if lf, ok := localized[primary]; ok {
lf.ProcessedDescription = synth
if lf.ProcessedName == "" {
lf.ProcessedName = out.ProcessedName
}
localized[primary] = lf
}
}
}
// Attempted enhance (even on timeout) — record provider mode, not misleading unknown.
out.AIProviderMode = e.EngineProviderMode()
out.FieldSources["name"] = "ai_enhance_failed"
out.FieldSources["description"] = "ai_enhance_failed"
// Failed enhance must not leave a skippable hash behind.
delete(out.FieldSources, FieldEnhanceInputHash)
for lang, lf := range localized {
lf.EnhanceInputHash = ""
localized[lang] = lf
}
out.LocalizedContent = localized
errNote := ""
for _, m := range langMetas {
if mm, ok := m.(map[string]any); ok {
if e, ok := mm["error"].(string); ok && e != "" {
errNote = e
break
}
}
}
appendStepLog(out.GPTResponse, StepAIEnhance, map[string]any{
"status": "failed",
"error": errNote,
"languages": langMetas,
})
break
}
if allUnchanged {
out.SkipCreditDebit = true
out.FieldSources["name"] = "ai_enhance_unchanged"
out.FieldSources["description"] = "ai_enhance_unchanged"
2026-08-16 21:35:48 +02:00
out.Notes = append(out.Notes, "ai_enhance_unchanged")
log.Printf("processing: ai_enhance_unchanged")
} else {
out.AIProviderMode = e.EngineProviderMode()
out.FieldSources["name"] = "ai_enhance"
out.FieldSources["description"] = "ai_enhance"
2026-08-16 21:35:48 +02:00
for _, m := range langMetas {
if mm, ok := m.(map[string]any); ok {
reason, _ := mm["reason"].(string)
if reason == "" {
if raw, ok := mm["raw"].(map[string]any); ok {
reason, _ = raw["reason"].(string)
}
}
if reason != "" {
out.Notes = append(out.Notes, "ai_enhance: forced re-enhance ("+reason+")")
log.Printf("processing: ai_enhance forced re-enhance reason=%s", reason)
break
}
}
}
}
appendStepLog(out.GPTResponse, StepAIEnhance, map[string]any{
"status": map[string]any{"unchanged": allUnchanged, "ok": anyOK, "failed": anyFailed},
"languages": langMetas,
})
default:
appendStepLog(out.GPTResponse, step, map[string]any{"status": "unknown"})
}
}
2026-08-16 23:07:32 +02:00
// Pipelines without StepCategorize (enhance_only / normalize_only) still try
// vector categorize when AllowAI + embeddings are available. Full/categorize
// already ran runCategorizeStep (vector then LLM) inside the loop.
if !stepsContain(steps, StepCategorize) {
tryVectorCategorize(ctx, e, companyID, &out, categoryNames, policy)
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
}
preserveCategoryIfEmpty(&out, in.PriorCategory)
2026-08-16 16:57:36 +02:00
syncCategoryName(&out, in.CategoryNamesByUID)
out.Name = preferredProductTitle(in.GTIN, out.Name, out.ProcessedName, in.Name, in.PriorProcessedName)
out.ProcessedName = preferredProductTitle(in.GTIN, out.ProcessedName, out.Name, in.PriorProcessedName, in.Name)
2026-08-16 16:57:36 +02:00
out.Description = preferredProductDescription(out.Name, out.Description, out.ProcessedDescription, in.Description)
out.ProcessedDescription = preferredProductDescription(out.ProcessedName, out.ProcessedDescription, out.Description, in.PriorProcessedDescription)
if out.ProcessedName == "" {
out.ProcessedName = out.Name
}
2026-08-16 21:35:48 +02:00
// Titles are finalized first so name-as-category and formula leakage can be cleared.
scrubCategoryPollution(&out, in.CategoryNamesByUID, nil)
syncCategoryName(&out, in.CategoryNamesByUID)
displayCat := categoryDisplayLabel(out)
// After vector/mapped category resolution, formulas may key for the first time —
// repair short prose / title echo that ignored A1 description_template.
2026-08-16 19:21:49 +02:00
_, finalDescTpl := categoryFormulasFor(in, out.Category)
2026-08-16 21:35:48 +02:00
finalRepaired := false
if descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.ProcessedName, out.Name) {
2026-08-16 19:21:49 +02:00
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
2026-08-16 16:57:36 +02:00
out.ProcessedDescription = synth
2026-08-16 21:35:48 +02:00
finalRepaired = true
2026-08-16 16:57:36 +02:00
}
}
2026-08-16 21:35:48 +02:00
if descriptionNeedsEnhanceRepair(out.Description, finalDescTpl, out.Name, out.ProcessedName) {
if out.ProcessedDescription != "" && !descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.Name, out.ProcessedName) {
2026-08-16 16:57:36 +02:00
out.Description = out.ProcessedDescription
2026-08-16 21:35:48 +02:00
finalRepaired = true
2026-08-16 19:21:49 +02:00
} else if synth := synthesizeProductDescription(out.Name, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
2026-08-16 16:57:36 +02:00
out.Description = synth
2026-08-16 21:35:48 +02:00
finalRepaired = true
if descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.ProcessedName, out.Name) {
2026-08-16 16:57:36 +02:00
out.ProcessedDescription = synth
}
}
}
2026-08-16 21:35:48 +02:00
if finalRepaired {
delete(out.FieldSources, FieldEnhanceInputHash)
primary := strings.TrimSpace(in.Language)
if primary == "" && len(in.ContentLanguages) > 0 {
primary = strings.TrimSpace(in.ContentLanguages[0])
}
if primary == "" {
primary = company.DefaultLanguage
}
for lang, lf := range out.LocalizedContent {
lf.EnhanceInputHash = ""
if lang == primary {
lf.ProcessedDescription = out.ProcessedDescription
if lf.ProcessedName == "" {
lf.ProcessedName = out.ProcessedName
}
}
out.LocalizedContent[lang] = lf
}
}
if out.Attributes == nil {
out.Attributes = map[string]any{}
}
2026-08-16 16:57:36 +02:00
// Persist the same sanitize + category allowlist used by enhance (poll already
// projects clean attrs; DB must not keep feed junk like zavora/vzmetenje).
allowed := enhanceAllowedAttrKeys(in, out.Category)
out.Attributes = AttrsForPersist(out.Attributes, allowed)
out.ProcessedAttributes = AttrsForPersist(out.ProcessedAttributes, allowed)
2026-08-16 12:23:14 +02:00
if len(out.ProcessedAttributes) == 0 {
out.ProcessedAttributes = out.Attributes
}
2026-08-16 16:57:36 +02:00
// Prefer EngineProviderMode (job/completer label) over stamping "unknown" on
// 0-token paths (hash-skip / AI skipped). processOne also treats unknown as empty.
if isUnknownProviderMode(out.AIProviderMode) {
out.AIProviderMode = preferKnownProviderMode(e.EngineProviderMode(), out.AIProviderMode)
}
if len(out.Notes) > 0 {
out.GPTResponse["notes"] = out.Notes
}
return out, nil
}
// preserveCategoryIfEmpty keeps an existing processed category when normalize/AI
// left Category empty (common for A1 feeds where category lives only on processed).
func preserveCategoryIfEmpty(out *StepResult, prior string) {
if out == nil || strings.TrimSpace(out.Category) != "" {
return
}
prior = strings.TrimSpace(prior)
2026-08-16 21:35:48 +02:00
if prior == "" || isUnusableCategoryValue(prior, out.ProcessedName, out.Name) {
return
}
out.Category = SanitizeText(prior)
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "prior_processed"
}
2026-08-16 16:57:36 +02:00
// tryVectorCategorize sets Category from embeddings when mapped unique_id is absent.
// Requires policy.AllowAI and a configured/enabled VectorCategorizer.
func tryVectorCategorize(ctx context.Context, e *Engine, companyID string, out *StepResult, categoryNames []string, policy StepPolicy) {
if out == nil || strings.TrimSpace(out.Category) != "" {
return
}
if !policy.AllowAI {
return
}
if e == nil || e.Vector == nil || !e.Vector.Enabled() {
return
}
text := strings.TrimSpace(out.Name + " " + out.Description)
if text == "" {
return
}
cat, err := e.Vector.SuggestCategory(ctx, companyID, text, categoryNames)
if err != nil {
out.Notes = append(out.Notes, "vector_categorize: "+TruncateError(err))
appendStepLog(out.GPTResponse, "vector_categorize", map[string]any{
"status": "failed",
"error": TruncateError(err),
})
return
}
2026-08-16 21:35:48 +02:00
if strings.TrimSpace(cat) == "" || isUnusableCategoryValue(cat, out.ProcessedName, out.Name) {
2026-08-16 16:57:36 +02:00
return
}
out.Category = SanitizeOutput(cat)
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "vector"
out.Notes = append(out.Notes, "category: vector")
appendStepLog(out.GPTResponse, "vector_categorize", map[string]any{
"status": "ok",
"category": out.Category,
})
}
// noteMissingCategory records why Category stayed empty (mapped absent; vector skipped or failed).
2026-08-16 23:07:32 +02:00
// Used for pipelines that skip StepCategorize (vector-only post-pass).
2026-08-16 16:57:36 +02:00
func noteMissingCategory(out *StepResult, policy StepPolicy, vectorEnabled bool) {
if out == nil || strings.TrimSpace(out.Category) != "" {
return
}
for _, n := range out.Notes {
2026-08-16 23:07:32 +02:00
if strings.HasPrefix(n, "category: unset") || strings.HasPrefix(n, "category: vector") ||
strings.HasPrefix(n, "category: llm") || strings.HasPrefix(n, "ai_categorize:") {
2026-08-16 16:57:36 +02:00
return
}
}
switch {
case !policy.AllowAI:
out.Notes = append(out.Notes, "category: unset (no mapped unique_id; AI/vector not allowed)")
case !vectorEnabled:
out.Notes = append(out.Notes, "category: unset (no mapped unique_id; vector embeddings unavailable)")
default:
out.Notes = append(out.Notes, "category: unset (no mapped unique_id; vector did not match)")
}
}
func resolveSteps(processingType string) []string {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "enhance", "enhance_only", "enhance-only", "title", "description":
return []string{StepNormalize, StepAIEnhance}
case "attributes", "attributes_only", "specs", "specifications":
return []string{StepNormalize, StepParseSpecs, StepFillFields}
case "eprel", "eprel_only":
2026-08-16 16:57:36 +02:00
// parse_specs first so eprel_id buried in specifications/attributes is visible.
return []string{StepNormalize, StepParseSpecs, StepEPREL}
case "normalize_only":
return []string{StepNormalize}
2026-08-16 23:07:32 +02:00
case "categorize", "categorize_only":
// Taxonomy assign only (vector then LLM) — no title/description rewrite.
return []string{StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize}
case "categorize_enhance":
return append([]string{}, CanonicalSteps...)
default: // full
return append([]string{}, CanonicalSteps...)
}
}
2026-08-16 23:07:32 +02:00
func stepsContain(steps []string, want string) bool {
for _, s := range steps {
if s == want {
return true
}
}
return false
}
// InitialStepProgress builds pending step_progress rows for a job.
func InitialStepProgress(processingType string) []StepProgress {
steps := resolveSteps(processingType)
out := make([]StepProgress, 0, len(steps))
for _, s := range steps {
out = append(out, StepProgress{Step: s, Status: "pending"})
}
return out
}
func appendStepLog(gpt map[string]any, name string, raw any) {
steps, _ := gpt["steps"].([]any)
gpt["steps"] = append(steps, map[string]any{"step": name, "raw": raw})
}
2026-08-16 21:35:48 +02:00
// descriptionFormulaRetrySuffix is appended once when LLM JSON ignored the
// category description_template (short prose / title echo instead of multi-section HTML).
const descriptionFormulaRetrySuffix = "\n\nINVALID DESCRIPTION. Your JSON ignored the Description formula. Reply with ONLY one JSON object; \"description\" must be ONE HTML string covering each formula section in order with matching tags (h1/h2/h3/h4, p, ul)."
// descriptionNeedsEnhanceRepair is true when desc is weak/empty/title-echo or
// fails an active category description_template (A1 multi-section HTML).
// Heuristic invent/synth is intentionally excluded here — enhanceHashForceReason
// blocks hash-skip for synth so reprocess can still upgrade to real LLM copy.
func descriptionNeedsEnhanceRepair(desc string, template any, titles ...string) bool {
if isWeakPriorEnhanceDescription(desc, titles...) {
return true
}
return !descriptionSatisfiesFormula(desc, template)
}
// enhanceHashForceReason returns why a matching prior enhance_input_hash must not
// skip the LLM (empty = safe to reuse as ai_enhance_unchanged).
func enhanceHashForceReason(in ProductInput) string {
if isPromptLabelTitle(in.PriorProcessedName) {
return "prompt_label_title"
}
if reason := company.EnhanceHashSkipBlockReason(in.PriorProcessedDescription, in.PriorProcessedName, in.Name); reason != "" {
return reason
}
if !descriptionSatisfiesFormula(in.PriorProcessedDescription, in.DescriptionTemplate) {
return "formula-mismatch"
}
return ""
}
func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, attrs map[string]any) (string, string, int, any, error) {
2026-08-16 21:35:48 +02:00
catUID := strings.TrimSpace(in.CategoryUniqueID)
catName := strings.TrimSpace(category)
sysTpl, userTpl := resolveProductPromptTemplates(in)
hash := HashEnhanceInput(category, in.Name, in.Description, in.BrandPrompt, in.Language, sysTpl, userTpl, attrs)
if e == nil || e.Completer == nil {
2026-08-16 16:57:36 +02:00
name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)
2026-08-16 21:35:48 +02:00
logEnhanceOutcome(in, catUID, catName, "skip", "completer_nil", Completion{}, 0, "")
2026-08-16 16:57:36 +02:00
return name,
preferredProductDescription(name, in.Description, in.PriorProcessedDescription),
2026-08-16 21:35:48 +02:00
0, map[string]any{"status": "skipped", "reason": "completer_nil"}, nil
}
2026-08-16 16:57:36 +02:00
// Skip LLM when inputs match the last successful enhance (before any credit debit),
2026-08-16 21:35:48 +02:00
// but never reuse thin / title-echo / invent-synth / formula-mismatch priors,
// or a prompt-leakage title.
var forceReason string
if in.PriorEnhanceHash != "" && in.PriorEnhanceHash == hash &&
2026-08-16 21:35:48 +02:00
(in.PriorProcessedName != "" || in.PriorProcessedDescription != "") {
forceReason = enhanceHashForceReason(in)
if forceReason == "" {
name := preferredProductTitle(in.GTIN, in.PriorProcessedName, in.Name)
desc := preferredProductDescription(name, in.PriorProcessedDescription, in.Description)
logEnhanceOutcome(in, catUID, catName, "skip", "unchanged_hash", Completion{}, 0, "")
return name, desc, 0, map[string]any{
"status": "unchanged",
"input_hash": hash,
}, nil
}
log.Printf("processing: ai_enhance skip_blocked reason=%s category=%q name=%q",
forceReason, truncateRunes(category, 80), truncateRunes(in.Name, 80))
}
system, user := RenderProductEnhancePrompts(sysTpl, userTpl, category, in.Name, in.Description, in.GTIN, in.BrandPrompt, in.Language, attrs)
2026-08-16 21:35:48 +02:00
logEnhancePrompt(in, catUID, catName, system, user)
started := time.Now()
comp, obj, err := CompleteJSON(ctx, e.Completer, system, user, CompleteOptions{
MaxTokens: MaxTokensEnhance,
Temperature: DefaultStructuredTemp,
})
2026-08-16 21:35:48 +02:00
elapsed := time.Since(started)
if err != nil {
// Network/provider failure vs parse failure after retry
2026-08-16 16:57:36 +02:00
name := preferredProductTitle(in.GTIN, in.Name, in.PriorProcessedName)
desc := preferredProductDescription(name, in.Description, in.PriorProcessedDescription)
2026-08-16 21:35:48 +02:00
outcome := "refuse"
reason := "empty_or_provider_error"
if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
2026-08-16 19:21:49 +02:00
if synth := synthesizeProductDescription(name, category, in.Language, attrs, in.DescriptionTemplate); synth != "" {
2026-08-16 16:57:36 +02:00
desc = synth
2026-08-16 21:35:48 +02:00
outcome = "synthesized"
2026-08-16 16:57:36 +02:00
}
}
if obj == nil && comp.Text == "" {
2026-08-16 21:35:48 +02:00
logEnhanceOutcome(in, catUID, catName, outcome, reason+":"+TruncateError(err), comp, elapsed, forceReason)
2026-08-16 16:57:36 +02:00
return name, desc, 0, map[string]any{
"provider": "passthrough",
"error": TruncateError(err),
}, err
}
2026-08-16 21:35:48 +02:00
// Parse failed after retry — keep usable copy; synthesize when empty/weak/formula-miss.
if outcome != "synthesized" {
outcome = "refuse"
reason = "parse_failed"
} else {
reason = "parse_failed"
}
logEnhanceOutcome(in, catUID, catName, outcome, reason, comp, elapsed, forceReason)
2026-08-16 16:57:36 +02:00
return name, desc, comp.TotalTokens, map[string]any{
"status": "parse_failed",
2026-08-16 21:35:48 +02:00
"reason": "invalid_json",
2026-08-16 16:57:36 +02:00
"error": "AI returned invalid JSON; kept original title/description",
"raw": truncateRunes(comp.Text, 200),
}, nil
}
2026-08-16 21:35:48 +02:00
llmName := SanitizeOutput(fmt.Sprint(obj["name"]))
llmDesc := SanitizeOutput(fmt.Sprint(obj["description"]))
// Never prefer-fallback to originals for empty/leakage LLM fields — that stamped
// status=ok + input_hash on garbage enhance output (prod: 30272ms "ok", wrong copy).
if reason := llmEnhanceHardRefuseReason(llmName, llmDesc); reason != "" {
name := preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
desc := preferredProductDescription(name, llmDesc)
synthesized := false
if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
if desc == "" {
desc = preferredProductDescription(name, in.Description, in.PriorProcessedDescription)
}
if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
if synth := synthesizeProductDescription(name, category, in.Language, attrs, in.DescriptionTemplate); synth != "" {
desc = synth
synthesized = true
}
}
}
status := "refused"
outcome := "refuse"
if synthesized {
status = "synthesized"
outcome = "synthesized"
}
meta := map[string]any{
"status": status,
"reason": reason,
"raw": comp.Raw,
}
if forceReason != "" {
meta["forced_reenhance"] = true
meta["force_reason"] = forceReason
}
logEnhanceOutcome(in, catUID, catName, outcome, reason, comp, elapsed, forceReason)
return name, desc, comp.TotalTokens, meta, nil
}
name := preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
// Quality-gate on LLM description alone (do not absorb originals yet).
desc := preferredProductDescription(name, llmDesc)
didFormulaRetry := false
// Fast garbage that ignores A1 description_template: one formula-aware retry, then synthesize.
if !descriptionSatisfiesFormula(desc, in.DescriptionTemplate) &&
FormatDescriptionFormulaConstraint(in.DescriptionTemplate) != "" {
didFormulaRetry = true
retryUser := user + descriptionFormulaRetrySuffix
retryStarted := time.Now()
comp2, obj2, err2 := CompleteJSON(ctx, e.Completer, system, retryUser, CompleteOptions{
MaxTokens: MaxTokensEnhance,
Temperature: DefaultStructuredTemp,
})
elapsed += time.Since(retryStarted)
comp.PromptTokens += comp2.PromptTokens
comp.OutputTokens += comp2.OutputTokens
comp.TotalTokens += comp2.TotalTokens
if err2 == nil && obj2 != nil {
n2 := SanitizeOutput(fmt.Sprint(obj2["name"]))
d2 := SanitizeOutput(fmt.Sprint(obj2["description"]))
if llmEnhanceHardRefuseReason(n2, d2) == "" {
name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName)
desc = preferredProductDescription(name, d2)
if comp2.Raw != nil {
comp.Raw = comp2.Raw
}
if comp2.Text != "" {
comp.Text = comp2.Text
}
}
}
}
2026-08-16 19:21:49 +02:00
synthesized := false
2026-08-16 21:35:48 +02:00
if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
if desc == "" {
desc = preferredProductDescription(name, in.Description, in.PriorProcessedDescription)
}
if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
if synth := synthesizeProductDescription(name, category, in.Language, attrs, in.DescriptionTemplate); synth != "" {
desc = synth
synthesized = true
}
2026-08-16 16:57:36 +02:00
}
}
meta := map[string]any{
"status": "ok",
"raw": comp.Raw,
}
2026-08-16 21:35:48 +02:00
if forceReason != "" {
meta["reason"] = forceReason
meta["forced_reenhance"] = true
}
outcome := "ok"
reason := forceReason
2026-08-16 19:21:49 +02:00
// Heuristic synthesize must not poison enhance_input_hash (would hash-skip
// formula-aware LLM copy on reprocess). Only persist hash for real LLM quality.
if synthesized {
meta["status"] = "synthesized"
2026-08-16 21:35:48 +02:00
outcome = "synthesized"
if didFormulaRetry {
reason = "formula_retry"
} else {
reason = "weak_or_formula_mismatch"
}
meta["reason"] = reason
} else if descriptionNeedsEnhanceRepair(desc, in.DescriptionTemplate, name) {
meta["status"] = "refused"
outcome = "refuse"
if didFormulaRetry {
reason = "formula_retry"
} else {
reason = "weak_or_formula_mismatch"
}
meta["reason"] = reason
} else {
2026-08-16 16:57:36 +02:00
meta["input_hash"] = hash
2026-08-16 21:35:48 +02:00
if didFormulaRetry {
reason = "formula_retry"
if meta["reason"] == nil {
meta["reason"] = reason
}
}
2026-08-16 16:57:36 +02:00
}
2026-08-16 21:35:48 +02:00
logEnhanceOutcome(in, catUID, catName, outcome, reason, comp, elapsed, forceReason)
2026-08-16 16:57:36 +02:00
return name, desc, comp.TotalTokens, meta, nil
}
2026-08-16 21:35:48 +02:00
// llmEnhanceHardRefuseReason reports empty/leakage LLM fields that must never be
// accepted as quality enhance (even when originals could fill the gap).
func llmEnhanceHardRefuseReason(name, desc string) string {
name = strings.TrimSpace(name)
desc = strings.TrimSpace(desc)
if name == "" || name == "<nil>" {
return "empty_name"
}
if isPromptLabelTitle(name) {
return "prompt_leakage_name"
}
if desc == "" || desc == "<nil>" {
return "empty_description"
}
return ""
}
func sanitizeJSON(v any) string {
if v == nil {
return "{}"
}
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return SanitizeText(string(b))
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
return SanitizeOutput(strings.Trim(s, "\"'` "))
}
2026-08-16 16:57:36 +02:00
// labeledPromptValue returns the first usable line after any of the given labels
// (case-insensitive), e.g. "Name:" / "Desc:" from ProductEnhanceUser.
2026-08-16 16:57:36 +02:00
// Skips matches whose value is prompt-label / formula-leakage text so instruction
// bullets like "- name: short retail title; follow any Title formula…" do not
// win over the later "Name: <product>" line in CategoryEnhanceUserTemplate.
func labeledPromptValue(user string, labels ...string) string {
lower := strings.ToLower(user)
2026-08-16 16:57:36 +02:00
type hit struct {
at int
label string
}
var hits []hit
for _, label := range labels {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
continue
}
2026-08-16 16:57:36 +02:00
searchFrom := 0
for {
rel := strings.Index(lower[searchFrom:], label)
if rel < 0 {
break
}
at := searchFrom + rel
hits = append(hits, hit{at: at, label: label})
searchFrom = at + len(label)
}
}
2026-08-16 16:57:36 +02:00
if len(hits) == 0 {
return ""
}
2026-08-16 16:57:36 +02:00
sort.Slice(hits, func(i, j int) bool { return hits[i].at < hits[j].at })
for _, h := range hits {
rest := user[h.at+len(h.label):]
if j := strings.Index(strings.ToLower(rest), "attrs:"); j >= 0 {
rest = rest[:j]
}
if j := strings.Index(strings.ToLower(rest), "attributes:"); j >= 0 {
rest = rest[:j]
}
val := firstLine(rest)
if val == "" || isPromptLabelTitle(val) {
continue
}
return val
}
2026-08-16 16:57:36 +02:00
return ""
}
// isPromptLabelTitle detects enhance pollution where the model echoed a
2026-08-16 16:57:36 +02:00
// prompt header ("Category:" / "Category: 120") as the product title, or
// leaked instruction scaffolding from CategoryEnhanceUserTemplate /
// AppendFormulaConstraints into name.
func isPromptLabelTitle(s string) bool {
s = strings.TrimSpace(s)
if s == "" || s == "<nil>" {
return false
}
2026-08-16 16:57:36 +02:00
if isPromptLeakageTitle(s) {
return true
}
lower := strings.ToLower(s)
for _, label := range []string{
"category", "name", "desc", "description",
"attrs", "attributes", "current name", "current description",
} {
if lower == label || lower == label+":" {
return true
}
if strings.HasPrefix(lower, label+":") || strings.HasPrefix(lower, label+" :") {
return true
}
}
return false
}
2026-08-16 16:57:36 +02:00
// isPromptLeakageTitle detects when an LLM echoed enhance-prompt instructions
// (Title formula / short retail title / Schema / Reply with ONLY JSON / …)
// as the product name instead of a real title.
func isPromptLeakageTitle(s string) bool {
s = strings.TrimSpace(s)
if s == "" || s == "<nil>" {
return false
}
lower := strings.ToLower(s)
for _, phrase := range promptLeakagePhrases {
if strings.Contains(lower, phrase) {
return true
}
}
// Long dumps of the enhance template: any formula/schema keyword is enough.
if len([]rune(s)) > 120 {
for _, kw := range promptLeakageLongKeywords {
if strings.Contains(lower, kw) {
return true
}
}
}
return false
}
// Phrases copied from aiprompts.CategoryEnhanceUserTemplate, BuiltInDefaults,
// and processing.AppendFormulaConstraints / FormatTitleFormulaConstraint.
var promptLeakagePhrases = []string{
"title formula",
"follow any",
"constraints that follow",
"short retail title",
"use attrs",
"write name in",
"schema:",
"reply with only json",
"your reply is parsed as json",
"description formula",
"build name from attrs",
"prefer attrs values",
"order matters; join with",
"do not hardcode a language",
2026-08-16 21:35:48 +02:00
// Description / title formula scaffolding (Format*FormulaConstraint + enhance templates).
"emit description as one html",
"emit one html string",
"covering each section",
"tags matching section",
"overrides any shorter",
"competing full html",
"metadescription",
"never ignore the description",
"follow any description formula",
"when a description formula",
"structured html sections",
"never copy name as description",
"do not invent specs",
"prefer 1-3 factual",
"1-2 sentences",
"keep literal text as written",
"build name from attrs using this structure",
2026-08-16 16:57:36 +02:00
}
var promptLeakageLongKeywords = []string{
"formula",
"constraints",
"schema",
"json",
"attrs",
"retail title",
2026-08-16 21:35:48 +02:00
"html string",
"section type",
}
// isUnusableCategoryValue rejects tokens that must never become Category /
// CategoryName: prompt labels / title-formula scaffolding, or the product
// title itself (name-as-category). Callers with taxonomy should prefer
// scrubCategoryPollution so a title that legitimately equals a category name
// can still resolve to unique_id.
func isUnusableCategoryValue(s string, productTitles ...string) bool {
s = strings.TrimSpace(s)
if s == "" || s == "<nil>" {
return false
}
if isPromptLabelTitle(s) {
return true
}
return categoryTokenEqualsProductTitle(s, productTitles...)
}
func categoryTokenEqualsProductTitle(s string, productTitles ...string) bool {
s = strings.TrimSpace(s)
if s == "" || s == "<nil>" {
return false
}
for _, t := range productTitles {
t = strings.TrimSpace(t)
if t == "" || t == "<nil>" {
continue
}
if strings.EqualFold(s, t) {
return true
}
}
return false
}
func taxonomyDisplayNameSet(namesByUID map[string]string) map[string]struct{} {
if len(namesByUID) == 0 {
return nil
}
out := make(map[string]struct{}, len(namesByUID))
for _, name := range namesByUID {
n := strings.ToLower(strings.TrimSpace(name))
if n != "" {
out[n] = struct{}{}
}
}
return out
}
// scrubCategoryPollution clears Category / CategoryName when they hold prompt
// leakage/formula text, or equal the product title without resolving to a
// company taxonomy unique_id / display name.
func scrubCategoryPollution(out *StepResult, namesByUID map[string]string, valid map[string]struct{}) {
if out == nil {
return
}
titles := []string{out.ProcessedName, out.Name}
clearCategory := func(note string) {
out.Category = ""
out.CategoryName = ""
if out.FieldSources == nil {
out.FieldSources = map[string]any{}
}
out.FieldSources["category"] = "cleared_invalid"
out.Notes = append(out.Notes, note)
}
cat := strings.TrimSpace(out.Category)
if cat != "" {
switch {
case isPromptLabelTitle(cat):
clearCategory("category: ignored (prompt leakage)")
case categoryTokenEqualsProductTitle(cat, titles...) &&
resolveCompanyCategoryUniqueID(cat, namesByUID, valid) == "":
clearCategory("category: ignored (product title)")
}
}
name := strings.TrimSpace(out.CategoryName)
if name == "" {
return
}
if isPromptLabelTitle(name) {
out.CategoryName = ""
return
}
if categoryTokenEqualsProductTitle(name, titles...) {
if _, ok := taxonomyDisplayNameSet(namesByUID)[strings.ToLower(name)]; !ok {
out.CategoryName = ""
}
}
2026-08-16 16:57:36 +02:00
}
// preferredProductTitle picks the first usable title, skipping empty values,
// prompt-label echoes like "Category:", and instruction-text leakage.
func preferredProductTitle(gtin string, candidates ...string) string {
for _, c := range candidates {
c = strings.TrimSpace(c)
if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
continue
}
return SanitizeOutput(c)
}
if strings.TrimSpace(gtin) != "" {
return SanitizeText("Product " + strings.TrimSpace(gtin))
}
return "Product"
}
2026-08-16 16:57:36 +02:00
// Thin wrappers keep processing call sites stable; logic lives in company so
// catalog.RepairWeakEnhanceHashes can reuse it without an import cycle.
func isWeakPriorEnhanceDescription(priorDesc string, titles ...string) bool {
return company.IsWeakPriorEnhanceDescription(priorDesc, titles...)
}
func containsWeakFillerPhrase(desc string) bool {
return company.ContainsWeakFillerPhrase(desc)
}
func descriptionEchoesTitle(desc, title string) bool {
return company.DescriptionEchoesTitle(desc, title)
}
// preferredProductDescription picks the first usable description, skipping empty
// values, prompt-label echoes, weak filler phrases, and copy that merely repeats
// the product title.
func preferredProductDescription(title string, candidates ...string) string {
for _, c := range candidates {
c = strings.TrimSpace(c)
if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
continue
}
2026-08-16 16:57:36 +02:00
if descriptionEchoesTitle(c, title) {
continue
}
if containsWeakFillerPhrase(c) {
continue
}
return SanitizeOutput(c)
}
return ""
}
2026-08-16 16:57:36 +02:00
func attrLookupCI(attrs map[string]any, keys ...string) string {
if attrs == nil {
return ""
}
for _, want := range keys {
want = strings.TrimSpace(want)
if want == "" {
continue
}
if v := strings.TrimSpace(stringFromAny(attrs[want])); v != "" {
return v
}
for k, raw := range attrs {
if strings.EqualFold(strings.TrimSpace(k), want) {
if v := strings.TrimSpace(stringFromAny(raw)); v != "" {
return v
}
}
}
}
return ""
}
func formatAttrDimParts(attrs map[string]any, maxParts int) []string {
if attrs == nil || maxParts <= 0 {
return nil
}
prefer := []string{
"width", "height", "depth", "weight", "max_load", "max load", "load_capacity",
"vesa", "screen_size", "diagonal", "color", "material", "size",
}
parts := make([]string, 0, maxParts)
seen := map[string]struct{}{}
add := func(k, v string) {
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if k == "" || v == "" {
return
}
lk := strings.ToLower(k)
if _, ok := seen[lk]; ok {
return
}
seen[lk] = struct{}{}
parts = append(parts, fmt.Sprintf("%s %s", k, v))
}
for _, k := range prefer {
if len(parts) >= maxParts {
break
}
if v := attrLookupCI(attrs, k); v != "" {
add(k, v)
}
}
return parts
}
2026-08-16 19:21:49 +02:00
// synthesizeProductDescription prefers a category description_template skeleton
// (HTML section types) when present; otherwise falls back to plain title synthesize.
func synthesizeProductDescription(title, category, language string, attrs map[string]any, descriptionTemplate any) string {
if sections, ok := parseDescriptionFormulaSections(descriptionTemplate); ok && len(sections) > 0 {
if out := synthesizeDescriptionFromFormula(title, category, language, attrs, sections); out != "" {
return out
}
}
return synthesizeDescriptionFromTitle(title, category, language, attrs)
}
// synthesizeDescriptionFromFormula builds a minimal HTML description matching
// category description_template section types so timeout/fallback still respects
// A1 category structure (unlike plain title synthesize).
func synthesizeDescriptionFromFormula(title, category, language string, attrs map[string]any, sections []descriptionFormulaSection) string {
title = strings.TrimSpace(title)
if title == "" || title == "<nil>" || isPromptLabelTitle(title) {
return ""
}
base := synthesizeDescriptionFromTitle(title, category, language, attrs)
if base == "" {
return ""
}
dims := formatAttrDimParts(attrs, 6)
var b strings.Builder
paraUsed := false
listUsed := false
for _, s := range sections {
typ := strings.ToLower(strings.TrimSpace(s.Type))
switch typ {
case "h1", "h2", "h3", "h4":
heading := title
if typ != "h1" {
if isSlovenianContentLanguage(language) {
heading = "Ključne lastnosti"
} else {
heading = "Key features"
}
}
fmt.Fprintf(&b, "<%s>%s</%s>", typ, SanitizeOutput(heading), typ)
case "ul":
b.WriteString("<ul>")
items := dims
if len(items) == 0 {
items = []string{base}
}
for _, it := range items {
fmt.Fprintf(&b, "<li>%s</li>", SanitizeOutput(it))
}
b.WriteString("</ul>")
listUsed = true
default: // p and unknown → paragraph
body := base
if paraUsed && len(dims) > 0 && !listUsed {
if isSlovenianContentLanguage(language) {
body = "Ključne specifikacije: " + strings.Join(dims, ", ") + "."
} else {
body = "Key specs: " + strings.Join(dims, ", ") + "."
}
}
fmt.Fprintf(&b, "<p>%s</p>", SanitizeOutput(body))
paraUsed = true
}
}
return SanitizeOutput(b.String())
}
2026-08-16 16:57:36 +02:00
// synthesizeDescriptionFromTitle builds a short factual fallback when enhance
// returns empty/title-echo/filler copy. Uses title, category, brand, model, and
// key dims. language is a content-language code (en/sl/…) or English label.
func synthesizeDescriptionFromTitle(title, category, language string, attrs map[string]any) string {
title = strings.TrimSpace(title)
if title == "" || title == "<nil>" || isPromptLabelTitle(title) {
return ""
}
cat := strings.TrimSpace(category)
if strings.EqualFold(cat, "general") {
cat = ""
}
brand := attrLookupCI(attrs, "brand")
model := attrLookupCI(attrs, "product_model", "model", "sku")
dims := formatAttrDimParts(attrs, 3)
sl := isSlovenianContentLanguage(language)
var b strings.Builder
if sl {
b.WriteString(title)
switch {
case cat != "" && brand != "":
fmt.Fprintf(&b, " je izdelek v kategoriji %s znamke %s", cat, brand)
case cat != "":
fmt.Fprintf(&b, " je izdelek v kategoriji %s", cat)
case brand != "":
fmt.Fprintf(&b, " je izdelek znamke %s", brand)
default:
b.WriteString(" je katalogski izdelek z znanimi atributi")
}
if model != "" && !strings.Contains(strings.ToLower(title), strings.ToLower(model)) {
fmt.Fprintf(&b, " (model %s)", model)
}
if len(dims) > 0 {
fmt.Fprintf(&b, ". Ključne specifikacije: %s", strings.Join(dims, ", "))
}
b.WriteByte('.')
} else {
b.WriteString(title)
switch {
case cat != "" && brand != "":
fmt.Fprintf(&b, " is a %s product from %s", cat, brand)
case cat != "":
fmt.Fprintf(&b, " is listed in the %s category", cat)
case brand != "":
fmt.Fprintf(&b, " is a product from %s", brand)
default:
b.WriteString(" is a catalog product with the known attributes")
}
if model != "" && !strings.Contains(strings.ToLower(title), strings.ToLower(model)) {
fmt.Fprintf(&b, " (model %s)", model)
}
if len(dims) > 0 {
fmt.Fprintf(&b, ". Key specs: %s", strings.Join(dims, ", "))
}
b.WriteByte('.')
}
out := SanitizeOutput(b.String())
// Never emit sole retail-filler when title/attrs exist — strip legacy phrase if any helper reintroduces it.
if containsWeakFillerPhrase(out) {
out = strings.TrimSpace(strings.ReplaceAll(out, "Ready for retail listing.", ""))
out = strings.TrimSpace(strings.ReplaceAll(out, "ready for retail listing.", ""))
out = strings.TrimSpace(strings.Trim(out, ".")) + "."
}
return out
}
func isSlovenianContentLanguage(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
if code, err := company.ParseLanguage(raw, false); err == nil && code == "sl" {
return true
}
lower := strings.ToLower(raw)
return lower == "slovenian" || strings.Contains(lower, "slovenian")
}