From 3b678ca857b231891d923ccfc127a95e04df5dc5 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Sun, 23 Aug 2026 19:30:05 +0200 Subject: [PATCH] fix --- apps/api/internal/company/weak_desc.go | 3 + .../enhance_preserve_original_test.go | 146 ++++++++++++++++++ .../processing/pipeline_llm_mock_test.go | 11 +- apps/api/internal/processing/steps.go | 58 ++++--- 4 files changed, 194 insertions(+), 24 deletions(-) create mode 100644 apps/api/internal/processing/enhance_preserve_original_test.go 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" + + `

{Naslov}

{Odstavek}

` + "\n" + + aiprompts.SectionDescriptionEnd + + feedName := "BRUNNER zložljiv stol za kampiranje ONE SHOT 0404164N.C20" + feedDesc := "Predstavljajte si eleganten in izjemno lahek stol, zasnovan posebej za umetniške direktorje." + newName := "zložljiv stol Brunner ONE SHOT siv" + newDesc := "

Lahek kamp stol

" + strings.Repeat("Udobje na poti. ", 15) + + "

" + + reply, _ := json.Marshal(map[string]any{ + "name": newName, + "description": newDesc, + "meta_title": newName, + "meta_description": "Brunner ONE SHOT zložljiv kamp stol", + "attrs": map[string]any{"brand": "BRUNNER"}, + }) + e := &Engine{ + Completer: stubCompleter{fn: func(_, _ string) (Completion, error) { + return Completion{Text: string(reply), TotalTokens: 20, Model: "mock"}, nil + }}, + Vector: NoopVectorCategorizer{}, + } + descTpl := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) + out, err := e.RunSteps(context.Background(), "co-preserve", ProductInput{ + GTIN: "8022068075495", + Name: feedName, + Description: feedDesc, + Mapped: map[string]any{"name": feedName, "description": feedDesc, "brand": "BRUNNER", "category": "stolcki"}, + Language: "sl", + CategoryEnhancePrompt: prompt, + DescriptionTemplate: descTpl, + CategoryNamesByUID: map[string]string{"stolcki": "Stolčki"}, + CategoryUniqueID: "stolcki", + PriorCategory: "stolcki", + }, "enhance_only", nil, StepPolicy{AllowAI: true}) + if err != nil { + t.Fatal(err) + } + if out.Name != feedName { + t.Fatalf("Original name must stay feed, got %q", out.Name) + } + if out.Description != feedDesc { + t.Fatalf("Original description must stay feed, got %q", out.Description) + } + if out.ProcessedName != newName { + t.Fatalf("ProcessedName=%q want %q", out.ProcessedName, newName) + } + if !strings.Contains(out.ProcessedDescription, "

") { + t.Fatalf("ProcessedDescription missing formula HTML: %s", out.ProcessedDescription) + } + if out.ProcessedName == out.Name { + t.Fatal("Review must show name Changed (enriched ≠ original)") + } + if out.ProcessedDescription == out.Description { + t.Fatal("Review must show description Changed (enriched ≠ original)") + } +} + +func TestRunSteps_formulaInventDoesNotOverwriteOriginal(t *testing.T) { + t.Parallel() + prompt := aiprompts.SectionDescriptionStart + "\n" + + `

{Naslov}

{Odstavek}

` + "\n" + + aiprompts.SectionDescriptionEnd + feedName := "BRUNNER ONE SHOT" + feedDesc := "Predstavljajte si eleganten in izjemno lahek stol za umetniške direktorje." + // Completer echoes supplier — formula gate synthesizes processed HTML. + reply, _ := json.Marshal(map[string]any{ + "name": feedName, + "description": feedDesc, + }) + e := &Engine{ + Completer: stubCompleter{fn: func(_, _ string) (Completion, error) { + return Completion{Text: string(reply), TotalTokens: 8, Model: "mock"}, nil + }}, + Vector: NoopVectorCategorizer{}, + } + descTpl := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) + out, err := e.RunSteps(context.Background(), "co-invent", ProductInput{ + Name: feedName, + Description: feedDesc, + Mapped: map[string]any{"name": feedName, "description": feedDesc, "brand": "BRUNNER", "width": "0.44m"}, + Language: "sl", + CategoryEnhancePrompt: prompt, + DescriptionTemplate: descTpl, + }, "enhance_only", nil, StepPolicy{AllowAI: true}) + if err != nil { + t.Fatal(err) + } + if out.Description != feedDesc { + t.Fatalf("invent must not overwrite Original description, got %q", out.Description) + } + if !strings.Contains(strings.ToLower(out.ProcessedDescription), "ključne lastnosti") && + !company.LooksLikeHeuristicSynthesize(out.ProcessedDescription) && + !descriptionSatisfiesFormula(out.ProcessedDescription, descTpl) { + t.Fatalf("expected processed formula/synth HTML, got %q", out.ProcessedDescription) + } + if out.ProcessedDescription == out.Description { + t.Fatal("Original and Enriched must differ when invent fills processed only") + } +} + +func TestEnhanceHashForceReason_descCopyPaste(t *testing.T) { + t.Parallel() + long := "Supplier copy about the widget with enough characters to clear the weak-description floor for hash skip checks." + in := ProductInput{ + Name: "Widget Pro Retail Title", + Description: long, + PriorProcessedName: "Widget Pro Retail Title Enriched", + PriorProcessedDescription: long, + DescriptionTemplate: map[string]any{ + "sections": []any{ + map[string]any{"type": "h2", "instructions": "x"}, + map[string]any{"type": "p", "instructions": "y"}, + }, + }, + } + if got := enhanceHashForceReason(in); got != "formula-mismatch" { + t.Fatalf("got %q want formula-mismatch", got) + } + in.PriorProcessedDescription = "

A

" + long + "

" + in.Description = "

A

" + 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.