diff --git a/apps/api/internal/aiprompts/description_formula.go b/apps/api/internal/aiprompts/description_formula.go index 2d7a38d..ddbcf5b 100644 --- a/apps/api/internal/aiprompts/description_formula.go +++ b/apps/api/internal/aiprompts/description_formula.go @@ -161,29 +161,63 @@ func parseDescriptionFormula(template any) (DescriptionFormula, bool) { } } -// 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. +// EffectiveDescriptionFormula merges structured description_template with the +// category enhance prompt Title/Description/Meta role sections. +// +// Precedence for HTML body sections: +// 1. Prompt Description HTML overlay when it yields more sections than stored +// (tenants author structure in /categories/.../prompt Section instructions). +// 2. Otherwise stored description_template.sections. +// +// Meta instructions: stored metaTitle/metaDescription win; else --- Meta --- body. func EffectiveDescriptionFormula(stored any, categoryPrompt string) DescriptionFormula { - if f, ok := parseDescriptionFormula(stored); ok && len(f.Sections) > 0 { - return f + storedF, storedOK := parseDescriptionFormula(stored) + out := DescriptionFormula{} + if storedOK { + out = storedF } - body := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd) - if body == "" { - body = strings.TrimSpace(categoryPrompt) + + descBody := ExtractEnhanceSectionBody(categoryPrompt, SectionDescriptionStart, SectionDescriptionEnd) + if descBody == "" && !promptHasRoleMarkers(categoryPrompt) { + // Unsectioned prompt may still be a legacy HTML blob. + descBody = strings.TrimSpace(categoryPrompt) } - if body == "" || !reLegacyHTMLBlock.MatchString(body) { - if f, ok := parseDescriptionFormula(stored); ok { - return f + var derived DescriptionFormula + if descBody != "" && reLegacyHTMLBlock.MatchString(descBody) { + derived = DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{ + DescriptionRules: descBody, + WasLegacy: true, + }) + } + if len(derived.Sections) > len(out.Sections) { + out.Sections = derived.Sections + } + + metaBody := ExtractEnhanceSectionBody(categoryPrompt, SectionMetaStart, SectionMetaEnd) + if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(metaBody) != "" { + out.MetaDescription = strings.TrimSpace(metaBody) + out.MetaTitle = DefaultLegacyMetaTitleRule + } else { + if strings.TrimSpace(out.MetaTitle) == "" && strings.TrimSpace(derived.MetaTitle) != "" { + out.MetaTitle = derived.MetaTitle + } + if strings.TrimSpace(out.MetaDescription) == "" && strings.TrimSpace(derived.MetaDescription) != "" { + out.MetaDescription = derived.MetaDescription } - return DescriptionFormula{} } - return DeriveDescriptionFormulaFromLegacyParts(LegacyEnhanceParts{ - DescriptionRules: body, - WasLegacy: true, - }) + return out +} + +func promptHasRoleMarkers(prompt string) bool { + lower := strings.ToLower(prompt) + return strings.Contains(lower, strings.ToLower(SectionTitleStart)) || + strings.Contains(lower, strings.ToLower(SectionDescriptionStart)) || + strings.Contains(lower, strings.ToLower(SectionMetaStart)) +} + +// TitleSectionInstructions returns the --- Title --- body from a category prompt. +func TitleSectionInstructions(categoryPrompt string) string { + return ExtractEnhanceSectionBody(categoryPrompt, SectionTitleStart, SectionTitleEnd) } // EffectiveDescriptionTemplateAny is EffectiveDescriptionFormula as a JSON object @@ -207,13 +241,18 @@ func EffectiveDescriptionTemplateAny(stored any, categoryPrompt string) any { // 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) + body := TitleSectionInstructions(categoryPrompt) if body == "" { body = strings.TrimSpace(categoryPrompt) } if body == "" { return false } + // Built-in CategoryEnhanceUserTemplate Title role mentions "Title formula" + // generically — that is not a tenant rewrite formula. + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(body)), "role: title") { + return false + } lower := strings.ToLower(body) cues := []string{ "napiši novo ime", @@ -223,8 +262,9 @@ func CategoryTitlePromptRequiresRewrite(categoryPrompt string) bool { "write a new", "new product name", "rename", - "title formula", "sentence case", + "tip izdelka", + "product type", } for _, c := range cues { if strings.Contains(lower, c) { diff --git a/apps/api/internal/aiprompts/description_formula_test.go b/apps/api/internal/aiprompts/description_formula_test.go index bde27db..2eff642 100644 --- a/apps/api/internal/aiprompts/description_formula_test.go +++ b/apps/api/internal/aiprompts/description_formula_test.go @@ -93,6 +93,40 @@ func TestEffectiveDescriptionFormula_fromPromptDescriptionSection(t *testing.T) } } +func TestEffectiveDescriptionFormula_prefersRicherPromptHTML(t *testing.T) { + t.Parallel() + stored := map[string]any{ + "sections": []any{ + map[string]any{"type": "p", "instructions": "Short"}, + }, + "metaTitle": "Stored meta title rule", + } + prompt := SectionDescriptionStart + "\n" + + `

