package processing import ( "context" "strings" "testing" "github.com/descrybe/descrybe-v2/apps/api/internal/company" ) func TestHashEnhanceInput_stableAndSensitive(t *testing.T) { attrs := map[string]any{"brand": "Acme", "color": "Red"} a := HashEnhanceInput("Shoes", "Runner", "A shoe", "", "", "", "", attrs) b := HashEnhanceInput("Shoes", "Runner", "A shoe", "", "", "", "", attrs) if a == "" || a != b { t.Fatalf("expected stable hash, got %q vs %q", a, b) } if HashEnhanceInput("Shoes", "Runner X", "A shoe", "", "", "", "", attrs) == a { t.Fatal("name change must change hash") } if HashEnhanceInput("Shoes", "Runner", "A shoe", "Brand:\n- tone: bold", "", "", "", attrs) == a { t.Fatal("brand prompt must change hash") } if HashEnhanceInput("Shoes", "Runner", "A shoe", "", "", "sys-a", "user-a", attrs) == a { t.Fatal("prompt template change must change hash") } if HashEnhanceInput("Shoes", "Runner", "A shoe", "", "fr", "", "", attrs) == a { t.Fatal("language change must change hash") } // Attr key order must not matter (CompactAttrs + json map sort). attrs2 := map[string]any{"color": "Red", "brand": "Acme"} if HashEnhanceInput("Shoes", "Runner", "A shoe", "", "", "", "", attrs2) != a { t.Fatal("attr key order must not change hash") } } // enhanceHashFor runs the real pipeline once with a compliant stub reply and // returns the enhance_input_hash it stamped. Deriving it this way keeps hash-skip // tests correct as the steps feeding enhance (parsed specs, filled fields, // category) evolve — hard-coding HashEnhanceInput args silently drifts instead. func enhanceHashFor(t *testing.T, in ProductInput, processingType string) string { t.Helper() e := &Engine{ Completer: stubCompleter{fn: func(string, string) (Completion, error) { return Completion{ Text: `{"name":"Seed Title","description":"Seed retail copy with enough factual detail to pass the enhance quality gate."}`, TotalTokens: 1, }, nil }}, Vector: NoopVectorCategorizer{}, } seed := in seed.PriorEnhanceHash = "" seed.PriorProcessedName = "" seed.PriorProcessedDescription = "" out, err := e.RunSteps(context.Background(), "co", seed, processingType, nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } hash, _ := out.FieldSources[FieldEnhanceInputHash].(string) if hash == "" { t.Fatalf("seed run did not stamp an enhance hash: sources=%v notes=%v", out.FieldSources, out.Notes) } return hash } func TestRunSteps_skipsEnhanceWhenInputHashUnchanged(t *testing.T) { calls := 0 e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ return Completion{Text: `{"name":"ShouldNotRun","description":"Nope"}`, TotalTokens: 9}, nil }}, Vector: NoopVectorCategorizer{}, } priorDesc := "Cached description with enough retail detail for listing copy." in := ProductInput{ Name: "Widget", Description: "A widget with enough mapped detail for hashing.", Mapped: map[string]any{"name": "Widget", "description": "A widget with enough mapped detail for hashing."}, PriorProcessedName: "Cached Widget", PriorProcessedDescription: priorDesc, } in.PriorEnhanceHash = enhanceHashFor(t, in, "enhance_only") out, err := e.RunSteps(context.Background(), "co", in, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls != 0 { t.Fatalf("expected LLM skip, calls=%d", calls) } if out.ProcessedName != "Cached Widget" { t.Fatalf("name=%q", out.ProcessedName) } if out.ProcessedDescription != priorDesc { t.Fatalf("desc=%q", out.ProcessedDescription) } if out.TotalTokens != 0 { t.Fatalf("tokens=%d want 0", out.TotalTokens) } if out.FieldSources[FieldEnhanceInputHash] != in.PriorEnhanceHash { t.Fatalf("hash field=%v", out.FieldSources[FieldEnhanceInputHash]) } if src, _ := out.FieldSources["name"].(string); src != "ai_enhance_unchanged" { t.Fatalf("name source=%v", out.FieldSources["name"]) } if !out.SkipCreditDebit { t.Fatal("expected SkipCreditDebit when enhance hash unchanged") } if shouldDebitProductProcessing(false, out) { t.Fatal("processOne must not debit when enhance unchanged") } // Hash-skip (0 tokens) must still persist known engine mode, not "unknown". if out.AIProviderMode != AIProviderInternal { t.Fatalf("ai_provider_mode=%q want %q on hash-skip", out.AIProviderMode, AIProviderInternal) } // processOne treats unknown as empty and prefers job modeLabel. got := preferKnownProviderMode(AIProviderUnknown, AIProviderInternal) if got != AIProviderInternal { t.Fatalf("preferKnownProviderMode(unknown, internal)=%q", got) } joined := strings.Join(out.Notes, ";") if !strings.Contains(joined, "ai_enhance_unchanged") { t.Fatalf("notes=%v", out.Notes) } } func TestRunSteps_callsEnhanceWhenInputHashDiffers(t *testing.T) { calls := 0 e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ return Completion{Text: `{"name":"Fresh","description":"Fresh retail copy with enough detail for a quality enhance hash."}`, TotalTokens: 3}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ Mapped: map[string]any{"name": "Widget", "description": "A widget"}, PriorEnhanceHash: "deadbeef", PriorProcessedName: "Old", PriorProcessedDescription: "Old desc", }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls != 1 { t.Fatalf("calls=%d", calls) } if out.ProcessedName != "Fresh" { t.Fatalf("name=%q", out.ProcessedName) } if out.TotalTokens != 3 { t.Fatalf("tokens=%d", out.TotalTokens) } if out.SkipCreditDebit { t.Fatal("hash miss must still debit") } if !shouldDebitProductProcessing(false, out) { t.Fatal("expected debit when enhance ran") } h, _ := out.FieldSources[FieldEnhanceInputHash].(string) if h == "" || h == "deadbeef" { t.Fatalf("expected new hash in field_sources, got %q", h) } } func TestRunSteps_reenhancesWhenPriorDescEqualsTitle(t *testing.T) { calls := 0 e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ return Completion{Text: `{"name":"Widget Pro","description":"A durable retail widget for everyday use with clear specs."}`, TotalTokens: 5}, nil }}, Vector: NoopVectorCategorizer{}, } in := ProductInput{ Name: "Widget", Description: "Widget", Mapped: map[string]any{"name": "Widget", "description": "Widget"}, PriorProcessedName: "Widget", PriorProcessedDescription: "Widget", } sysTpl, userTpl := resolveProductPromptTemplates(ProductInput{}) in.PriorEnhanceHash = HashEnhanceInput("", "Widget", "Widget", "", "", sysTpl, userTpl, map[string]any{}) out, err := e.RunSteps(context.Background(), "co", in, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls != 1 { t.Fatalf("expected LLM re-enhance when prior desc equals title, calls=%d", calls) } if out.ProcessedDescription == "Widget" || out.ProcessedDescription == "" { t.Fatalf("desc=%q", out.ProcessedDescription) } if out.SkipCreditDebit { t.Fatal("title-echo prior must still debit") } } func TestPreferredProductDescription_skipsTitleEcho(t *testing.T) { title := "Acme Widget Pro" if got := preferredProductDescription(title, title, "Acme Widget Pro.", "A durable retail widget for everyday use."); got != "A durable retail widget for everyday use." { t.Fatalf("got %q", got) } if got := preferredProductDescription(title, title, strings.ToUpper(title)); got != "" { t.Fatalf("expected empty when all candidates echo title, got %q", got) } if got := preferredProductDescription(title, "", "", "Category:"); got != "" { t.Fatalf("expected empty for unusable candidates, got %q", got) } } func TestRunSteps_emptyDescriptionGetsNonemptyProcessedDesc(t *testing.T) { calls := 0 e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ return Completion{Text: `{"name":"Gigabyte Monitor","description":""}`, TotalTokens: 4}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ Name: "Gigabyte Monitor", Mapped: map[string]any{"name": "Gigabyte Monitor", "brand": "Gigabyte"}, }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls != 1 { t.Fatalf("expected enhance call, calls=%d", calls) } if strings.TrimSpace(out.ProcessedDescription) == "" { t.Fatal("expected nonempty ProcessedDescription when title set") } if descriptionEchoesTitle(out.ProcessedDescription, out.ProcessedName) { t.Fatalf("ProcessedDescription must not echo title: name=%q desc=%q", out.ProcessedName, out.ProcessedDescription) } if descriptionEchoesTitle(out.ProcessedDescription, "Gigabyte Monitor") { t.Fatalf("ProcessedDescription must not echo mapped title: desc=%q", out.ProcessedDescription) } } func TestRunSteps_thinOKDoesNotPersistEnhanceHash(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { return Completion{Text: `{"name":"Monitor","description":"ok"}`, TotalTokens: 2}, nil }}, Vector: NoopVectorCategorizer{}, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ Name: "Monitor", Description: "Monitor", Mapped: map[string]any{"name": "Monitor", "description": "Monitor"}, }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h != "" && isWeakPriorEnhanceDescription(out.ProcessedDescription, out.ProcessedName) { t.Fatalf("must not persist hash with weak desc: hash=%q desc=%q", h, out.ProcessedDescription) } for _, lf := range out.LocalizedContent { if lf.EnhanceInputHash != "" && isWeakPriorEnhanceDescription(lf.ProcessedDescription, lf.ProcessedName) { t.Fatalf("localized hash poisoned with weak desc: %+v", lf) } } } func TestRunSteps_synthesizedDescDoesNotPersistEnhanceHash(t *testing.T) { e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { // Empty description forces synthesizeProductDescription. return Completion{Text: `{"name":"GIGABYTE GS27QC","description":""}`, TotalTokens: 3}, nil }}, Vector: NoopVectorCategorizer{}, } descTpl := map[string]any{ "sections": []any{ map[string]any{"type": "h1", "instructions": "Heading"}, map[string]any{"type": "p", "instructions": "Body"}, }, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ Name: "GIGABYTE GS27QC", Mapped: map[string]any{"name": "GIGABYTE GS27QC", "category": "7", "brand": "GIGABYTE"}, DescriptionTemplate: descTpl, Language: "sl", }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if !strings.Contains(out.ProcessedDescription, "

") { t.Fatalf("expected formula synthesize HTML, got %q", out.ProcessedDescription) } if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h != "" { t.Fatalf("synthesized description must not persist enhance hash (got %q)", h) } for _, lf := range out.LocalizedContent { if lf.EnhanceInputHash != "" { t.Fatalf("localized synthesized hash must be empty: %+v", lf) } } } func TestRunSteps_formulaIgnoredByLLM_retriesThenSynthesizes(t *testing.T) { calls := 0 var sawFormulaRetry bool e := &Engine{ Completer: stubCompleter{fn: func(system, user string) (Completion, error) { calls++ if strings.Contains(user, "INVALID DESCRIPTION") { sawFormulaRetry = true } // Non-weak short prose that ignores multi-section HTML formula. return Completion{ Text: `{"name":"GIGABYTE GS27QC","description":"GIGABYTE GS27QC is a gaming monitor with a 61 cm curved panel for immersive play."}`, TotalTokens: 4, }, nil }}, Vector: NoopVectorCategorizer{}, } descTpl := map[string]any{ "sections": []any{ map[string]any{"type": "h1", "instructions": "Heading"}, map[string]any{"type": "p", "instructions": "Body"}, map[string]any{"type": "ul", "instructions": "Specs"}, }, } out, err := e.RunSteps(context.Background(), "co", ProductInput{ Name: "GIGABYTE GS27QC", Mapped: map[string]any{"name": "GIGABYTE GS27QC", "category": "7", "brand": "GIGABYTE", "width": "61 cm"}, CategoryFormulasByKey: map[string]CategoryFormulas{ "7": {DescriptionTemplate: descTpl}, }, Language: "en", }, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true}) if err != nil { t.Fatal(err) } if calls < 2 { t.Fatalf("expected formula retry after ignore, calls=%d", calls) } if !sawFormulaRetry { t.Fatal("expected INVALID DESCRIPTION retry user suffix") } for _, tag := range []string{"

", "

", "