diff --git a/apps/api/internal/aiprompts/description_formula.go b/apps/api/internal/aiprompts/description_formula.go index b43b68c..2d7a38d 100644 --- a/apps/api/internal/aiprompts/description_formula.go +++ b/apps/api/internal/aiprompts/description_formula.go @@ -160,3 +160,76 @@ func parseDescriptionFormula(template any) (DescriptionFormula, bool) { return f, true } } + +// EffectiveDescriptionFormula returns the stored description_template when it has +// sections; otherwise derives sections from the category enhance prompt Description +// role (legacy HTML

{…}

{…}

overlays). That is how A1 and tenants +// author HTML structure in /categories/.../prompt — without this, enhance accepts +// plain prose and never runs the formula quality gate. +func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionFormula { + if f, ok := parseDescriptionFormula(stored); ok && len(f.Sections) > 0 { + return f + } + body := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd) + if body == "" { + body = strings.TrimSpace(categoryPrompt) + } + if body == "" || !reLegacyHTMLBlock.MatchString(body) { + if f, ok := parseDescriptionFormula(stored); ok { + return f + } + return DescriptionFormula{} + } + return DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{ + DescriptionRules: body, + WasLegacy: true, + }) +} + +// EffectiveDescriptionTemplateAny is EffectiveDescriptionFormula as a JSON object +// for processing.ProductInput.DescriptionTemplate (nil when empty). +func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any { + f := EffectiveDescriptionFormula(stored, categoryPrompt) + if len(f.Sections) == 0 && strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == "" { + return nil + } + b, err := json.Marshal(f) + if err != nil { + return nil + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil + } + return out +} + +// CategoryTitlePromptRequiresRewrite reports whether the Title role instructs a +// new retail name (not keep/echo supplier title). Used to reject "Matched" copy-paste. +func CategoryTitlePromptRequiresRewrite(categoryPrompt string) bool { + body := ExtractEnhanceSectionBody(categoryPrompt, SectionTitleStart, SectionTitleEnd) + if body == "" { + body = strings.TrimSpace(categoryPrompt) + } + if body == "" { + return false + } + lower := strings.ToLower(body) + cues := []string{ + "napiši novo ime", + "napisi novo ime", + "novo ime izdelka", + "po formuli", + "write a new", + "new product name", + "rename", + "title formula", + "sentence case", + } + for _, c := range cues { + if strings.Contains(lower, c) { + return true + } + } + return false +} diff --git a/apps/api/internal/aiprompts/description_formula_test.go b/apps/api/internal/aiprompts/description_formula_test.go index 82c8e5f..bde27db 100644 --- a/apps/api/internal/aiprompts/description_formula_test.go +++ b/apps/api/internal/aiprompts/description_formula_test.go @@ -54,3 +54,53 @@ GPT predloga: t.Fatalf("derived template should be OK: %s", raw) } } + +func TestEffectiveDescriptionFormula_fromPromptDescriptionSection(t *testing.T) { + t.Parallel() + prompt := strings.Join([]string{ + SectionTitleStart, + `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka". Ne uporabljaj vejic.`, + SectionTitleEnd, + "", + SectionDescriptionStart, + `

{Napiši Novo ime izdelka in izpostavi en benefit}

`, + `

{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}

`, + `

{Izpostavi en benefit, in NE napiši Novo ime izdelka}

`, + `

{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi tip izdelka lowercase}