{One}

{Two}

{Three}

{Four}

` + + `{Specs}` + "\n" + + SectionDescriptionEnd + "\n" + + SectionMetaStart + "\nIgnored because stored meta exists\n" + SectionMetaEnd + f := EffectiveDescriptionFormula(stored, prompt) + if len(f.Sections) < 4 { + t.Fatalf("want prompt HTML sections to win over thin stored, got %#v", f.Sections) + } + if f.MetaTitle != "Stored meta title rule" { + t.Fatalf("stored metaTitle should win, got %q", f.MetaTitle) + } +} + +func TestEffectiveDescriptionFormula_metaSectionWhenStoredEmpty(t *testing.T) { + t.Parallel() + prompt := SectionMetaStart + "\nWrite meta_description to 140 chars.\n" + SectionMetaEnd + f := EffectiveDescriptionFormula(nil, prompt) + if !strings.Contains(f.MetaDescription, "140") { + t.Fatalf("metaDescription=%q", f.MetaDescription) + } + if strings.TrimSpace(f.MetaTitle) == "" { + t.Fatal("expected default metaTitle when Meta section present") + } +} + func TestExtractEnhanceSectionBody(t *testing.T) { t.Parallel() prompt := SectionTitleStart + "\nHello\n" + SectionTitleEnd + "\n" + diff --git a/apps/api/internal/company/weak_desc.go b/apps/api/internal/company/weak_desc.go index f115e53..3b891d9 100644 --- a/apps/api/internal/company/weak_desc.go +++ b/apps/api/internal/company/weak_desc.go @@ -140,26 +140,29 @@ func ShouldRefuseEnhanceHashSkip(priorDesc string, titles ...string) bool { // DescriptionMissingFormulaHTMLTags is true when sectionTypes require HTML tags // (h1/h2/h3/h4, p, ul) that are absent from desc — formula-mismatch for skip/clear. +// Counts matter: a formula with two h2 sections needs at least two

T

Body

", types) { t.Fatal("full HTML must match") } + twoH2 := []string{"h2", "p", "h2", "p", "ul"} + if !DescriptionMissingFormulaHTMLTags("

A

One

", twoH2) { + t.Fatal("single h2 must fail when formula needs two") + } + ok := "

A

One

B

Two

" + if DescriptionMissingFormulaHTMLTags(ok, twoH2) { + t.Fatal("two h2 + two p + ul should satisfy") + } } diff --git a/apps/api/internal/processing/formula_prompt.go b/apps/api/internal/processing/formula_prompt.go index bf7af80..a42f2a4 100644 --- a/apps/api/internal/processing/formula_prompt.go +++ b/apps/api/internal/processing/formula_prompt.go @@ -47,6 +47,46 @@ func AppendFormulaConstraints(userTpl string, titleTemplate, descriptionTemplate return userTpl + "\n\n" + joined.String() } +// FormatTitleSectionConstraint turns free-form --- Title --- section instructions +// into a required enhance constraint when no structured title_template elements exist. +func FormatTitleSectionConstraint(categoryPrompt string) string { + body := strings.TrimSpace(aiprompts.TitleSectionInstructions(categoryPrompt)) + if body == "" || isBuiltInTitleRoleBoilerplate(body) { + return "" + } + var b strings.Builder + b.WriteString("Title instructions (REQUIRED — overrides any shorter \"short retail title\" rule):\n") + b.WriteString(body) + b.WriteString("\nBuild JSON \"name\" exactly per these instructions in {{language}}.") + b.WriteString(" Never copy the supplier Name unchanged when instructions ask for a new name or formula.") + return b.String() +} + +func isBuiltInTitleRoleBoilerplate(body string) bool { + lower := strings.ToLower(strings.TrimSpace(body)) + return strings.HasPrefix(lower, "role: title") +} + +// AppendTitleSectionConstraint appends FormatTitleSectionConstraint when the +// category Title role has text and structured title_template is empty/unusable. +func AppendTitleSectionConstraint(userTpl, categoryPrompt string, titleTemplate any) string { + if FormatTitleFormulaConstraint(titleTemplate) != "" { + return strings.TrimSpace(userTpl) + } + block := FormatTitleSectionConstraint(categoryPrompt) + if block == "" { + return strings.TrimSpace(userTpl) + } + userTpl = strings.TrimSpace(userTpl) + if strings.Contains(userTpl, "Title instructions (REQUIRED") { + return userTpl + } + if userTpl == "" { + return block + } + return userTpl + "\n\n" + block +} + // MaxAttrAllowlistPromptKeys caps Allowed attribute keys listed in enhance prompts. const MaxAttrAllowlistPromptKeys = 40 diff --git a/apps/api/internal/processing/prompt_render.go b/apps/api/internal/processing/prompt_render.go index e0f45df..02ffc8c 100644 --- a/apps/api/internal/processing/prompt_render.go +++ b/apps/api/internal/processing/prompt_render.go @@ -29,6 +29,8 @@ func resolveProductPromptTemplates(in ProductInput) (systemTpl, userTpl string) descTpl := aiprompts.EffectiveDescriptionTemplateAny(in.DescriptionTemplate, catPrompt) // Category formulas are language-agnostic; inject once into the shared user skeleton. userTpl = AppendFormulaConstraints(userTpl, in.TitleTemplate, descTpl, in.OmitSEOMeta) + // Free-form --- Title --- instructions when structured title_template is empty. + userTpl = AppendTitleSectionConstraint(userTpl, catPrompt, in.TitleTemplate) // Category attribute allowlist + title-formula keys guide JSON "attrs" extraction. allowed := enhanceAllowedAttrKeys(in, in.CategoryUniqueID) userTpl = AppendAttributeConstraints(userTpl, allowed, in.TitleTemplate) diff --git a/apps/api/internal/processing/prompt_render_test.go b/apps/api/internal/processing/prompt_render_test.go index 19495de..fad5b1f 100644 --- a/apps/api/internal/processing/prompt_render_test.go +++ b/apps/api/internal/processing/prompt_render_test.go @@ -113,6 +113,8 @@ func TestResolveProductPromptTemplates_derivesFormulaFromPromptHTML(t *testing.T aiprompts.SectionDescriptionStart + "\n" + `

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

