291 lines
7.8 KiB
Go
291 lines
7.8 KiB
Go
package company
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// LangPromptAny is the wildcard key for a single shared prompt used for every
|
|
// content language (avoid duplicating the same text per lang).
|
|
const LangPromptAny = "*"
|
|
|
|
// LangPromptMap is language-code → prompt text for category / template overrides.
|
|
// Optional key LangPromptAny ("*") is a shared overlay for all languages.
|
|
type LangPromptMap map[string]string
|
|
|
|
// LocalizedFields holds AI/output fields for one content language.
|
|
type LocalizedFields struct {
|
|
ProcessedName string `json:"processed_name,omitempty"`
|
|
ProcessedDescription string `json:"processed_description,omitempty"`
|
|
MetaTitle string `json:"meta_title,omitempty"`
|
|
MetaDescription string `json:"meta_description,omitempty"`
|
|
EnhanceInputHash string `json:"enhance_input_hash,omitempty"`
|
|
}
|
|
|
|
// LocalizedContent is language-code → per-language product output fields.
|
|
type LocalizedContent map[string]LocalizedFields
|
|
|
|
// SanitizeLangPromptMap validates language codes, sanitizes prompts, and drops empties.
|
|
// Accepts LangPromptAny ("*") as a shared any-language prompt key.
|
|
func SanitizeLangPromptMap(in map[string]string, maxRunes int) (LangPromptMap, error) {
|
|
out := make(LangPromptMap)
|
|
if len(in) == 0 {
|
|
return out, nil
|
|
}
|
|
for lang, prompt := range in {
|
|
code, err := parseLangPromptKey(lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := strings.TrimSpace(security.SanitizePrompt(prompt, maxRunes))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out[code] = p
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseLangPromptKey(raw string) (string, error) {
|
|
key := strings.TrimSpace(raw)
|
|
if key == LangPromptAny {
|
|
return LangPromptAny, nil
|
|
}
|
|
code, err := ParseLanguage(key, false)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unsupported language %q", raw)
|
|
}
|
|
return code, nil
|
|
}
|
|
|
|
// PromptForLanguage resolves a prompt with fallback:
|
|
// requested lang → LangPromptAny ("*") → primary → "" (caller may then use built-in).
|
|
// Empty primary skips the primary step. Does not invent cross-lang text beyond this chain.
|
|
func PromptForLanguage(m LangPromptMap, lang, primary string) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
try := func(code string) string {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[code])
|
|
}
|
|
if code, err := ParseLanguage(lang, true); err == nil {
|
|
if p := try(code); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
if p := try(LangPromptAny); p != "" {
|
|
return p
|
|
}
|
|
if prim, err := ParseLanguage(primary, false); err == nil {
|
|
if p := try(prim); p != "" {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HasAnyPrompt reports whether any language has a non-empty prompt.
|
|
func HasAnyPrompt(m LangPromptMap) bool {
|
|
for _, p := range m {
|
|
if strings.TrimSpace(p) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DecodeLangPromptMap accepts JSON object / map[string]any / map[string]string.
|
|
func DecodeLangPromptMap(raw any) (LangPromptMap, error) {
|
|
out := make(LangPromptMap)
|
|
if raw == nil {
|
|
return out, nil
|
|
}
|
|
switch v := raw.(type) {
|
|
case LangPromptMap:
|
|
return SanitizeLangPromptMap(v, security.MaxCampaignPromptRunes)
|
|
case map[string]string:
|
|
return SanitizeLangPromptMap(v, security.MaxCampaignPromptRunes)
|
|
case map[string]any:
|
|
tmp := make(map[string]string, len(v))
|
|
for k, val := range v {
|
|
s, ok := val.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("prompt for %q must be a string", k)
|
|
}
|
|
tmp[k] = s
|
|
}
|
|
return SanitizeLangPromptMap(tmp, security.MaxCampaignPromptRunes)
|
|
case string:
|
|
s := strings.TrimSpace(v)
|
|
if s == "" || s == "{}" {
|
|
return out, nil
|
|
}
|
|
var obj map[string]string
|
|
if err := json.Unmarshal([]byte(s), &obj); err != nil {
|
|
return nil, fmt.Errorf("invalid prompt map json")
|
|
}
|
|
return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes)
|
|
case []byte:
|
|
if len(v) == 0 {
|
|
return out, nil
|
|
}
|
|
var obj map[string]string
|
|
if err := json.Unmarshal(v, &obj); err != nil {
|
|
return nil, fmt.Errorf("invalid prompt map json")
|
|
}
|
|
return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes)
|
|
default:
|
|
b, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid prompt map")
|
|
}
|
|
var obj map[string]string
|
|
if err := json.Unmarshal(b, &obj); err != nil {
|
|
return nil, fmt.Errorf("invalid prompt map json")
|
|
}
|
|
return SanitizeLangPromptMap(obj, security.MaxCampaignPromptRunes)
|
|
}
|
|
}
|
|
|
|
// EncodeLangPromptMap marshals a prompt map to JSON bytes (never null).
|
|
func EncodeLangPromptMap(m LangPromptMap) ([]byte, error) {
|
|
if m == nil {
|
|
return []byte("{}"), nil
|
|
}
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// ParseContentLanguages validates and normalizes an ordered language list.
|
|
// Empty input with allowEmptyAsPrimary yields [DefaultLanguage] or [primary] when primary set.
|
|
func ParseContentLanguages(raw []string, primary string) ([]string, error) {
|
|
primaryCode, err := ParseLanguage(primary, true)
|
|
if err != nil {
|
|
primaryCode = DefaultLanguage
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(raw)+1)
|
|
add := func(code string) {
|
|
if _, ok := seen[code]; ok {
|
|
return
|
|
}
|
|
seen[code] = struct{}{}
|
|
out = append(out, code)
|
|
}
|
|
add(primaryCode)
|
|
for _, r := range raw {
|
|
code, err := ParseLanguage(r, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unsupported language %q", r)
|
|
}
|
|
add(code)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LoadContentLanguages returns companies.content_languages, ensuring primary is first.
|
|
func LoadContentLanguages(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) []string {
|
|
primary := LoadLanguage(ctx, pool, companyID)
|
|
if pool == nil {
|
|
return []string{primary}
|
|
}
|
|
var langs []string
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT COALESCE(content_languages, '{}') FROM companies WHERE id = $1`, companyID).Scan(&langs)
|
|
if err != nil || len(langs) == 0 {
|
|
return []string{primary}
|
|
}
|
|
parsed, err := ParseContentLanguages(langs, primary)
|
|
if err != nil {
|
|
return []string{primary}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
// FieldsForLanguage returns localized fields for lang (empty struct if missing).
|
|
func FieldsForLanguage(content LocalizedContent, lang string) LocalizedFields {
|
|
if len(content) == 0 {
|
|
return LocalizedFields{}
|
|
}
|
|
code, err := ParseLanguage(lang, true)
|
|
if err != nil {
|
|
code = DefaultLanguage
|
|
}
|
|
return content[code]
|
|
}
|
|
|
|
// SetFieldsForLanguage upserts fields for one language into content.
|
|
func SetFieldsForLanguage(content LocalizedContent, lang string, fields LocalizedFields) LocalizedContent {
|
|
if content == nil {
|
|
content = LocalizedContent{}
|
|
}
|
|
code, err := ParseLanguage(lang, true)
|
|
if err != nil {
|
|
code = DefaultLanguage
|
|
}
|
|
content[code] = fields
|
|
return content
|
|
}
|
|
|
|
// DecodeLocalizedContent parses JSONB / map into LocalizedContent.
|
|
func DecodeLocalizedContent(raw any) (LocalizedContent, error) {
|
|
out := LocalizedContent{}
|
|
if raw == nil {
|
|
return out, nil
|
|
}
|
|
var b []byte
|
|
switch v := raw.(type) {
|
|
case []byte:
|
|
b = v
|
|
case string:
|
|
b = []byte(v)
|
|
default:
|
|
var err error
|
|
b, err = json.Marshal(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(b) == 0 || string(b) == "null" || string(b) == "{}" {
|
|
return out, nil
|
|
}
|
|
var tmp map[string]LocalizedFields
|
|
if err := json.Unmarshal(b, &tmp); err != nil {
|
|
return nil, fmt.Errorf("invalid localized_content")
|
|
}
|
|
for lang, fields := range tmp {
|
|
code, err := ParseLanguage(lang, false)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out[code] = fields
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// EncodeLocalizedContent marshals localized content (never null).
|
|
func EncodeLocalizedContent(c LocalizedContent) ([]byte, error) {
|
|
if c == nil {
|
|
return []byte("{}"), nil
|
|
}
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
// SyncPrimaryFromLocalized copies primary-language fields onto the denormalized columns shape.
|
|
func SyncPrimaryFromLocalized(content LocalizedContent, primary string) LocalizedFields {
|
|
return FieldsForLanguage(content, primary)
|
|
}
|