`, + `{Tehnične specifikacije}`, + SectionDescriptionEnd, + }, "\n") + f := EffectiveDescriptionFormula(nil, prompt) + if len(f.Sections) < 4 { + t.Fatalf("want HTML sections from Description role, got %#v", f.Sections) + } + if f.Sections[0].Type != "h2" || f.Sections[1].Type != "p" { + t.Fatalf("unexpected order: %#v", f.Sections) + } + last := f.Sections[len(f.Sections)-1] + if last.Type != "ul" { + t.Fatalf("last should be ul specs, got %#v", last) + } + anyTpl := EffectiveDescriptionTemplateAny(nil, prompt) + if anyTpl == nil { + t.Fatal("expected non-nil template any") + } + if !CategoryTitlePromptRequiresRewrite(prompt) { + t.Fatal("Title section should require rewrite") + } + if CategoryTitlePromptRequiresRewrite("--- Title ---\nKeep supplier name.\n--- End Title ---") { + t.Fatal("keep-only Title must not require rewrite") + } +} + +func TestExtractEnhanceSectionBody(t *testing.T) { + t.Parallel() + prompt := SectionTitleStart + "\nHello\n" + SectionTitleEnd + "\n" + + SectionDescriptionStart + "\nBody here\n" + SectionDescriptionEnd + if got := ExtractEnhanceSectionBody(prompt, SectionTitleStart, SectionTitleEnd); got != "Hello" { + t.Fatalf("title body=%q", got) + } + if got := ExtractEnhanceSectionBody(prompt, SectionDescriptionStart, SectionDescriptionEnd); got != "Body here" { + t.Fatalf("desc body=%q", got) + } +} diff --git a/apps/api/internal/aiprompts/legacy_split.go b/apps/api/internal/aiprompts/legacy_split.go index ab3d4cb..49cbb9f 100644 --- a/apps/api/internal/aiprompts/legacy_split.go +++ b/apps/api/internal/aiprompts/legacy_split.go @@ -110,6 +110,31 @@ func BuildCategoryEnhanceOverlay(parts LegacyEnhanceParts) string { return strings.TrimSpace(b.String()) } +// ExtractEnhanceSectionBody returns the text between start/end markers +// (case-insensitive markers, body preserved). Empty when the start marker is missing. +func ExtractEnhanceSectionBody(prompt, start, end string) string { + raw := strings.ReplaceAll(strings.ReplaceAll(prompt, "\r\n", "\n"), "\r", "\n") + lower := strings.ToLower(raw) + startL := strings.ToLower(strings.TrimSpace(start)) + endL := strings.ToLower(strings.TrimSpace(end)) + if startL == "" { + return "" + } + startIdx := strings.Index(lower, startL) + if startIdx < 0 { + return "" + } + bodyStart := startIdx + len(startL) + rest := raw[bodyStart:] + restLower := lower[bodyStart:] + if endL != "" { + if endIdx := strings.Index(restLower, endL); endIdx >= 0 { + rest = rest[:endIdx] + } + } + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(rest, "\n"), "\n")) +} + func cleanLegacyInstruction(s string) string { s = modernizeLegacyPlaceholders(s) s = reDoubledQuotes.ReplaceAllString(s, `"`) diff --git a/apps/api/internal/processing/formula_prompt.go b/apps/api/internal/processing/formula_prompt.go index c6bd000..bf7af80 100644 --- a/apps/api/internal/processing/formula_prompt.go +++ b/apps/api/internal/processing/formula_prompt.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" "github.com/descrybe/descrybe-v2/apps/api/internal/company" ) @@ -276,6 +277,26 @@ func AppendTitleFormulaSystemOverride(systemTpl string, titleTemplate any) strin return systemTpl + "\n" + titleFormulaSystemOverride } +// titlePromptRewriteOverride covers free-form Title section instructions +// (categories.prompt --- Title ---) that are not structured title_template. +const titlePromptRewriteOverride = `When the category Title section asks for a new product name or naming formula, "name" MUST be rewritten — never copy the supplier Name unchanged when type/brand/model/color (or other Title cues) are available. Obey the Title section over any "keep short retail title" guidance.` + +// AppendTitlePromptRewriteOverride strengthens the system prompt when the +// category Title role requires a rewrite. No-op when Title does not ask for one. +func AppendTitlePromptRewriteOverride(systemTpl, categoryPrompt string) string { + if !aiprompts.CategoryTitlePromptRequiresRewrite(categoryPrompt) { + return strings.TrimSpace(systemTpl) + } + systemTpl = strings.TrimSpace(systemTpl) + if systemTpl == "" { + return titlePromptRewriteOverride + } + if strings.Contains(systemTpl, `"name" MUST be rewritten`) { + return systemTpl + } + return systemTpl + "\n" + titlePromptRewriteOverride +} + // categoryEnhanceSystemOverlay is appended when categories.prompt (enhance overlay) // is active so free-form category copy drives title, description, AND attributes — // not description alone — while Title/Description formulas keep structural precedence. diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go index f90de4c..e0f45df 100644 --- a/apps/api/internal/processing/prompt_render.go +++ b/apps/api/internal/processing/prompt_render.go @@ -24,18 +24,23 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) userTpl = def.UserTemplate } } + // Prefer stored description_template; else derive HTML sections from the + // category prompt Description role (A1-style

{…}

