Files
2026-08-17 21:20:45 +02:00

306 lines
8.3 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
// Public returns a copy safe for API/UI clients. Drops enhance_input_hash
// (pipeline cache key) so GET product payloads cannot leak job internals.
// UpdateProcessedProduct merges from DB, so omitting the hash on GET is safe.
func (c LocalizedContent) Public() LocalizedContent {
if len(c) == 0 {
return c
}
out := make(LocalizedContent, len(c))
for lang, fields := range c {
fields.EnhanceInputHash = ""
out[lang] = fields
}
return out
}
// 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)
}