Files
descrybe/apps/api/internal/processing/enhance_formula_regression_test.go
T
2026-08-23 20:49:40 +02:00

270 lines
13 KiB
Go

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"
)
// Regressions for "enhance just copies the feed" on A1-style category formulas.
// Fixture is the reported product (GTIN 8022068075495, category Stolčki) with the
// committed A1 seed prompt and the formula columns as the live A1 catalog stores
// them: a brand-only title_template stub and a generic English description_template.
type formulaRegressionCompleter struct {
system, user string
calls int
reply string
}
func (c *formulaRegressionCompleter) Complete(_ context.Context, system, user string) (Completion, error) {
c.calls++
c.system, c.user = system, user
return Completion{Text: c.reply, TotalTokens: 10}, nil
}
func (c *formulaRegressionCompleter) Enabled() bool { return true }
// a1StolckiLegacyPrompt is the "Stolčki" entry of scripts/seed/a1-category-prompts.json.
const a1StolckiLegacyPrompt = "Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:\n\n" +
"Star_opis_izdelka: {\"\"OPIS IZDELKA\"\"};\nStaro_ime_izdelka: {\"\"STARO IME IZDELKA\"\"};\n\n" +
"Uporabi spodnjo GPT predlogo. \n\nSledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:\n\nGPT predloga:\n\n\n" +
"<name>{Napiši novo ime izdelka po formuli: \"\"znamka s pravilno kapitalizacijo\"\", \"\"tip izdelka lowercase\"\", " +
"\"\"\"poln model izdelka, če lahko z besedo in ID uppercase\"\"\". Ne uporabljaj vejic.}</name>\n" +
"<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>\n\n" +
"<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p>\n" +
"<H2>{Izpostavi en benefit, in NE napiši Novo ime izdelka}</H2>\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka.}</p>" +
"<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>"
const a1BrandOnlyTitleTemplate = `{"separator":" ","elements":[` +
`{"id":"0-variable-brand","type":"variable","label":"Znamka","value":"brand","example":"Samsung"}]}`
const a1GenericEnglishDescTemplate = `{"metaTitle":"Create a concise and compelling Meta Title.",` +
`"metaDescription":"Write a short Meta Description.",` +
`"sections":[{"id":"a7908e85","type":"p","instructions":"Write a detailed paragraph about specific features or benefits."}]}`
const a1FeedName = "BRUNNER zlažljiv stol za kampiranje ONE SHOT sivo črn 0404164N.C20"
const a1FeedDesc = "Eleganten in lahek stol za umetniške direktorje. Zložljiv aluminijast okvir, udobno sedišče in naslon za roke."
const a1FormulaName = "BRUNNER zložljiv stol za kampiranje ONE SHOT 0404164N.C20"
// a1FormulaReply obeys the Stolčki formula: h2 + p + h2 + p + spec list, in
// Slovenian. Its prose contains " je " and its spec list contains "Znamka:" —
// the combination that used to be misread as invent fallback and discarded.
const a1FormulaReply = `{"name":"` + a1FormulaName + `",
"description":"<h2>BRUNNER zložljiv stol ONE SHOT za udobje na poti</h2><p>Zložljiv stol iz aluminija je zasnovan za dolge ure udobnega sedenja na terenu, na snemanju ali ob kampiranju. Okvir se razpre v enem gibu in ostane stabilen tudi na neravnih tleh, medtem ko tkanina sedišča prijetno diha in se hitro suši. Naslonjala za roke razbremenijo ramena, hrbtni del pa podpira naravno držo. Zaradi majhne teže ga brez napora prenesete od avtomobila do prizorišča, zložen pa zavzame le malo prostora v prtljažniku.</p><h2>Stabilnost in dolga življenjska doba</h2><p>Konstrukcija združuje trpežne materiale in premišljene detajle. Aluminijasti profili so odporni proti koroziji, spoji pa so ojačani na mestih največjih obremenitev. Sivo črna kombinacija ostaja videti urejena tudi po sezoni uporabe na prostem, saj se madeži manj poznajo. Stol je enostavno očistiti z vlažno krpo, po uporabi pa ga preprosto zložite in shranite.</p><b>Tehnične specifikacije</b><ul><li>Znamka: BRUNNER</li><li>Model: ONE SHOT 0404164N.C20</li><li>Barva: sivo črna</li><li>Material okvirja: aluminij</li></ul>",
"meta_title":"BRUNNER zložljiv stol ONE SHOT",
"meta_description":"BRUNNER zložljiv stol za kampiranje ONE SHOT je lahek in stabilen zložljiv stol z udobnim sediščem za teren.",
"attrs":{"brand":"BRUNNER","product_model":"ONE SHOT 0404164N.C20"}}`
func jsonObject(t *testing.T, raw string) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
t.Fatal(err)
}
return m
}
func a1ProductInput(t *testing.T, catPrompt string, titleTpl, descTpl any) ProductInput {
t.Helper()
in := ProductInput{
GTIN: "8022068075495",
Name: a1FeedName,
Description: a1FeedDesc,
Mapped: map[string]any{
"name": a1FeedName,
"description": a1FeedDesc,
"brand": "BRUNNER",
"gtin": "8022068075495",
"category": "stolcki",
"specifications": map[string]any{"Barva": "sivo črna", "Material": "aluminij"},
},
Raw: map[string]any{},
Language: "sl",
ContentLanguages: []string{"sl"},
CategoryFormulasByKey: map[string]CategoryFormulas{
"stolcki": {TitleTemplate: titleTpl, DescriptionTemplate: descTpl},
},
CategoryNamesByUID: map[string]string{"stolcki": "Stolčki"},
OmitSEOMeta: true,
}
if catPrompt != "" {
in.CategoryPromptsByLang = map[string]company.LangPromptMap{
"stolcki": {"sl": catPrompt, "*": catPrompt},
}
}
return in
}
// Formula-compliant Slovenian copy must reach processed_* on every processing type
// the dashboard starts. It used to be dropped by the invent heuristic, leaving the
// supplier feed text on both sides of Review ("Matched").
func TestRunSteps_A1FormulaCopyReachesProcessedFields(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
// "title" / "description" / "enhance" are what products/+page.svelte sends.
for _, ptype := range []string{"full", "enhance", "title", "description"} {
t.Run(ptype, func(t *testing.T) {
c := &formulaRegressionCompleter{reply: a1FormulaReply}
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, ptype, []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if c.calls != 1 {
t.Fatalf("compliant reply must not trigger a formula retry, calls=%d", c.calls)
}
if out.ProcessedName != a1FormulaName {
t.Fatalf("ProcessedName=%q want the formula name %q", out.ProcessedName, a1FormulaName)
}
if !strings.Contains(out.ProcessedDescription, "<h2>") ||
!strings.Contains(out.ProcessedDescription, "<ul>") {
t.Fatalf("ProcessedDescription lost the formula HTML: %q", out.ProcessedDescription)
}
if out.ProcessedDescription == out.Description || out.ProcessedName == out.Name {
t.Fatal("Review would show Matched — enriched must differ from Original")
}
if out.Name != a1FeedName || out.Description != a1FeedDesc {
t.Fatalf("Original must stay supplier copy: name=%q desc=%q", out.Name, out.Description)
}
if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h == "" {
t.Fatal("quality enhance must persist enhance_input_hash so reprocess can skip")
}
})
}
}
// The formula only keys when enhance is fed a category and attributes. Partial
// enhance types used to run normalize+ai_enhance alone, so the prompt carried an
// empty Attrs line and (for feeds without a mapped category) no category at all.
func TestRunSteps_partialEnhanceStillFeedsFormulaInputs(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
for _, ptype := range []string{"enhance", "title", "description"} {
t.Run(ptype, func(t *testing.T) {
c := &formulaRegressionCompleter{reply: a1FormulaReply}
if _, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, ptype, []string{"stolcki"}, StepPolicy{AllowAI: true}); err != nil {
t.Fatal(err)
}
if !strings.Contains(c.user, "Kategorija: Stolčki") {
t.Fatalf("category missing from enhance prompt:\n%s", c.user)
}
if !strings.Contains(c.user, `"brand":"BRUNNER"`) {
t.Fatalf("attrs missing from enhance prompt:\n%s", c.user)
}
if !strings.Contains(c.user, "GPT predloga:") || !strings.Contains(c.user, "<name>{") {
t.Fatalf("category formula missing from enhance prompt:\n%s", c.user)
}
})
}
}
// A model that echoes the supplier name/description must never be recorded as a
// successful enhance: no enhance_input_hash (so reprocess retries) and Original
// keeps the feed copy.
func TestRunSteps_feedEchoIsNotRecordedAsEnhanced(t *testing.T) {
t.Parallel()
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
echo, _ := json.Marshal(map[string]any{"name": a1FeedName, "description": a1FeedDesc})
c := &formulaRegressionCompleter{reply: string(echo)}
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, "enhance", []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if c.calls < 2 {
t.Fatalf("feed echo must trigger the formula/title retry, calls=%d", c.calls)
}
if h, _ := out.FieldSources[FieldEnhanceInputHash].(string); h != "" {
t.Fatalf("feed echo must not persist an enhance hash, got %q", h)
}
if out.Description != a1FeedDesc {
t.Fatalf("Original description must stay supplier copy, got %q", out.Description)
}
}
// Original mirrors the feed. A supplier description that trips a quality heuristic
// must still show up as Original rather than being blanked or replaced with the
// enriched text (that replacement is what Review reported as "Matched").
func TestRunSteps_originalNeverBorrowsEnrichedCopy(t *testing.T) {
t.Parallel()
weakFeedDesc := "Ta stol je izdelek znamke BRUNNER."
if !company.LooksLikeHeuristicSynthesize(weakFeedDesc) {
t.Fatalf("fixture should trip the invent heuristic: %q", weakFeedDesc)
}
catPrompt := aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt)
in := a1ProductInput(t, catPrompt,
jsonObject(t, a1BrandOnlyTitleTemplate), jsonObject(t, a1GenericEnglishDescTemplate))
in.Description = weakFeedDesc
in.Mapped["description"] = weakFeedDesc
c := &formulaRegressionCompleter{reply: a1FormulaReply}
out, err := (&Engine{Completer: c}).RunSteps(
context.Background(), "co", in, "enhance", []string{"stolcki"}, StepPolicy{AllowAI: true})
if err != nil {
t.Fatal(err)
}
if out.Description != weakFeedDesc {
t.Fatalf("Original must stay the supplier text, got %q", out.Description)
}
if out.ProcessedDescription == out.Description {
t.Fatal("enriched must differ from Original")
}
}
// A brand-only title_template ({elements:[brand]}) is a migration stub, not a
// formula: instructing "name = brand" produces a title the picker then discards in
// favour of the feed name — an LLM call spent to reproduce the supplier title.
func TestFormatTitleFormulaConstraint_ignoresBrandOnlyStub(t *testing.T) {
t.Parallel()
if got := FormatTitleFormulaConstraint(jsonObject(t, a1BrandOnlyTitleTemplate)); got != "" {
t.Fatalf("brand-only stub must not read as a Title formula, got:\n%s", got)
}
real := map[string]any{"separator": " ", "elements": []any{
map[string]any{"type": "variable", "value": "product_type"},
map[string]any{"type": "variable", "value": "brand"},
map[string]any{"type": "variable", "value": "product_model"},
}}
if FormatTitleFormulaConstraint(real) == "" {
t.Fatal("a real multi-slot title formula must still produce constraints")
}
}
// A formula name that is shorter than — and a prefix of — the supplier name must
// survive: preferredProductTitle's brand-stub rule would otherwise hand the feed
// title back and undo the Title formula.
func TestFormulaAwareTitle_keepsShortFormulaNameOverSupplierPrefix(t *testing.T) {
t.Parallel()
in := ProductInput{
Name: "BRUNNER stol za kampiranje ONE SHOT sivo črn 0404164N.C20",
CategoryEnhancePrompt: aiprompts.SplitLegacyCombinedEnhancePrompt(a1StolckiLegacyPrompt),
}
if !categoryRequiresTitleRewrite(in) {
t.Fatal("A1 Title section must count as a rewrite formula")
}
if got := formulaAwareTitle(in, "BRUNNER stol"); got != "BRUNNER stol" {
t.Fatalf("formula name dropped for the supplier title: %q", got)
}
// Without a category rewrite rule the richer supplier title still wins.
plain := ProductInput{Name: in.Name}
if got := formulaAwareTitle(plain, "BRUNNER stol"); got != in.Name {
t.Fatalf("without a formula the fuller supplier title should win, got %q", got)
}
}