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}- {Napiši 3-10 tehničnih specifikacij}
` + "\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