This commit is contained in:
2026-08-16 16:57:36 +02:00
parent 96f8c1115c
commit 532d439c41
106 changed files with 10147 additions and 494 deletions
@@ -0,0 +1,74 @@
package aiprompts
import (
"context"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/google/uuid"
)
// productEnhanceUpsertLanguages returns ISO 639-1 codes to store product_enhance
// under. Never returns LangPromptAny ("*") — ai_prompt_templates.language has
// CHECK (language ~ '^[a-z]{2}$'). Categories.prompt JSON may still use "*".
func productEnhanceUpsertLanguages(contentLangs []string, primary string) []string {
primary = company.NormalizeLanguage(primary)
if primary == "" || primary == company.LangPromptAny || !company.IsAllowedLanguage(primary) {
primary = company.DefaultLanguage
}
out := make([]string, 0, len(contentLangs)+1)
seen := map[string]struct{}{}
add := func(code string) {
code = company.NormalizeLanguage(code)
if code == "" || code == company.LangPromptAny || !company.IsAllowedLanguage(code) {
return
}
if _, ok := seen[code]; ok {
return
}
seen[code] = struct{}{}
out = append(out, code)
}
add(primary)
for _, lang := range contentLangs {
add(lang)
}
if len(out) == 0 {
return []string{company.DefaultLanguage}
}
return out
}
// ApplyBuiltInProductEnhance upserts BuiltInDefaults for product_enhance under
// the company primary language and each content language. Templates still use
// {{language}} at render time; Resolve falls back requested → primary → built-in.
// Do not store LangPromptAny ("*") in ai_prompt_templates (DB language CHECK).
func (s *Service) ApplyBuiltInProductEnhance(ctx context.Context, companyID uuid.UUID) (languagesApplied int, err error) {
def, ok := DefaultFor(KeyProductEnhance)
if !ok {
return 0, ErrInvalidKey
}
primary := company.LoadLanguage(ctx, s.Pool, companyID)
langs := productEnhanceUpsertLanguages(
company.LoadContentLanguages(ctx, s.Pool, companyID),
primary,
)
enabled := true
items := make([]UpdateItem, 0, len(langs))
for _, lang := range langs {
items = append(items, UpdateItem{
Key: KeyProductEnhance,
Language: lang,
SystemTemplate: def.SystemTemplate,
UserTemplate: def.UserTemplate,
IsEnabled: &enabled,
})
}
_, err = s.Update(ctx, companyID, UpdateInput{
Language: langs[0],
Prompts: items,
})
if err != nil {
return 0, err
}
return len(langs), nil
}
@@ -0,0 +1,71 @@
package aiprompts
import (
"regexp"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
)
var languageFmt = regexp.MustCompile(`^[a-z]{2}$`)
func TestProductEnhanceUpsertLanguages_neverStar(t *testing.T) {
t.Parallel()
cases := []struct {
name string
contentLangs []string
primary string
want []string
}{
{
name: "primary_only",
contentLangs: nil,
primary: "sl",
want: []string{"sl"},
},
{
name: "primary_plus_content",
contentLangs: []string{"sl", "en", "de"},
primary: "sl",
want: []string{"sl", "en", "de"},
},
{
name: "drops_star_and_invalid",
contentLangs: []string{"*", "sl", "xx", "", "EN"},
primary: "*",
want: []string{"en", "sl"},
},
{
name: "dedupes_primary_in_content",
contentLangs: []string{"sl", "sl", "en"},
primary: "sl",
want: []string{"sl", "en"},
},
{
name: "empty_falls_back_default",
contentLangs: []string{"*", ""},
primary: "",
want: []string{company.DefaultLanguage},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := productEnhanceUpsertLanguages(tc.contentLangs, tc.primary)
if len(got) != len(tc.want) {
t.Fatalf("len=%d want %d (%v)", len(got), len(tc.want), got)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("got %v want %v", got, tc.want)
}
if !languageFmt.MatchString(got[i]) {
t.Fatalf("language %q fails ai_prompt_templates_language_fmt", got[i])
}
if got[i] == company.LangPromptAny {
t.Fatalf("must not upsert LangPromptAny")
}
}
})
}
}
+31 -5
View File
@@ -1,5 +1,7 @@
package aiprompts
import "strings"
// Prompt keys stored in ai_prompt_templates.prompt_key.
const (
KeyProductEnhance = "product_enhance"
@@ -45,6 +47,32 @@ type DefaultTemplate struct {
UserTemplate string `json:"user_template"`
}
// CategoryEnhanceUserTemplate is the shared per-category (and built-in) enhance USER
// message. Includes {{name}} {{description}} {{attrs}} {{category}} {{language}}.
// Compatible with the enhance system JSON schema {"name","description"} — not a
// competing HTML marketing document. Title/description formulas stay in
// title_template / description_template and are appended at render time as plain
// text instructions (see processing.AppendFormulaConstraints).
// Used by local A1/Demo prompt repair and seed-a1 overlays.
const CategoryEnhanceUserTemplate = `Your reply is parsed as JSON {"name":"string","description":"string"} only (system schema). Write name and description in {{language}} (do not hardcode a language).
- name: short retail title; follow any Title formula constraints that follow; use Attrs
- description: prefer 1-3 factual paragraphs as ONE string; limited HTML (<h2><p><ul><li>) is allowed if needed — do NOT emit a competing full HTML document, <name>/<metaDescription> blocks, or separate schema
Category: {{category}}
Name: {{name}}
Desc: {{description}}
Attrs: {{attrs}}`
// CategoryEnhancePromptNeedsRepair reports whether a stored categories.prompt value
// should be replaced by CategoryEnhanceUserTemplate (idempotent equality check).
func CategoryEnhancePromptNeedsRepair(prompt string) bool {
p := strings.TrimSpace(prompt)
if p == "" {
return false
}
return p != strings.TrimSpace(CategoryEnhanceUserTemplate)
}
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
var BuiltInDefaults = []DefaultTemplate{
{
@@ -56,15 +84,13 @@ Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"}
- name: short retail title
- description: 1-2 factual sentences
- description: 1-2 factual sentences; never copy name as description
- When Desc is empty or the same as Name, write 1-2 factual sentences from Category and Attrs only (do not invent specs)
- Write name and description in {{language}}
Example:
{"name":"Acme Widget Pro","description":"Durable widget for everyday use. Clear specs, ready to ship."}
{{brand_voice}}`,
UserTemplate: `Category: {{category}}
Name: {{name}}
Desc: {{description}}
Attrs: {{attrs}}`,
UserTemplate: CategoryEnhanceUserTemplate,
},
{
Key: KeySEOMeta,
+59
View File
@@ -0,0 +1,59 @@
package aiprompts
import (
"strings"
"testing"
)
func TestCategoryEnhanceUserTemplateHasRequiredVars(t *testing.T) {
t.Parallel()
vars := ExtractVariables(CategoryEnhanceUserTemplate)
want := []string{"language", "category", "name", "description", "attrs"}
got := map[string]struct{}{}
for _, v := range vars {
got[v] = struct{}{}
}
for _, w := range want {
if _, ok := got[w]; !ok {
t.Fatalf("CategoryEnhanceUserTemplate missing {{%s}}; vars=%v", w, vars)
}
}
lower := strings.ToLower(CategoryEnhanceUserTemplate)
if strings.Contains(lower, "100 besed") || strings.Contains(lower, "gpt predloga") {
t.Fatal("template must not demand legacy long HTML marketing docs")
}
if !strings.Contains(CategoryEnhanceUserTemplate, `{"name":"string","description":"string"}`) {
t.Fatal("template should reference JSON schema shape")
}
if !strings.Contains(lower, "{{language}}") {
t.Fatal("language must come from {{language}}")
}
}
func TestCategoryEnhancePromptNeedsRepair(t *testing.T) {
t.Parallel()
if CategoryEnhancePromptNeedsRepair("") {
t.Fatal("empty should not need repair")
}
if CategoryEnhancePromptNeedsRepair(CategoryEnhanceUserTemplate) {
t.Fatal("canonical template should be idempotent")
}
if CategoryEnhancePromptNeedsRepair(" " + CategoryEnhanceUserTemplate + "\n") {
t.Fatal("whitespace-trimmed canonical should be idempotent")
}
legacy := `Ustvari nov opis\n<H2>foo</H2>\nAttrs missing`
if !CategoryEnhancePromptNeedsRepair(legacy) {
t.Fatal("legacy HTML prompt should need repair")
}
}
func TestBuiltInProductEnhanceUsesSharedUserTemplate(t *testing.T) {
t.Parallel()
def, ok := DefaultFor(KeyProductEnhance)
if !ok {
t.Fatal("missing product_enhance default")
}
if def.UserTemplate != CategoryEnhanceUserTemplate {
t.Fatalf("UserTemplate must be CategoryEnhanceUserTemplate")
}
}
+31 -10
View File
@@ -87,8 +87,11 @@ func (s *Service) GetBundle(ctx context.Context, companyID uuid.UUID, language s
}, nil
}
// Resolve returns the effective templates for one key + language
// (custom if enabled for lang, else built-in). No cross-language company fallback.
// Resolve returns the effective templates for one key + language.
// Fallback: requested lang → "*" → company primary language → built-in.
// Language on the result is always the requested (normalized) lang so {{language}}
// still resolves to the content language being generated — templates are shared,
// not duplicated per language.
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID, key, language string) (Resolved, error) {
if !ValidPromptKey(key) {
return Resolved{}, ErrInvalidKey
@@ -101,11 +104,19 @@ func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID, key, languag
if err != nil {
lang = company.DefaultLanguage
}
st, err := s.loadOne(ctx, companyID, key, lang)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Resolved{}, err
primary := company.LoadLanguage(ctx, s.Pool, companyID)
candidates := []string{lang, company.LangPromptAny}
if primary != "" && primary != lang && primary != company.LangPromptAny {
candidates = append(candidates, primary)
}
if err == nil && st.isEnabled {
for _, cand := range candidates {
st, err := s.loadOne(ctx, companyID, key, cand)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Resolved{}, err
}
if err != nil || !st.isEnabled {
continue
}
sys := strings.TrimSpace(st.systemTemplate)
user := strings.TrimSpace(st.userTemplate)
if sys == "" {
@@ -155,9 +166,15 @@ func (s *Service) Update(ctx context.Context, companyID uuid.UUID, in UpdateInpu
if langRaw == "" {
langRaw = defaultLang
}
lang, err := company.ParseLanguage(langRaw, false)
if err != nil {
return Bundle{}, fmt.Errorf("%w: language %q", ErrInvalidInput, langRaw)
var lang string
if langRaw == company.LangPromptAny {
lang = company.LangPromptAny
} else {
parsed, err := company.ParseLanguage(langRaw, false)
if err != nil {
return Bundle{}, fmt.Errorf("%w: language %q", ErrInvalidInput, langRaw)
}
lang = parsed
}
lastLang = lang
if item.Reset {
@@ -196,7 +213,11 @@ func (s *Service) Update(ctx context.Context, companyID uuid.UUID, in UpdateInpu
if err := tx.Commit(ctx); err != nil {
return Bundle{}, err
}
return s.GetBundle(ctx, companyID, lastLang)
bundleLang := lastLang
if bundleLang == company.LangPromptAny {
bundleLang = defaultLang
}
return s.GetBundle(ctx, companyID, bundleLang)
}
func (s *Service) loadAll(ctx context.Context, companyID uuid.UUID) ([]stored, error) {