This commit is contained in:
2026-08-16 23:42:00 +02:00
parent 6e77154228
commit 92f046b542
32 changed files with 1443 additions and 105 deletions
+222 -4
View File
@@ -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: 30272ms "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 {