overlays). + descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt) // Category formulas are language-agnostic; inject once into the shared user skeleton. - userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, in.DescriptionTemplate, in.OmitSEOMeta) + userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, descTpl, in.OmitSEOMeta) // Category attribute allowlist + title-formula keys guide JSON "attrs" extraction. allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID) userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate) // Company/built-in system prompts often say "1-2 sentences"; when a category // description formula exists, override that so process matches A1 category defs. - systemTpl = AppendDescriptionFormulaSystemOverride(systemTpl, in.DescriptionTemplate) + systemTpl = AppendDescriptionFormulaSystemOverride(systemTpl, descTpl) // Same for title_template vs "short retail title" — formulas win for name structure. systemTpl = AppendTitleFormulaSystemOverride(systemTpl, in.TitleTemplate) + // Free-form Title section (prompt UI) that asks for a rewrite — not title_template. + systemTpl = AppendTitlePromptRewriteOverride(systemTpl, catPrompt) if !in.OmitSEOMeta { - systemTpl = AppendMetaFormulaSystemOverride(systemTpl, in.DescriptionTemplate) + systemTpl = AppendMetaFormulaSystemOverride(systemTpl, descTpl) } // categories.prompt applies to name, description, and attrs (not description-only). systemTpl = AppendCategoryEnhanceSystemOverlay(systemTpl, catPrompt) diff --git a/apps/api/internal/processing/prompt_render_test.go b/apps/api/internal/processing/prompt_render_test.go index 75812d3..19495de 100644 --- a/apps/api/internal/processing/prompt_render_test.go +++ b/apps/api/internal/processing/prompt_render_test.go @@ -105,6 +105,39 @@ func TestResolveProductPromptTemplates_categoryWithTitleFormula(t *testing.T) { } } +func TestResolveProductPromptTemplates_derivesFormulaFromPromptHTML(t *testing.T) { + t.Parallel() + prompt := aiprompts.SectionTitleStart + "\n" + + `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka", "model". Ne uporabljaj vejic.` + "\n" + + aiprompts.SectionTitleEnd + "\n\n" + + aiprompts.SectionDescriptionStart + "\n" + + `

{Napiši Novo ime izdelka in izpostavi en benefit}

` + + `

{Napiši odstavek, ki je dolg 100 besed.}

