fixes
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user