fix
This commit is contained in:
@@ -49,6 +49,9 @@ var heuristicSynthesizePhrases = []string{
|
|||||||
" — katalogski izdelek",
|
" — katalogski izdelek",
|
||||||
" — from ",
|
" — from ",
|
||||||
" — catalog product",
|
" — catalog product",
|
||||||
|
// synthesizeDescriptionFromFormula heading fallbacks
|
||||||
|
"ključne lastnosti",
|
||||||
|
"key features",
|
||||||
}
|
}
|
||||||
|
|
||||||
// LooksLikeHeuristicSynthesize reports invent / formula-skeleton fallback copy.
|
// LooksLikeHeuristicSynthesize reports invent / formula-skeleton fallback copy.
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
`<H2>{Naslov}</H2><p>{Odstavek}</p><ul><li>{Spec}</li></ul>` + "\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 := "<h2>Lahek kamp stol</h2><p>" + strings.Repeat("Udobje na poti. ", 15) +
|
||||||
|
"</p><ul><li>width: 0.44m</li><li>weight: 2.88kg</li></ul>"
|
||||||
|
|
||||||
|
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, "<h2>") {
|
||||||
|
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" +
|
||||||
|
`<H2>{Naslov}</H2><p>{Odstavek}</p><ul><li>{Spec}</li></ul>` + "\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 = "<h2>A</h2><p>" + long + "</p>"
|
||||||
|
in.Description = "<h2>A</h2><p>" + long + "</p>"
|
||||||
|
if got := enhanceHashForceReason(in); got != "desc-copy-paste" {
|
||||||
|
t.Fatalf("got %q want desc-copy-paste", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ func TestRunSteps_enhanceMockHappyPath(t *testing.T) {
|
|||||||
Completer: stubCompleter{fn: func(system, _ string) (Completion, error) {
|
Completer: stubCompleter{fn: func(system, _ string) (Completion, error) {
|
||||||
gotSystem = system
|
gotSystem = system
|
||||||
return Completion{
|
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,
|
TotalTokens: 11,
|
||||||
Model: "mock",
|
Model: "mock",
|
||||||
}, nil
|
}, nil
|
||||||
@@ -68,6 +68,15 @@ func TestRunSteps_enhanceMockHappyPath(t *testing.T) {
|
|||||||
if out.ProcessedName != "Mock Shoe" {
|
if out.ProcessedName != "Mock Shoe" {
|
||||||
t.Fatalf("ProcessedName=%q", out.ProcessedName)
|
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 {
|
if out.TotalTokens != 11 {
|
||||||
t.Fatalf("TotalTokens=%d", out.TotalTokens)
|
t.Fatalf("TotalTokens=%d", out.TotalTokens)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -413,14 +413,11 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
|||||||
}
|
}
|
||||||
langMetas = append(langMetas, meta)
|
langMetas = append(langMetas, meta)
|
||||||
if lang == primary {
|
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.ProcessedName = name
|
||||||
out.ProcessedDescription = desc
|
out.ProcessedDescription = desc
|
||||||
if name != "" {
|
|
||||||
out.Name = name
|
|
||||||
}
|
|
||||||
if desc != "" {
|
|
||||||
out.Description = desc
|
|
||||||
}
|
|
||||||
if lf := localized[lang]; lf.MetaTitle != "" {
|
if lf := localized[lang]; lf.MetaTitle != "" {
|
||||||
out.MetaTitle = 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 out.ProcessedName != "" && descriptionNeedsEnhanceRepair(out.ProcessedDescription, failDescTpl, out.ProcessedName, out.Name) {
|
||||||
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
|
if synth := synthesizeProductDescription(out.ProcessedName, displayCat, primary, enhanceAttrs, failDescTpl); synth != "" {
|
||||||
out.ProcessedDescription = synth
|
out.ProcessedDescription = synth
|
||||||
if out.Description == "" || descriptionNeedsEnhanceRepair(out.Description, failDescTpl, out.Name, out.ProcessedName) {
|
// Never stamp invent fallback onto Original description.
|
||||||
out.Description = synth
|
|
||||||
}
|
|
||||||
if lf, ok := localized[primary]; ok {
|
if lf, ok := localized[primary]; ok {
|
||||||
lf.ProcessedDescription = synth
|
lf.ProcessedDescription = synth
|
||||||
if lf.ProcessedName == "" {
|
if lf.ProcessedName == "" {
|
||||||
@@ -562,18 +557,9 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput
|
|||||||
finalRepaired = true
|
finalRepaired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if descriptionNeedsEnhanceRepair(out.Description, finalDescTpl, out.Name, out.ProcessedName) {
|
// Original description stays supplier/normalized feed copy. Formula HTML belongs
|
||||||
if out.ProcessedDescription != "" && !descriptionNeedsEnhanceRepair(out.ProcessedDescription, finalDescTpl, out.Name, out.ProcessedName) {
|
// only on processed_description — copying synth onto description made Review
|
||||||
out.Description = out.ProcessedDescription
|
// always Matched with invent "Ključne lastnosti" instead of a real enrich diff.
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if finalRepaired {
|
if finalRepaired {
|
||||||
delete(out.FieldSources, FieldEnhanceInputHash)
|
delete(out.FieldSources, FieldEnhanceInputHash)
|
||||||
primary := strings.TrimSpace(in.Language)
|
primary := strings.TrimSpace(in.Language)
|
||||||
@@ -794,10 +780,16 @@ func normalizeTitleCompare(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
|
func titleNeedsRewriteRetry(in ProductInput, name string) bool {
|
||||||
if !aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) {
|
if !titlesAreEquivalent(name, in.Name) {
|
||||||
return false
|
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
|
// 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) {
|
if titleNeedsRewriteRetry(in, in.PriorProcessedName) {
|
||||||
return "title-copy-paste"
|
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 ""
|
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) {
|
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
|
// Prompt Description HTML overlays count as the formula when description_template
|
||||||
// is empty — otherwise enhance accepts supplier-like prose and skips formula retry.
|
// is empty — otherwise enhance accepts supplier-like prose and skips formula retry.
|
||||||
|
|||||||
Reference in New Issue
Block a user