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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user