Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
package aiprompts
import "errors"
var (
ErrInvalidKey = errors.New("invalid prompt key")
ErrInvalidInput = errors.New("invalid prompt input")
)
// ClientError maps known client errors to safe API messages.
func ClientError(err error) (msg string, ok bool) {
switch {
case errors.Is(err, ErrInvalidKey):
return "invalid prompt key", true
case errors.Is(err, ErrInvalidInput):
return "invalid prompt templates", true
default:
// Wrapped ErrInvalidKey / ErrInvalidInput from fmt.Errorf("%w: …")
if errors.Is(err, ErrInvalidKey) {
return err.Error(), true
}
if errors.Is(err, ErrInvalidInput) {
return err.Error(), true
}
return "", false
}
}
+129
View File
@@ -0,0 +1,129 @@
package aiprompts
// Prompt keys stored in ai_prompt_templates.prompt_key.
const (
KeyProductEnhance = "product_enhance"
KeySEOMeta = "seo_meta"
KeyCampaignEmail = "campaign_email"
)
// MaxSystemRunes / MaxUserRunes bound stored templates (prompt-injection surface).
const (
MaxSystemRunes = 6000
MaxUserRunes = 4000
)
// Variable describes a placeholder tenants can insert into templates.
type Variable struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
Keys []string `json:"keys"` // prompt_key values that support this variable
}
// Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal).
var VariableCatalog = []Variable{
{Name: "name", Label: "Product name", Description: "Current product title", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "description", Label: "Description", Description: "Current product description", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "category", Label: "Category", Description: "Resolved category name", Keys: []string{KeyProductEnhance, KeySEOMeta}},
{Name: "attrs", Label: "Attributes", Description: "Compact JSON of product attributes", Keys: []string{KeyProductEnhance}},
{Name: "gtin", Label: "GTIN", Description: "Product GTIN / barcode when present", Keys: []string{KeyProductEnhance}},
{Name: "brand", Label: "Brand name", Description: "Company or product brand label", Keys: []string{KeySEOMeta, KeyCampaignEmail}},
{Name: "brand_voice", Label: "Brand voice", Description: "Brand kit tone / dos / don'ts block", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
{Name: "language", Label: "Content language", Description: "Company content language (English display name)", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
{Name: "campaign_prompt", Label: "Campaign brief", Description: "Per-campaign user brief or template default", Keys: []string{KeyCampaignEmail}},
{Name: "products", Label: "Product list", Description: "Plain-text product snippets for the campaign", Keys: []string{KeyCampaignEmail}},
{Name: "template_key", Label: "Template key", Description: "Campaign template id (christmas, custom, …)", Keys: []string{KeyCampaignEmail}},
}
// DefaultTemplate is the built-in prompt when the company has no custom row or disabled it.
type DefaultTemplate struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
}
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
var BuiltInDefaults = []DefaultTemplate{
{
Key: KeyProductEnhance,
Label: "Product title & description",
Description: "Used when processing products (AI enhance step).",
SystemTemplate: `Retail product copywriter.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string"}
- name: short retail title
- description: 1-2 factual sentences
- 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}}`,
},
{
Key: KeySEOMeta,
Label: "SEO meta title & description",
Description: "Used when applying AI SEO meta to a product.",
SystemTemplate: `SEO meta writer for ecommerce.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"meta_title":"string","meta_description":"string"}
- meta_title: 50-60 chars, product + benefit
- meta_description: 120-155 chars, factual
- Write meta_title and meta_description in {{language}}
Example:
{"meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."}
{{brand_voice}}`,
UserTemplate: `Name: {{name}}
Category: {{category}}
Desc: {{description}}`,
},
{
Key: KeyCampaignEmail,
Label: "Campaign email",
Description: "Used when generating marketing emails with AI.",
SystemTemplate: `Marketing email writer.
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"subject":"string","html_body":"string","plain_body":"string"}
- subject: short
- html_body: simple HTML (<p>, <ul>, <a> only)
- plain_body: plain text mirror
- Write subject, html_body, and plain_body in {{language}}
Example:
{"subject":"Holiday picks from Acme","html_body":"<p>Season's greetings.</p><ul><li>Widget Pro</li></ul><p><a href=\"#\">Shop now</a></p>","plain_body":"Season's greetings.\n- Widget Pro\nShop now"}
{{brand_voice}}`,
UserTemplate: `{{campaign_prompt}}
Products:
{{products}}
Brand: {{brand}}
Template: {{template_key}}`,
},
}
// ValidPromptKey reports whether key is a known prompt_key.
func ValidPromptKey(key string) bool {
switch key {
case KeyProductEnhance, KeySEOMeta, KeyCampaignEmail:
return true
default:
return false
}
}
// DefaultFor returns the built-in default for key, or empty if unknown.
func DefaultFor(key string) (DefaultTemplate, bool) {
for _, d := range BuiltInDefaults {
if d.Key == key {
return d, true
}
}
return DefaultTemplate{}, false
}
+61
View File
@@ -0,0 +1,61 @@
package aiprompts
import (
"regexp"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
// varToken matches {{name}} with optional whitespace. Only [a-z0-9_]+ names.
var varToken = regexp.MustCompile(`\{\{\s*([a-z][a-z0-9_]*)\s*\}\}`)
// Vars is the substitution map for Render (keys without braces).
type Vars map[string]string
// Render replaces {{var}} tokens. Unknown variables become empty string (safe, deterministic).
// Templates are sanitized before render; values should already be sanitized by callers.
func Render(template string, vars Vars) string {
template = strings.TrimSpace(template)
if template == "" {
return ""
}
return varToken.ReplaceAllStringFunc(template, func(match string) string {
sub := varToken.FindStringSubmatch(match)
if len(sub) < 2 {
return ""
}
name := sub[1]
if vars == nil {
return ""
}
return vars[name]
})
}
// SanitizeTemplate cleans and bounds a stored prompt template.
func SanitizeTemplate(s string, maxRunes int) string {
return security.SanitizePrompt(s, maxRunes)
}
// ExtractVariables returns unique variable names found in template (sorted order of first appearance).
func ExtractVariables(template string) []string {
matches := varToken.FindAllStringSubmatch(template, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) < 2 {
continue
}
name := m[1]
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
@@ -0,0 +1,49 @@
package aiprompts
import "testing"
func TestRender_replacesKnownVars(t *testing.T) {
t.Parallel()
got := Render("Hello {{name}} in {{category}}", Vars{
"name": "Widget",
"category": "Tools",
})
want := "Hello Widget in Tools"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestRender_unknownVarEmpty(t *testing.T) {
t.Parallel()
got := Render("X={{missing}}Y", Vars{"name": "a"})
if got != "X=Y" {
t.Fatalf("got %q", got)
}
}
func TestRender_whitespaceInBraces(t *testing.T) {
t.Parallel()
got := Render("{{ name }}", Vars{"name": "ok"})
if got != "ok" {
t.Fatalf("got %q", got)
}
}
func TestExtractVariables(t *testing.T) {
t.Parallel()
got := ExtractVariables("{{name}} and {{name}} then {{brand_voice}}")
if len(got) != 2 || got[0] != "name" || got[1] != "brand_voice" {
t.Fatalf("got %#v", got)
}
}
func TestValidPromptKey(t *testing.T) {
t.Parallel()
if !ValidPromptKey(KeyProductEnhance) {
t.Fatal("expected product_enhance valid")
}
if ValidPromptKey("nope") {
t.Fatal("expected nope invalid")
}
}
+238
View File
@@ -0,0 +1,238 @@
package aiprompts
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service loads and stores per-company AI prompt templates.
type Service struct {
Pool *pgxpool.Pool
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{Pool: pool}
}
type stored struct {
key string
language string
systemTemplate string
userTemplate string
isEnabled bool
updatedAt time.Time
}
// GetBundle returns all prompt keys with effective templates for language + variable catalog.
func (s *Service) GetBundle(ctx context.Context, companyID uuid.UUID, language string) (Bundle, error) {
lang, err := company.ParseLanguage(language, true)
if err != nil {
lang = company.LoadLanguage(ctx, s.Pool, companyID)
}
contentLangs := company.LoadContentLanguages(ctx, s.Pool, companyID)
storedRows, err := s.loadAll(ctx, companyID)
if err != nil {
return Bundle{}, err
}
byKeyLang := map[string]stored{}
customLangs := map[string][]string{}
for _, st := range storedRows {
byKeyLang[st.key+"\x00"+st.language] = st
customLangs[st.key] = appendUnique(customLangs[st.key], st.language)
}
out := make([]Template, 0, len(BuiltInDefaults))
for _, def := range BuiltInDefaults {
t := Template{
Key: def.Key,
Language: lang,
Label: def.Label,
Description: def.Description,
IsDefault: true,
IsCustom: false,
IsEnabled: true,
}
if st, ok := byKeyLang[def.Key+"\x00"+lang]; ok {
t.IsCustom = true
t.IsDefault = false
t.IsEnabled = st.isEnabled
t.UpdatedAt = st.updatedAt
if st.isEnabled {
t.SystemTemplate = st.systemTemplate
t.UserTemplate = st.userTemplate
} else {
t.SystemTemplate = def.SystemTemplate
t.UserTemplate = def.UserTemplate
t.IsDefault = true
}
} else {
t.SystemTemplate = def.SystemTemplate
t.UserTemplate = def.UserTemplate
}
out = append(out, t)
}
return Bundle{
Language: lang,
Prompts: out,
Variables: VariableCatalog,
CustomLanguages: customLangs,
ContentLanguages: contentLangs,
}, nil
}
// Resolve returns the effective templates for one key + language
// (custom if enabled for lang, else built-in). No cross-language company fallback.
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID, key, language string) (Resolved, error) {
if !ValidPromptKey(key) {
return Resolved{}, ErrInvalidKey
}
def, ok := DefaultFor(key)
if !ok {
return Resolved{}, ErrInvalidKey
}
lang, err := company.ParseLanguage(language, true)
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
}
if err == nil && st.isEnabled {
sys := strings.TrimSpace(st.systemTemplate)
user := strings.TrimSpace(st.userTemplate)
if sys == "" {
sys = def.SystemTemplate
}
if user == "" {
user = def.UserTemplate
}
return Resolved{
Key: key,
Language: lang,
SystemTemplate: sys,
UserTemplate: user,
IsCustom: true,
}, nil
}
return Resolved{
Key: key,
Language: lang,
SystemTemplate: def.SystemTemplate,
UserTemplate: def.UserTemplate,
IsCustom: false,
}, nil
}
// Update applies prompt updates (upsert or reset). Empty prompts list is a no-op.
func (s *Service) Update(ctx context.Context, companyID uuid.UUID, in UpdateInput) (Bundle, error) {
defaultLang := strings.TrimSpace(in.Language)
if defaultLang == "" {
defaultLang = company.LoadLanguage(ctx, s.Pool, companyID)
}
if len(in.Prompts) == 0 {
return s.GetBundle(ctx, companyID, defaultLang)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return Bundle{}, err
}
defer tx.Rollback(ctx)
lastLang := defaultLang
for _, item := range in.Prompts {
key := strings.TrimSpace(strings.ToLower(item.Key))
if !ValidPromptKey(key) {
return Bundle{}, fmt.Errorf("%w: %s", ErrInvalidKey, item.Key)
}
langRaw := strings.TrimSpace(item.Language)
if langRaw == "" {
langRaw = defaultLang
}
lang, err := company.ParseLanguage(langRaw, false)
if err != nil {
return Bundle{}, fmt.Errorf("%w: language %q", ErrInvalidInput, langRaw)
}
lastLang = lang
if item.Reset {
_, err := tx.Exec(ctx, `
DELETE FROM ai_prompt_templates
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
companyID, key, lang)
if err != nil {
return Bundle{}, err
}
continue
}
sys := SanitizeTemplate(item.SystemTemplate, MaxSystemRunes)
user := SanitizeTemplate(item.UserTemplate, MaxUserRunes)
if sys == "" && user == "" {
return Bundle{}, fmt.Errorf("%w: empty templates for %s", ErrInvalidInput, key)
}
enabled := true
if item.IsEnabled != nil {
enabled = *item.IsEnabled
}
_, err = tx.Exec(ctx, `
INSERT INTO ai_prompt_templates (
company_id, prompt_key, language, system_template, user_template, is_enabled, updated_at
) VALUES ($1,$2,$3,$4,$5,$6, now())
ON CONFLICT (company_id, prompt_key, language) DO UPDATE SET
system_template = EXCLUDED.system_template,
user_template = EXCLUDED.user_template,
is_enabled = EXCLUDED.is_enabled,
updated_at = now()`,
companyID, key, lang, sys, user, enabled)
if err != nil {
return Bundle{}, err
}
}
if err := tx.Commit(ctx); err != nil {
return Bundle{}, err
}
return s.GetBundle(ctx, companyID, lastLang)
}
func (s *Service) loadAll(ctx context.Context, companyID uuid.UUID) ([]stored, error) {
rows, err := s.Pool.Query(ctx, `
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
FROM ai_prompt_templates WHERE company_id = $1`, companyID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []stored
for rows.Next() {
var st stored
if err := rows.Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
func (s *Service) loadOne(ctx context.Context, companyID uuid.UUID, key, language string) (stored, error) {
var st stored
err := s.Pool.QueryRow(ctx, `
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
FROM ai_prompt_templates
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
companyID, key, language).Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt)
return st, err
}
func appendUnique(list []string, v string) []string {
for _, x := range list {
if x == v {
return list
}
}
return append(list, v)
}
+51
View File
@@ -0,0 +1,51 @@
package aiprompts
import "time"
// Template is one company's prompt for a feature key (+ language) (API / storage shape).
type Template struct {
Key string `json:"key"`
Language string `json:"language,omitempty"`
Label string `json:"label,omitempty"`
Description string `json:"description,omitempty"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
IsEnabled bool `json:"is_enabled"`
IsCustom bool `json:"is_custom"`
IsDefault bool `json:"is_default"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// UpdateItem is one prompt in a PUT body.
type UpdateItem struct {
Key string `json:"key"`
Language string `json:"language,omitempty"`
SystemTemplate string `json:"system_template"`
UserTemplate string `json:"user_template"`
IsEnabled *bool `json:"is_enabled,omitempty"`
Reset bool `json:"reset,omitempty"` // delete custom row → fall back to built-in
}
// UpdateInput is the PUT /integrations/ai/prompts body.
type UpdateInput struct {
Language string `json:"language,omitempty"` // default language for items missing Language
Prompts []UpdateItem `json:"prompts"`
}
// Resolved is the effective system+user templates after defaults / custom merge.
type Resolved struct {
Key string
Language string
SystemTemplate string
UserTemplate string
IsCustom bool
}
// Bundle is the GET response for the prompts UI.
type Bundle struct {
Language string `json:"language"`
Prompts []Template `json:"prompts"`
Variables []Variable `json:"variables"`
CustomLanguages map[string][]string `json:"custom_languages"` // key → langs with overrides
ContentLanguages []string `json:"content_languages,omitempty"`
}