This commit is contained in:
2026-08-23 20:49:40 +02:00
parent 3b678ca857
commit f3d4fb56ed
31 changed files with 3608 additions and 111 deletions
+96 -20
View File
@@ -48,10 +48,12 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
in.Name,
in.PriorProcessedName,
)
out.Description = preferredProductDescription(out.Name,
// Original is supplier copy only. PriorProcessedDescription is the last
// ENRICHED text — using it here made Review report "Matched" against copy
// the feed never contained.
out.Description = supplierOriginalDescription(out.Name,
stringFromAny(normalized["description"]),
in.Description,
in.PriorProcessedDescription,
)
// mapped_data.category / category_unique_id (unique_id codes) win.
applyCategoryFromMapped(&out, normalized, in.Mapped, in.Raw)
@@ -104,7 +106,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
in.Name,
in.PriorProcessedName,
)
out.Description = preferredProductDescription(out.Name,
out.Description = supplierOriginalDescription(out.Name,
stringFromAny(normalized["description"]),
out.Description,
in.Description,
@@ -451,7 +453,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
out.ProcessedDescription = out.Description
}
// Timeout/error/empty/formula-miss: always synthesize a factual fallback when a title exists.
_, failDescTpl := categoryFormulasFor(in, out.Category)
failDescTpl := effectiveDescriptionTemplateFor(in, out)
if out.ProcessedName != "" && descriptionNeedsEnhanceRepair(out.ProcessedDescription, failDescTpl, out.ProcessedName, out.Name) {
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
out.ProcessedDescription = synth
@@ -528,17 +530,22 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
}
}
// Pipelines without StepCategorize (enhance_only / normalize_only) still run
// vector + LLM categorize when category is empty so enhance is not fed a blank.
// Pipelines without StepCategorize (normalize_only) still run vector + LLM
// categorize when category is empty so downstream steps are not fed a blank.
if !stepsContain(steps, StepCategorize) {
runCategorizeStep(ctx, e, companyID, &out, in, categoryNames, policy)
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
}
// Record why category stayed empty for every pipeline, including the ones that
// now categorize inside the loop (enhance / title / description).
noteMissingCategory(&out, policy, e != nil && e.Vector != nil && e.Vector.Enabled())
preserveCategoryIfEmpty(&out, in.PriorCategory)
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)
out.Description = preferredProductDescription(out.Name, out.Description, out.ProcessedDescription, in.Description)
// Original stays supplier copy; only the enriched side may borrow from it.
// Filling Original from ProcessedName/Description is what produced Review rows
// that read "Matched" while the Feed tab still showed the real supplier text.
out.Name = preferredProductTitle(in.GTIN, out.Name, in.Name)
out.ProcessedName = finalProcessedTitle(in, &out)
out.Description = supplierOriginalDescription(out.Name, out.Description, in.Description)
out.ProcessedDescription = preferredProductDescription(out.ProcessedName, out.ProcessedDescription, out.Description, in.PriorProcessedDescription)
if out.ProcessedName == "" {
out.ProcessedName = out.Name
@@ -549,7 +556,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
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.
_, finalDescTpl := categoryFormulasFor(in, out.Category)
finalDescTpl := effectiveDescriptionTemplateFor(in, out)
finalRepaired := false
if descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.ProcessedName, out.Name) {
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" {
@@ -684,7 +691,12 @@ func noteMissingCategory(out *StepResult, policy StepPolicy, vectorEnabled bool)
func resolveSteps(processingType string) []string {
switch strings.ToLower(strings.TrimSpace(processingType)) {
case "enhance", "enhance_only", "enhance-only", "title", "description":
return []string{StepNormalize, StepAIEnhance}
// parse_specs/fill_fields/categorize feed ai_enhance — they never rewrite
// title/description themselves. Without them enhance ran with empty Attrs
// and (for feeds without a mapped category) no category, so the category
// title/description formula could not key and the model echoed the feed.
// categorize is a no-op when the category is already set.
return []string{StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance}
case "attributes", "attributes_only", "specs", "specifications":
return []string{StepNormalize, StepParseSpecs, StepFillFields}
case "eprel", "eprel_only":
@@ -779,10 +791,10 @@ func normalizeTitleCompare(s string) string {
return strings.TrimSpace(b.String())
}
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
if !titlesAreEquivalent(name, in.Name) {
return false
}
// categoryRequiresTitleRewrite reports that the category asks enhance for a NEW
// product name — a structured title_template, or free-form Title instructions that
// spell out a naming formula (legacy A1 <name>{…}</name>).
func categoryRequiresTitleRewrite(in ProductInput) bool {
if aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) {
return true
}
@@ -792,6 +804,62 @@ func titleNeedsRewriteRetry(in ProductInput, name string) bool {
return FormatTitleSectionConstraint(in.CategoryEnhancePrompt) != ""
}
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
return titlesAreEquivalent(name, in.Name) && categoryRequiresTitleRewrite(in)
}
// formulaAwareTitle keeps a formula-built name even when it is shorter than — and a
// prefix of — the supplier Name. preferredProductTitle's brand-stub rule would
// otherwise prefer the longer feed title and silently undo the Title formula
// (A1 formulas like "znamka tip model" often are a prefix of the feed name).
func formulaAwareTitle(in ProductInput, llmName string) string {
llmName = strings.TrimSpace(llmName)
if llmName != "" && llmName != "<nil>" && !isPromptLabelTitle(llmName) &&
!titlesAreEquivalent(llmName, in.Name) && categoryRequiresTitleRewrite(in) {
return preferredProductTitle(in.GTIN, llmName)
}
return preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
}
// finalProcessedTitle settles ProcessedName after category resolution. A name the
// enhance step built from an active category Title formula is kept as-is; only an
// empty/unusable one falls back to the supplier title.
func finalProcessedTitle(in ProductInput, out *StepResult) string {
processed := strings.TrimSpace(out.ProcessedName)
if processed != "" && processed != "<nil>" && !isPromptLabelTitle(processed) &&
!titlesAreEquivalent(processed, out.Name) {
titleTpl, _ := categoryFormulasFor(in, out.Category)
formulaIn := in
formulaIn.TitleTemplate = titleTpl
if formulaIn.CategoryEnhancePrompt == "" {
formulaIn.CategoryEnhancePrompt = categoryEnhancePromptFor(
in.CategoryPromptsByLang, out.Category, in.Language, in.Language)
}
if categoryRequiresTitleRewrite(formulaIn) {
return preferredProductTitle(in.GTIN, processed)
}
}
return preferredProductTitle(in.GTIN, out.ProcessedName, out.Name, in.PriorProcessedName, in.Name)
}
// supplierOriginalDescription picks the Original (pre-enrich) description from
// supplier candidates only. When every candidate trips a quality filter it still
// returns the raw feed text — Original is meant to mirror the feed, and leaving it
// blank (or borrowing enriched copy) is what made Review show a false "Matched".
func supplierOriginalDescription(title string, candidates ...string) string {
if desc := preferredProductDescription(title, candidates...); desc != "" {
return desc
}
for _, c := range candidates {
c = strings.TrimSpace(c)
if c == "" || c == "<nil>" || isPromptLabelTitle(c) {
continue
}
return SanitizeOutput(c)
}
return ""
}
// 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 {
@@ -831,13 +899,19 @@ func normalizeDescCompare(s string) string {
}
func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, attrs map[string]any) (string, string, int, any, error) {
catUID := strings.TrimSpace(in.CategoryUniqueID)
catName := strings.TrimSpace(category)
if in.CategoryDisplayName == "" {
in.CategoryDisplayName = catName
}
// Fill the platform default before anything reads the formula, so the prompt the
// model receives and the gate its reply is judged against are the same formula.
in = withDefaultCategoryFormula(in)
// Prompt Description HTML overlays count as the formula when description_template
// is empty — otherwise enhance accepts supplier-like prose and skips formula retry.
if effective := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, in.CategoryEnhancePrompt); effective != nil {
in.DescriptionTemplate = effective
}
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 {
@@ -967,7 +1041,7 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string,
return name, desc, comp.TotalTokens, meta, nil
}
name := preferredProductTitle(in.GTIN, llmName, in.Name, in.PriorProcessedName)
name := formulaAwareTitle(in, llmName)
// Quality-gate on LLM description alone (do not absorb originals yet).
desc := preferredProductDescription(name, llmDesc)
didFormulaRetry := false
@@ -1582,7 +1656,9 @@ func preferredProductDescription(title string, candidates ...string) string {
if containsWeakFillerPhrase(c) {
continue
}
if company.LooksLikeHeuristicSynthesize(c) {
// Strict test only: this drops the candidate outright, so ambiguous
// skeleton wording must not disqualify real formula copy.
if company.IsInventFallbackDescription(c) {
continue
}
return SanitizeOutput(c)