2026-08-16 16:57:36 +02:00
package processing
import (
"encoding/json"
"fmt"
2026-08-16 23:42:00 +02:00
"sort"
2026-08-16 16:57:36 +02:00
"strconv"
"strings"
2026-08-16 21:35:48 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
2026-08-16 16:57:36 +02:00
)
2026-08-16 23:42:00 +02:00
// AppendFormulaConstraints appends language-agnostic title/description/meta formula
2026-08-16 16:57:36 +02:00
// guidance to the enhance user template (before {{var}} render). Empty templates
// are no-ops. Shared skeleton stays in company/built-in prompts; formulas only
// constrain structure for the active language via {{language}} elsewhere.
2026-08-16 23:42:00 +02:00
// Meta instructions come from description_template.metaTitle / metaDescription
// (legacy A1 / cats.json) and are distinct from HTML description sections.
// Attribute allowlist / formula-key guidance is AppendAttributeConstraints.
2026-08-16 16:57:36 +02:00
func AppendFormulaConstraints ( userTpl string , titleTemplate , descriptionTemplate any ) string {
userTpl = strings . TrimSpace ( userTpl )
2026-08-16 23:42:00 +02:00
blocks := [] string {
FormatTitleFormulaConstraint ( titleTemplate ),
FormatDescriptionFormulaConstraint ( descriptionTemplate ),
FormatMetaFormulaConstraint ( descriptionTemplate ),
}
var joined strings . Builder
for _ , block := range blocks {
if block == "" {
continue
}
if joined . Len () > 0 {
joined . WriteString ( "\n\n" )
}
joined . WriteString ( block )
}
if joined . Len () == 0 {
return userTpl
}
if userTpl == "" {
return joined . String ()
}
return userTpl + "\n\n" + joined . String ()
}
// MaxAttrAllowlistPromptKeys caps Allowed attribute keys listed in enhance prompts.
const MaxAttrAllowlistPromptKeys = 40
// AppendAttributeConstraints appends category_attributes allowlist + title-formula
// variable keys so the model can emit JSON "attrs" validated via AttrsForEnhance.
// No-op when both allowlist and formula keys are empty.
func AppendAttributeConstraints ( userTpl string , allowed map [ string ] struct {}, titleTemplate any ) string {
userTpl = strings . TrimSpace ( userTpl )
block := FormatAttributeAllowlistConstraint ( allowed , titleTemplate )
if block == "" {
2026-08-16 16:57:36 +02:00
return userTpl
}
2026-08-16 23:42:00 +02:00
if userTpl == "" {
return block
}
if strings . Contains ( userTpl , `Allowed attribute keys (JSON "attrs" object` ) {
return userTpl
}
return userTpl + "\n\n" + block
}
// FormatAttributeAllowlistConstraint lists category allowlist keys and title-formula
// attr slots for the Attributes enhance role. When allowed is nil, only formula
// keys are listed (unit-test / sanitize-only paths).
func FormatAttributeAllowlistConstraint ( allowed map [ string ] struct {}, titleTemplate any ) string {
keys := preferredAttrKeyList ( allowed , MaxAttrAllowlistPromptKeys )
formulaKeys := titleFormulaAttrKeys ( titleTemplate )
if len ( keys ) == 0 && len ( formulaKeys ) == 0 {
return ""
}
2026-08-16 16:57:36 +02:00
var b strings . Builder
2026-08-16 23:42:00 +02:00
b . WriteString ( "Allowed attribute keys (JSON \"attrs\" object — remap feed labels onto these; omit unknowns):\n" )
if len ( keys ) > 0 {
b . WriteString ( "- category: " )
b . WriteString ( strings . Join ( keys , ", " ))
b . WriteByte ( '\n' )
} else {
b . WriteString ( "- category: (core characteristics only — brand, product_model, dims, …)\n" )
}
if len ( formulaKeys ) > 0 {
b . WriteString ( "- title formula slots: " )
b . WriteString ( strings . Join ( formulaKeys , ", " ))
b . WriteByte ( '\n' )
}
b . WriteString ( "Fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs or zero dimensions." )
return strings . TrimSpace ( b . String ())
}
func preferredAttrKeyList ( allowed map [ string ] struct {}, maxKeys int ) [] string {
if len ( allowed ) == 0 || maxKeys <= 0 {
return nil
}
prefer := preferredAllowedKeys ( allowed )
seen := map [ string ] struct {}{}
out := make ([] string , 0 , len ( prefer ))
for _ , k := range prefer {
k = strings . TrimSpace ( k )
if k == "" {
continue
}
if _ , ok := seen [ k ]; ok {
continue
2026-08-16 16:57:36 +02:00
}
2026-08-16 23:42:00 +02:00
seen [ k ] = struct {}{}
out = append ( out , k )
2026-08-16 16:57:36 +02:00
}
2026-08-16 23:42:00 +02:00
sort . Strings ( out )
if len ( out ) > maxKeys {
out = out [: maxKeys ]
2026-08-16 16:57:36 +02:00
}
2026-08-16 23:42:00 +02:00
return out
}
func titleFormulaAttrKeys ( template any ) [] string {
elements , _ , ok := parseTitleFormula ( template )
if ! ok || len ( elements ) == 0 {
return nil
}
seen := map [ string ] struct {}{}
var out [] string
for _ , el := range elements {
if el . Type != "variable" {
continue
}
k := strings . TrimSpace ( el . Value )
if k == "" {
continue
}
canon := canonicalizeAttrKey ( k )
if canon == "" {
canon = strings . ToLower ( k )
}
if _ , ok := seen [ canon ]; ok {
continue
}
seen [ canon ] = struct {}{}
out = append ( out , canon )
}
sort . Strings ( out )
return out
2026-08-16 16:57:36 +02:00
}
// FormatTitleFormulaConstraint turns categories.title_template into enhance-prompt
// constraints (element order: literal text + attribute variable keys).
func FormatTitleFormulaConstraint ( template any ) string {
elements , separator , ok := parseTitleFormula ( template )
if ! ok || len ( elements ) == 0 {
return ""
}
sep := separator
if sep == "" {
sep = " "
}
var b strings . Builder
b . WriteString ( "Title formula (order matters; join with " )
b . WriteString ( strconv . Quote ( sep ))
b . WriteString ( "). Build name from Attrs using this structure:\n" )
for i , el := range elements {
switch el . Type {
case "text" :
fmt . Fprintf ( & b , "%d. text %s\n" , i + 1 , strconv . Quote ( el . Value ))
case "variable" :
key := strings . TrimSpace ( el . Value )
if key == "" {
continue
}
fmt . Fprintf ( & b , "%d. attr [%s]\n" , i + 1 , key )
default :
continue
}
}
2026-08-17 00:39:25 +02:00
b . WriteString ( "Prefer Attrs values for [attr] slots; keep literal text as written; write name in {{language}}; never emit brand-only when product_type or product_model slots exist." )
2026-08-16 16:57:36 +02:00
return strings . TrimSpace ( b . String ())
}
2026-08-16 23:42:00 +02:00
// FormatMetaFormulaConstraint turns description_template.metaTitle / metaDescription
// (cats.json / A1 SEO instructions) into enhance-prompt constraints distinct from
// HTML description sections.
func FormatMetaFormulaConstraint ( template any ) string {
metaTitle , metaDesc := parseMetaFormulaInstructions ( template )
if metaTitle == "" && metaDesc == "" {
return ""
}
var b strings . Builder
b . WriteString ( "SEO meta formula (REQUIRED — distinct from description HTML; plain text only):\n" )
b . WriteString ( "Emit meta_title and meta_description as separate JSON fields in {{language}}.\n" )
if metaTitle != "" {
fmt . Fprintf ( & b , "- meta_title (50-60 chars): %s\n" , metaTitle )
} else {
b . WriteString ( "- meta_title: 50-60 chars, product name + main benefit or use case\n" )
}
if metaDesc != "" {
fmt . Fprintf ( & b , "- meta_description (120-155 chars): %s\n" , metaDesc )
} else {
b . WriteString ( "- meta_description: 120-155 chars, factual SEO snippet; never HTML\n" )
}
return strings . TrimSpace ( b . String ())
}
2026-08-16 16:57:36 +02:00
// FormatDescriptionFormulaConstraint turns categories.description_template sections
// into bullet instructions for the enhance user prompt.
func FormatDescriptionFormulaConstraint ( template any ) string {
sections , ok := parseDescriptionFormulaSections ( template )
if ! ok || len ( sections ) == 0 {
return ""
}
var b strings . Builder
2026-08-16 19:21:49 +02:00
b . WriteString ( "Description formula (REQUIRED — overrides any shorter \"1-2 sentences\" rule):\n" )
b . WriteString ( "Emit description as ONE HTML string covering each section in order in {{language}}.\n" )
b . WriteString ( "Use tags matching section type: h1/h2/h3/h4 → <hN>…</hN>, p → <p>…</p>, ul → <ul><li>…</li></ul>.\n" )
2026-08-16 16:57:36 +02:00
for _ , s := range sections {
typ := strings . TrimSpace ( s . Type )
instr := strings . TrimSpace ( s . Instructions )
if typ == "" && instr == "" {
continue
}
if typ == "" {
typ = "section"
}
if instr == "" {
fmt . Fprintf ( & b , "- %s\n" , typ )
continue
}
fmt . Fprintf ( & b , "- %s: %s\n" , typ , instr )
}
return strings . TrimSpace ( b . String ())
}
2026-08-16 19:21:49 +02:00
// descriptionFormulaSystemOverride is appended to the enhance system template when a
// category description_template is present so company/built-in "1-2 sentences" rules
// cannot override the per-category formula (A1 category definition pages).
const descriptionFormulaSystemOverride = `When the user message includes a "Description formula", obey those sections over any shorter "1-2 sentences" / "1-3 paragraphs" guidance: emit one HTML string using tags matching each section type (h1/h2/h3/h4, p, ul), in order, in {{ language }} . Never ignore the Description formula.`
// AppendDescriptionFormulaSystemOverride strengthens the system prompt when a
// description formula is active. No-op when template is empty/unparseable.
func AppendDescriptionFormulaSystemOverride ( systemTpl string , descriptionTemplate any ) string {
if FormatDescriptionFormulaConstraint ( descriptionTemplate ) == "" {
return strings . TrimSpace ( systemTpl )
}
systemTpl = strings . TrimSpace ( systemTpl )
if systemTpl == "" {
return descriptionFormulaSystemOverride
}
if strings . Contains ( systemTpl , "Description formula" ) {
return systemTpl
}
return systemTpl + "\n" + descriptionFormulaSystemOverride
}
2026-08-16 23:42:00 +02:00
// titleFormulaSystemOverride is appended to the enhance system template when a
// category title_template is present so company/built-in "short retail title"
// rules cannot ignore the Title formula (mirrors description formula override).
2026-08-17 00:39:25 +02:00
const titleFormulaSystemOverride = `When the user message includes a "Title formula", obey that structure for "name" over any shorter "short retail title" guidance: build name from Attrs in the given element order, keep literal text as written, write name in {{ language }} . Never ignore the Title formula. Never emit brand-only when the formula includes product_type or product_model.`
2026-08-16 23:42:00 +02:00
// AppendTitleFormulaSystemOverride strengthens the system prompt when a title
// formula is active. No-op when template is empty/unparseable.
func AppendTitleFormulaSystemOverride ( systemTpl string , titleTemplate any ) string {
if FormatTitleFormulaConstraint ( titleTemplate ) == "" {
return strings . TrimSpace ( systemTpl )
}
systemTpl = strings . TrimSpace ( systemTpl )
if systemTpl == "" {
return titleFormulaSystemOverride
}
if strings . Contains ( systemTpl , `When the user message includes a "Title formula"` ) {
return systemTpl
}
return systemTpl + "\n" + titleFormulaSystemOverride
}
// 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.
const categoryEnhanceSystemOverlay = `When the user message includes category guidance, apply it to "name", "description", and "attrs" (tone/focus for the title and body, plus attribute extraction onto Allowed attribute keys). Structural Title/Description formula blocks and Allowed attribute keys in the user message still take precedence when present.`
// AppendCategoryEnhanceSystemOverlay strengthens the system prompt when a
// per-category enhance overlay (categories.prompt) is set. No-op when empty.
func AppendCategoryEnhanceSystemOverlay ( systemTpl , categoryPrompt string ) string {
if strings . TrimSpace ( categoryPrompt ) == "" {
return strings . TrimSpace ( systemTpl )
}
systemTpl = strings . TrimSpace ( systemTpl )
if systemTpl == "" {
return categoryEnhanceSystemOverlay
}
marker := `apply it to "name", "description", and "attrs"`
if strings . Contains ( systemTpl , marker ) {
return systemTpl
}
// Upgrade older name+description-only overlay without stacking duplicates.
legacy := `apply it to BOTH "name" and "description"`
if strings . Contains ( systemTpl , legacy ) {
return strings . Replace ( systemTpl , legacy , marker , 1 )
}
return systemTpl + "\n" + categoryEnhanceSystemOverlay
}
// metaFormulaSystemOverride is appended when description_template carries metaTitle
// / metaDescription instructions so enhance emits SEO fields separately from HTML.
const metaFormulaSystemOverride = `When the user message includes an "SEO meta formula", emit meta_title and meta_description as plain SEO text (not HTML) distinct from description. Obey the character guidance in the formula.`
// AppendMetaFormulaSystemOverride strengthens the system prompt when meta formula
// instructions are present on description_template. No-op when absent.
func AppendMetaFormulaSystemOverride ( systemTpl string , descriptionTemplate any ) string {
if FormatMetaFormulaConstraint ( descriptionTemplate ) == "" {
return strings . TrimSpace ( systemTpl )
}
systemTpl = strings . TrimSpace ( systemTpl )
if systemTpl == "" {
return metaFormulaSystemOverride
}
if strings . Contains ( systemTpl , "SEO meta formula" ) {
return systemTpl
}
return systemTpl + "\n" + metaFormulaSystemOverride
}
2026-08-16 21:35:48 +02:00
// descriptionSatisfiesFormula reports whether desc includes the HTML tags required
// by categories.description_template sections. No formula → always true.
func descriptionSatisfiesFormula ( desc string , template any ) bool {
sections , ok := parseDescriptionFormulaSections ( template )
if ! ok || len ( sections ) == 0 {
return true
}
desc = strings . TrimSpace ( desc )
if desc == "" || desc == "<nil>" {
return false
}
types := make ([] string , 0 , len ( sections ))
for _ , s := range sections {
types = append ( types , s . Type )
}
return ! company . DescriptionMissingFormulaHTMLTags ( desc , types )
}
2026-08-16 16:57:36 +02:00
type titleFormulaElement struct {
Type string `json:"type"`
Value string `json:"value"`
}
type descriptionFormulaSection struct {
Type string `json:"type"`
Instructions string `json:"instructions"`
}
func parseTitleFormula ( template any ) ( elements [] titleFormulaElement , separator string , ok bool ) {
if template == nil {
return nil , "" , false
}
obj , err := asObjectMap ( template )
if err != nil || obj == nil {
return nil , "" , false
}
sep , _ := obj [ "separator" ].( string )
rawEls , exists := obj [ "elements" ]
if ! exists || rawEls == nil {
return nil , "" , false
}
b , err := json . Marshal ( rawEls )
if err != nil {
return nil , "" , false
}
var parsed [] titleFormulaElement
if err := json . Unmarshal ( b , & parsed ); err != nil {
return nil , "" , false
}
out := make ([] titleFormulaElement , 0 , len ( parsed ))
for _ , el := range parsed {
t := strings . ToLower ( strings . TrimSpace ( el . Type ))
v := strings . TrimSpace ( el . Value )
if t != "text" && t != "variable" {
continue
}
if v == "" {
continue
}
out = append ( out , titleFormulaElement { Type : t , Value : v })
}
if len ( out ) == 0 {
return nil , "" , false
}
return out , sep , true
}
func parseDescriptionFormulaSections ( template any ) ([] descriptionFormulaSection , bool ) {
if template == nil {
return nil , false
}
obj , err := asObjectMap ( template )
if err != nil || obj == nil {
return nil , false
}
raw , exists := obj [ "sections" ]
if ! exists || raw == nil {
return nil , false
}
b , err := json . Marshal ( raw )
if err != nil {
return nil , false
}
var parsed [] descriptionFormulaSection
if err := json . Unmarshal ( b , & parsed ); err != nil {
return nil , false
}
out := make ([] descriptionFormulaSection , 0 , len ( parsed ))
for _ , s := range parsed {
t := strings . ToLower ( strings . TrimSpace ( s . Type ))
instr := strings . TrimSpace ( s . Instructions )
if t == "" && instr == "" {
continue
}
out = append ( out , descriptionFormulaSection { Type : t , Instructions : instr })
}
return out , len ( out ) > 0
}
2026-08-16 23:42:00 +02:00
// parseMetaFormulaInstructions reads metaTitle / metaDescription instruction
// strings from description_template (camelCase as stored by the category UI).
func parseMetaFormulaInstructions ( template any ) ( metaTitle , metaDescription string ) {
if template == nil {
return "" , ""
}
obj , err := asObjectMap ( template )
if err != nil || obj == nil {
return "" , ""
}
metaTitle = strings . TrimSpace ( stringFromAny ( obj [ "metaTitle" ]))
if metaTitle == "" {
metaTitle = strings . TrimSpace ( stringFromAny ( obj [ "meta_title" ]))
}
metaDescription = strings . TrimSpace ( stringFromAny ( obj [ "metaDescription" ]))
if metaDescription == "" {
metaDescription = strings . TrimSpace ( stringFromAny ( obj [ "meta_description" ]))
}
return metaTitle , metaDescription
}
2026-08-16 16:57:36 +02:00
// categoryFormulasFor resolves title/description templates for a category key
// (unique_id or name). Explicit ProductInput fields win over the job cache map.
func categoryFormulasFor ( in ProductInput , category string ) ( title , description any ) {
if in . TitleTemplate != nil || in . DescriptionTemplate != nil {
return in . TitleTemplate , in . DescriptionTemplate
}
key := strings . ToLower ( strings . TrimSpace ( category ))
if key == "" || len ( in . CategoryFormulasByKey ) == 0 {
return nil , nil
}
f , ok := in . CategoryFormulasByKey [ key ]
if ! ok {
return nil , nil
}
return f . TitleTemplate , f . DescriptionTemplate
}
func asObjectMap ( v any ) ( map [ string ] any , error ) {
switch t := v .( type ) {
case map [ string ] any :
return t , nil
case nil :
return nil , nil
default :
b , err := json . Marshal ( t )
if err != nil {
return nil , err
}
if len ( b ) == 0 || string ( b ) == "null" {
return nil , nil
}
var obj map [ string ] any
if err := json . Unmarshal ( b , & obj ); err != nil {
return nil , err
}
return obj , nil
}
}