diff --git a/apps/api/internal/company/weak_desc.go b/apps/api/internal/company/weak_desc.go index 3b891d9..dfbc76a 100644 --- a/apps/api/internal/company/weak_desc.go +++ b/apps/api/internal/company/weak_desc.go @@ -49,6 +49,9 @@ var heuristicSynthesizePhrases = []string{ " — katalogski izdelek", " — from ", " — catalog product", + // synthesizeDescriptionFromFormula heading fallbacks + "ključne lastnosti", + "key features", } // LooksLikeHeuristicSynthesize reports invent / formula-skeleton fallback copy. diff --git a/apps/api/internal/processing/enhance_preserve_original_test.go b/apps/api/internal/processing/enhance_preserve_original_test.go new file mode 100644 index 0000000..a54adc9 --- /dev/null +++ b/apps/api/internal/processing/enhance_preserve_original_test.go @@ -0,0 +1,146 @@ +package processing + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" + "github.com/descrybe/descrybe-v2/apps/api/internal/company" +) + +// Regression: enhance used to stamp processed_* onto name/description, so Review +// always showed Matched — and formula invent ("Ključne lastnosti") overwrote the +// feed Original. Original must stay supplier; enriched must change. +func TestRunSteps_enhancePreservesOriginalVsEnriched(t *testing.T) { + t.Parallel() + prompt := aiprompts.SectionTitleStart + "\n" + + `Napiši novo ime izdelka po formuli: tip znamka model barva.` + "\n" + + aiprompts.SectionTitleEnd + "\n\n" + + aiprompts.SectionDescriptionStart + "\n" + + `
{Odstavek}
" + strings.Repeat("Udobje na poti. ", 15) + + "
{Odstavek}
" + long + "
" + in.Description = "" + long + "
" + if got := enhanceHashForceReason(in); got != "desc-copy-paste" { + t.Fatalf("got %q want desc-copy-paste", got) + } +} diff --git a/apps/api/internal/processing/pipeline_llm_mock_test.go b/apps/api/internal/processing/pipeline_llm_mock_test.go index 4c5ae22..f0f54b3 100644 --- a/apps/api/internal/processing/pipeline_llm_mock_test.go +++ b/apps/api/internal/processing/pipeline_llm_mock_test.go @@ -49,7 +49,7 @@ func TestRunSteps_enhanceMockHappyPath(t *testing.T) { Completer: stubCompleter{fn: func(system, _ string) (Completion, error) { gotSystem = system return Completion{ - Text: `{"name":"Mock Shoe","description":"Light runner for tests."}`, + Text: `{"name":"Mock Shoe","description":"Mock Shoe is a light runner built for daily miles, with cushioned foam midsole and a grippy outsole that stays stable on wet pavement."}`, TotalTokens: 11, Model: "mock", }, nil @@ -68,6 +68,15 @@ func TestRunSteps_enhanceMockHappyPath(t *testing.T) { if out.ProcessedName != "Mock Shoe" { t.Fatalf("ProcessedName=%q", out.ProcessedName) } + if out.Name != "Shoe" { + t.Fatalf("Original Name must stay supplier, got %q", out.Name) + } + if out.Description != "runner" { + t.Fatalf("Original Description must stay supplier, got %q", out.Description) + } + if out.ProcessedDescription != "Mock Shoe is a light runner built for daily miles, with cushioned foam midsole and a grippy outsole that stays stable on wet pavement." { + t.Fatalf("ProcessedDescription=%q", out.ProcessedDescription) + } if out.TotalTokens != 11 { t.Fatalf("TotalTokens=%d", out.TotalTokens) } diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index 72fccc7..f084049 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -413,14 +413,11 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput } langMetas = append(langMetas, meta) if lang == primary { + // Enriched fields only — keep out.Name / out.Description as the + // supplier/normalized Original so Review can show a real diff + // (Matched when we stamp both sides is a false "no change"). out.ProcessedName = name out.ProcessedDescription = desc - if name != "" { - out.Name = name - } - if desc != "" { - out.Description = desc - } if lf := localized[lang]; lf.MetaTitle != "" { out.MetaTitle = lf.MetaTitle } @@ -458,9 +455,7 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput if out.ProcessedName != "" && descriptionNeedsEnhanceRepair(out.ProcessedDescription, failDescTpl, out.ProcessedName, out.Name) { if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" { out.ProcessedDescription = synth - if out.Description == "" || descriptionNeedsEnhanceRepair(out.Description, failDescTpl, out.Name, out.ProcessedName) { - out.Description = synth - } + // Never stamp invent fallback onto Original description. if lf, ok := localized[primary]; ok { lf.ProcessedDescription = synth if lf.ProcessedName == "" { @@ -562,18 +557,9 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput finalRepaired = true } } - if descriptionNeedsEnhanceRepair(out.Description, finalDescTpl, out.Name, out.ProcessedName) { - if out.ProcessedDescription != "" && !descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.Name, out.ProcessedName) { - out.Description = out.ProcessedDescription - finalRepaired = true - } else if synth := synthesizeProductDescription(out.Name, displayCat, in.Language, out.Attributes, finalDescTpl); synth != "" { - out.Description = synth - finalRepaired = true - if descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.ProcessedName, out.Name) { - out.ProcessedDescription = synth - } - } - } + // Original description stays supplier/normalized feed copy. Formula HTML belongs + // only on processed_description — copying synth onto description made Review + // always Matched with invent "Ključne lastnosti" instead of a real enrich diff. if finalRepaired { delete(out.FieldSources, FieldEnhanceInputHash) primary := strings.TrimSpace(in.Language) @@ -794,10 +780,16 @@ func normalizeTitleCompare(s string) string { } func titleNeedsRewriteRetry(in ProductInput, name string) bool { - if !aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) { + if !titlesAreEquivalent(name, in.Name) { return false } - return titlesAreEquivalent(name, in.Name) + if aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) { + return true + } + if FormatTitleFormulaConstraint(in.TitleTemplate) != "" { + return true + } + return FormatTitleSectionConstraint(in.CategoryEnhancePrompt) != "" } // enhanceHashForceReason returns why a matching prior enhance_input_hash must not @@ -815,9 +807,29 @@ func enhanceHashForceReason(in ProductInput) string { if titleNeedsRewriteRetry(in, in.PriorProcessedName) { return "title-copy-paste" } + // Prior enrich left description identical to supplier — never hash-skip that. + if descriptionsAreEquivalent(in.PriorProcessedDescription, in.Description) { + return "desc-copy-paste" + } return "" } +// descriptionsAreEquivalent compares supplier vs enriched description ignoring +// trivial whitespace / case so copy-paste enhance cannot hash-skip forever. +func descriptionsAreEquivalent(a, b string) bool { + na := normalizeDescCompare(a) + nb := normalizeDescCompare(b) + if na == "" || nb == "" { + return false + } + return na == nb +} + +func normalizeDescCompare(s string) string { + s = stripHTMLTags(s) + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(s))), " ") +} + func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, attrs map[string]any) (string, string, int, any, error) { // Prompt Description HTML overlays count as the formula when description_template // is empty — otherwise enhance accepts supplier-like prose and skips formula retry.