` + + `{Tehnične specifikacije}` + "\n" + + aiprompts.SectionDescriptionEnd + sys, user := resolveProductPromptTemplates(ProductInput{ + EnhanceSystemTemplate: "Retail copywriter.\n- description: 1-2 sentences", + CategoryEnhancePrompt: prompt, + // Empty structured template — production A1 path often only has prompt HTML. + DescriptionTemplate: nil, + }) + if !strings.Contains(user, "Description formula") { + t.Fatalf("expected Description formula derived from prompt HTML:\n%s", user) + } + if !strings.Contains(user, "- h2:") || !strings.Contains(user, "- p:") || !strings.Contains(user, "- ul:") { + t.Fatalf("expected h2/p/ul sections in formula:\n%s", user) + } + if !strings.Contains(sys, "Description formula") { + t.Fatalf("missing description system override:\n%s", sys) + } + if !strings.Contains(sys, `"name" MUST be rewritten`) { + t.Fatalf("missing title rewrite system override:\n%s", sys) + } + if descriptionSatisfiesFormula("plain Slovenian prose without tags", aiprompts.EffectiveDescriptionTemplateAny(nil, prompt)) { + t.Fatal("plain prose must fail derived formula gate") + } +} + func TestResolveProductPromptTemplates_fallsBackToCompany(t *testing.T) { t.Parallel() _, user := resolveProductPromptTemplates(ProductInput{ diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index 8bbb976..c48299c 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -8,7 +8,9 @@ import ( "sort" "strings" "time" + "unicode" + "github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts" "github.com/descrybe/descrybe-v2/apps/api/internal/company" "github.com/descrybe/descrybe-v2/apps/api/internal/eprel" ) @@ -739,6 +741,10 @@ func appendStepLog(gpt map[string]any, name string, raw any) { // category description_template (short prose / title echo instead of multi-section HTML). const descriptionFormulaRetrySuffix = "\n\nINVALID DESCRIPTION. Your JSON ignored the Description formula. Reply with ONLY one JSON object; \"description\" must be ONE HTML string covering each formula section in order with matching tags (h1/h2/h3/h4, p, ul)." +// titleRewriteRetrySuffix is appended when the Title role requires a new retail +// name but the model echoed the supplier Name unchanged. +const titleRewriteRetrySuffix = "\n\nINVALID NAME. Category Title instructions require a NEW product name (formula / rewrite). \"name\" must NOT equal the supplier Name. Reply with ONLY one JSON object; rebuild \"name\" from type + brand + model + color (or the Title formula) in {{language}}." + // descriptionNeedsEnhanceRepair is true when desc is weak/empty/title-echo or // fails an active category description_template (A1 multi-section HTML). // Heuristic invent/synth is intentionally excluded here — enhanceHashForceReason @@ -750,6 +756,47 @@ func descriptionNeedsEnhanceRepair(desc string, template any, titles ...string) return !descriptionSatisfiesFormula(desc, template) } +// titlesAreEquivalent treats supplier vs enriched names as the same when only +// case/spacing/punctuation differ — used to detect Title formula copy-paste. +func titlesAreEquivalent(a, b string) bool { + na := normalizeTitleCompare(a) + nb := normalizeTitleCompare(b) + if na == "" || nb == "" { + return false + } + return na == nb +} + +func normalizeTitleCompare(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" || s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + prevSpace := false + for _, r := range s { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + prevSpace = false + case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/' || r == ',': + if !prevSpace && b.Len() > 0 { + b.WriteByte(' ') + prevSpace = true + } + } + } + return strings.TrimSpace(b.String()) +} + +func titleNeedsRewriteRetry(in ProductInput, name string) bool { + if !aiprompts.CategoryTitlePromptRequiresRewrite(in.CategoryEnhancePrompt) { + return false + } + return titlesAreEquivalent(name, in.Name) +} + // enhanceHashForceReason returns why a matching prior enhance_input_hash must not // skip the LLM (empty = safe to reuse as ai_enhance_unchanged). func enhanceHashForceReason(in ProductInput) string { @@ -762,10 +809,18 @@ func enhanceHashForceReason(in ProductInput) string { if !descriptionSatisfiesFormula(in.PriorProcessedDescription, in.DescriptionTemplate) { return "formula-mismatch" } + if titleNeedsRewriteRetry(in, in.PriorProcessedName) { + return "title-copy-paste" + } return "" } 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. + if effective := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, in.CategoryEnhancePrompt); effective != nil { + in.DescriptionTemplate = effective + } catUID := strings.TrimSpace(in.CategoryUniqueID) catName := strings.TrimSpace(category) sysTpl, userTpl := resolveProductPromptTemplates(in) @@ -885,11 +940,19 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, // Quality-gate on LLM description alone (do not absorb originals yet). desc := preferredProductDescription(name, llmDesc) didFormulaRetry := false - // Fast garbage that ignores A1 description_template: one formula-aware retry, then synthesize. - if !descriptionSatisfiesFormula(desc, in.DescriptionTemplate) && - FormatDescriptionFormulaConstraint(in.DescriptionTemplate) != "" { + needsDescRetry := !descriptionSatisfiesFormula(desc, in.DescriptionTemplate) && + FormatDescriptionFormulaConstraint(in.DescriptionTemplate) != "" + needsTitleRetry := titleNeedsRewriteRetry(in, name) + // One retry when Description HTML formula and/or Title rewrite were ignored. + if needsDescRetry || needsTitleRetry { didFormulaRetry = true - retryUser := user + descriptionFormulaRetrySuffix + retryUser := user + if needsDescRetry { + retryUser += descriptionFormulaRetrySuffix + } + if needsTitleRetry { + retryUser += titleRewriteRetrySuffix + } retryStarted := time.Now() comp2, obj2, err2 := CompleteJSON(ctx, e.Completer, system, retryUser, CompleteOptions{ MaxTokens: MaxTokensEnhance, @@ -904,7 +967,12 @@ func (e *Engine) enhance(ctx context.Context, in ProductInput, category string, n2 := SanitizeOutput(fmt.Sprint(obj2["name"])) d2 := SanitizeOutput(fmt.Sprint(obj2["description"])) if llmEnhanceHardRefuseReason(n2, d2) == "" { - name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName) + // Prefer rewritten title over supplier echo when Title role requires it. + if needsTitleRetry && n2 != "" && !titlesAreEquivalent(n2, in.Name) { + name = preferredProductTitle(in.GTIN, n2) + } else { + name = preferredProductTitle(in.GTIN, n2, name, in.Name, in.PriorProcessedName) + } desc = preferredProductDescription(name, d2) mt2, md2 := metaFieldsFromEnhanceObj(obj2) if mt2 != "" {