fix
This commit is contained in:
@@ -303,6 +303,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
||||
CategoryEnhancePrompt: catPrompt,
|
||||
TitleTemplate: titleTpl,
|
||||
DescriptionTemplate: descTpl,
|
||||
AllowedAttrKeys: in.AllowedAttrKeys,
|
||||
CategoryAttrKeys: in.CategoryAttrKeys,
|
||||
PriorEnhanceHash: priorHash,
|
||||
PriorProcessedName: priorName,
|
||||
PriorProcessedDescription: priorDesc,
|
||||
@@ -355,6 +357,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
||||
}
|
||||
}
|
||||
weakDesc := descriptionNeedsEnhanceRepair(desc, descTpl, name)
|
||||
enhanceMetaTitle, enhanceMetaDesc := enhanceMetaFromRaw(raw)
|
||||
// Only persist enhance_input_hash for quality ok / hash-skip unchanged.
|
||||
// Never copy input_hash from error/passthrough/thin/synthesized meta.
|
||||
persistHash := ""
|
||||
@@ -380,8 +383,8 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
||||
ProcessedName: name,
|
||||
ProcessedDescription: desc,
|
||||
EnhanceInputHash: persistHash,
|
||||
MetaTitle: company.FieldsForLanguage(localized, lang).MetaTitle,
|
||||
MetaDescription: company.FieldsForLanguage(localized, lang).MetaDescription,
|
||||
MetaTitle: enhanceMetaTitle,
|
||||
MetaDescription: enhanceMetaDesc,
|
||||
}
|
||||
// Preserve existing meta when re-enhancing titles only.
|
||||
// Never keep a bare "| <unique_id>" poisoned meta_title.
|
||||
@@ -410,6 +413,23 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
||||
if desc != "" {
|
||||
out.Description = desc
|
||||
}
|
||||
if lf := localized[lang]; lf.MetaTitle != "" {
|
||||
out.MetaTitle = lf.MetaTitle
|
||||
}
|
||||
if lf := localized[lang]; lf.MetaDescription != "" {
|
||||
out.MetaDescription = lf.MetaDescription
|
||||
}
|
||||
// Merge LLM attrs onto pipeline attrs (validated against category keys).
|
||||
// Attrs are independent of title/description soft-fail (synthesized/refused).
|
||||
if err == nil && status != "failed" && status != "parse_failed" {
|
||||
if merged := mergeEnhanceAttrsInto(attrs, enhanceAttrsFromRaw(raw), enhanceAllowedAttrKeys(in, out.Category)); len(merged) > 0 {
|
||||
attrs = merged
|
||||
enhanceAttrs = AttrsForEnhance(attrs, enhanceAllowedAttrKeys(in, out.Category))
|
||||
out.Attributes = attrs
|
||||
out.ProcessedAttributes = attrs
|
||||
out.FieldSources["attributes"] = "ai_enhance"
|
||||
}
|
||||
}
|
||||
if persistHash != "" {
|
||||
out.FieldSources[FieldEnhanceInputHash] = persistHash
|
||||
} else {
|
||||
@@ -818,6 +838,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
|
||||
}
|
||||
llmName := SanitizeOutput(fmt.Sprint(obj["name"]))
|
||||
llmDesc := SanitizeOutput(fmt.Sprint(obj["description"]))
|
||||
llmMetaTitle, llmMetaDesc := metaFieldsFromEnhanceObj(obj)
|
||||
llmAttrs := attrsFromEnhanceObj(obj)
|
||||
// Never prefer-fallback to originals for empty/leakage LLM fields — that stamped
|
||||
// status=ok + input_hash on garbage enhance output (prod: 30–272ms "ok", wrong copy).
|
||||
if reason := llmEnhanceHardRefuseReason(llmName, llmDesc); reason != "" {
|
||||
@@ -846,6 +868,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
|
||||
"reason": reason,
|
||||
"raw": comp.Raw,
|
||||
}
|
||||
attachEnhanceMeta(meta, llmMetaTitle, llmMetaDesc)
|
||||
attachEnhanceAttrs(meta, llmAttrs)
|
||||
if forceReason != "" {
|
||||
meta["forced_reenhance"] = true
|
||||
meta["force_reason"] = forceReason
|
||||
@@ -878,6 +902,16 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
|
||||
if llmEnhanceHardRefuseReason(n2, d2) == "" {
|
||||
name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName)
|
||||
desc = preferredProductDescription(name, d2)
|
||||
mt2, md2 := metaFieldsFromEnhanceObj(obj2)
|
||||
if mt2 != "" {
|
||||
llmMetaTitle = mt2
|
||||
}
|
||||
if md2 != "" {
|
||||
llmMetaDesc = md2
|
||||
}
|
||||
if a2 := attrsFromEnhanceObj(obj2); len(a2) > 0 {
|
||||
llmAttrs = a2
|
||||
}
|
||||
if comp2.Raw != nil {
|
||||
comp.Raw = comp2.Raw
|
||||
}
|
||||
@@ -903,6 +937,8 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
|
||||
"status": "ok",
|
||||
"raw": comp.Raw,
|
||||
}
|
||||
attachEnhanceMeta(meta, llmMetaTitle, llmMetaDesc)
|
||||
attachEnhanceAttrs(meta, llmAttrs)
|
||||
if forceReason != "" {
|
||||
meta["reason"] = forceReason
|
||||
meta["forced_reenhance"] = true
|
||||
@@ -942,6 +978,115 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
|
||||
return name, desc, comp.TotalTokens, meta, nil
|
||||
}
|
||||
|
||||
// attachEnhanceMeta stores parsed SEO fields on enhance raw meta for RunSteps.
|
||||
func attachEnhanceMeta(meta map[string]any, title, description string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
if title != "" {
|
||||
meta["meta_title"] = title
|
||||
}
|
||||
if description != "" {
|
||||
meta["meta_description"] = description
|
||||
}
|
||||
}
|
||||
|
||||
// attachEnhanceAttrs stores parsed attrs on enhance raw meta for RunSteps merge.
|
||||
func attachEnhanceAttrs(meta map[string]any, attrs map[string]any) {
|
||||
if meta == nil || len(attrs) == 0 {
|
||||
return
|
||||
}
|
||||
meta["attrs"] = attrs
|
||||
}
|
||||
|
||||
// attrsFromEnhanceObj extracts an attrs/attributes object from enhance JSON.
|
||||
func attrsFromEnhanceObj(obj map[string]any) map[string]any {
|
||||
if obj == nil {
|
||||
return nil
|
||||
}
|
||||
raw := obj["attrs"]
|
||||
if raw == nil {
|
||||
raw = obj["attributes"]
|
||||
}
|
||||
return coerceAttrMap(raw)
|
||||
}
|
||||
|
||||
// enhanceAttrsFromRaw reads attrs stashed on enhance raw meta by attachEnhanceAttrs.
|
||||
func enhanceAttrsFromRaw(raw any) map[string]any {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok || m == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := m["attrs"]; ok {
|
||||
return coerceAttrMap(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coerceAttrMap normalizes JSON object / map[string]string into map[string]any.
|
||||
func coerceAttrMap(raw any) map[string]any {
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" || val == nil {
|
||||
continue
|
||||
}
|
||||
out[k] = val
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
case map[string]string:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
k = strings.TrimSpace(k)
|
||||
val = strings.TrimSpace(val)
|
||||
if k == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = val
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// mergeEnhanceAttrsInto merges LLM attrs onto base after AttrsForEnhance validation
|
||||
// (MapAttrsOntoAllowedKeys + category allowlist). Empty LLM attrs → nil (no change).
|
||||
func mergeEnhanceAttrsInto(base, llmAttrs map[string]any, allowed map[string]struct{}) map[string]any {
|
||||
if len(llmAttrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
validated := AttrsForEnhance(llmAttrs, allowed)
|
||||
if len(validated) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(base)+len(validated))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range validated {
|
||||
if !attrValuePresent(v) {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1042,7 +1187,8 @@ func isPromptLabelTitle(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, label := range []string{
|
||||
"category", "name", "desc", "description",
|
||||
"attrs", "attributes", "current name", "current description",
|
||||
"meta", "meta_title", "meta_description", "metadescription",
|
||||
"attrs", "attributes", "current name", "current description", "current meta",
|
||||
} {
|
||||
if lower == label || lower == label+":" {
|
||||
return true
|
||||
@@ -1092,6 +1238,7 @@ var promptLeakagePhrases = []string{
|
||||
"reply with only json",
|
||||
"your reply is parsed as json",
|
||||
"description formula",
|
||||
"seo meta formula",
|
||||
"build name from attrs",
|
||||
"prefer attrs values",
|
||||
"order matters; join with",
|
||||
@@ -1114,6 +1261,23 @@ var promptLeakagePhrases = []string{
|
||||
"1-2 sentences",
|
||||
"keep literal text as written",
|
||||
"build name from attrs using this structure",
|
||||
"category guidance",
|
||||
"applies to name and description",
|
||||
"apply it to both",
|
||||
"never ignore the title formula",
|
||||
// Role-sectioned CategoryEnhanceUserTemplate / KeySEOMeta markers.
|
||||
"--- title ---",
|
||||
"--- end title ---",
|
||||
"--- description ---",
|
||||
"--- end description ---",
|
||||
"--- meta ---",
|
||||
"--- end meta ---",
|
||||
"--- attributes ---",
|
||||
"--- end attributes ---",
|
||||
"role: title",
|
||||
"role: description",
|
||||
"role: meta",
|
||||
"role: attributes",
|
||||
}
|
||||
|
||||
var promptLeakageLongKeywords = []string{
|
||||
@@ -1217,21 +1381,75 @@ func scrubCategoryPollution(out *StepResult, namesByUID map[string]string, valid
|
||||
}
|
||||
|
||||
// preferredProductTitle picks the first usable title, skipping empty values,
|
||||
// prompt-label echoes like "Category:", and instruction-text leakage.
|
||||
// prompt-label echoes like "Category:", instruction-text leakage, and brand-only
|
||||
// stubs when a longer product name exists among the candidates (e.g. "ANKER"
|
||||
// vs "Anker Soundcore Space One Pro").
|
||||
func preferredProductTitle(gtin string, candidates ...string) string {
|
||||
usable := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
|
||||
continue
|
||||
}
|
||||
usable = append(usable, c)
|
||||
}
|
||||
for _, c := range usable {
|
||||
if isBrandOnlyTitleAmong(c, usable) {
|
||||
continue
|
||||
}
|
||||
return SanitizeOutput(c)
|
||||
}
|
||||
// All remaining candidates are brand-only relative to each other — pick longest.
|
||||
best := ""
|
||||
for _, c := range usable {
|
||||
if len([]rune(c)) > len([]rune(best)) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return SanitizeOutput(best)
|
||||
}
|
||||
if strings.TrimSpace(gtin) != "" {
|
||||
return SanitizeText("Product " + strings.TrimSpace(gtin))
|
||||
}
|
||||
return "Product"
|
||||
}
|
||||
|
||||
// isBrandOnlyTitleAmong is true when candidate looks like a brand stub that a
|
||||
// longer candidate expands (prefix / first-token match).
|
||||
func isBrandOnlyTitleAmong(candidate string, all []string) bool {
|
||||
c := strings.TrimSpace(candidate)
|
||||
if c == "" {
|
||||
return false
|
||||
}
|
||||
words := strings.Fields(c)
|
||||
if len(words) > 2 {
|
||||
return false
|
||||
}
|
||||
if len(words) == 1 && len([]rune(c)) > 40 {
|
||||
return false
|
||||
}
|
||||
clower := strings.ToLower(c)
|
||||
for _, other := range all {
|
||||
o := strings.TrimSpace(other)
|
||||
if o == "" || strings.EqualFold(o, c) {
|
||||
continue
|
||||
}
|
||||
if len([]rune(o)) <= len([]rune(c)) {
|
||||
continue
|
||||
}
|
||||
olower := strings.ToLower(o)
|
||||
if strings.HasPrefix(olower, clower+" ") || strings.HasPrefix(olower, clower+"-") {
|
||||
return true
|
||||
}
|
||||
oWords := strings.Fields(o)
|
||||
if len(words) == 1 && len(oWords) >= 2 && strings.EqualFold(oWords[0], words[0]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Reference in New Issue
Block a user