` + `

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

` + + `

{Izpostavi en benefit}

` + + `

{Napiši odstavek z tipom izdelka lowercase.}

` + `{Tehnične specifikacije}` + "\n" + aiprompts.SectionDescriptionEnd sys, user := resolveProductPromptTemplates(ProductInput{ @@ -133,9 +135,22 @@ func TestResolveProductPromptTemplates_derivesFormulaFromPromptHTML(t *testing.T 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)) { + if !strings.Contains(user, "Title instructions (REQUIRED") { + t.Fatalf("missing Title section constraint:\n%s", user) + } + effective := aiprompts.EffectiveDescriptionTemplateAny(nil, prompt) + if descriptionSatisfiesFormula("plain Slovenian prose without tags", effective) { t.Fatal("plain prose must fail derived formula gate") } + // One h2 is not enough when formula asks for two. + thin := "

A

One

Two

" + if descriptionSatisfiesFormula(thin, effective) { + t.Fatal("under-counted h2 must fail formula gate") + } + ok := "

A

One

B

Two

" + if !descriptionSatisfiesFormula(ok, effective) { + t.Fatal("full section count must pass formula gate") + } } func TestResolveProductPromptTemplates_fallsBackToCompany(t *testing.T) { diff --git a/apps/api/internal/processing/steps.go b/apps/api/internal/processing/steps.go index c48299c..0f55a29 100644 --- a/apps/api/internal/processing/steps.go +++ b/apps/api/internal/processing/steps.go @@ -278,6 +278,9 @@ func (e *Engine) RunSteps(ctx context.Context, companyID string, in ProductInput catPrompt = categoryEnhancePromptFor(in.CategoryPromptsByLang, out.Category, lang, primary) } titleTpl, descTpl := categoryFormulasFor(in, out.Category) + if effective := aiprompts.EffectiveDescriptionTemplateAny(descTpl, catPrompt); effective != nil { + descTpl = effective + } priorFields := company.FieldsForLanguage(in.PriorLocalized, lang) priorHash := priorFields.EnhanceInputHash priorName := priorFields.ProcessedName