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,254 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// LangPromptMap is language-code → prompt text for category / template overrides.
|
||||
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.
|
||||
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 := ParseLanguage(lang, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsupported language %q", lang)
|
||||
}
|
||||
p := strings.TrimSpace(security.SanitizePrompt(prompt, maxRunes))
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out[code] = p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PromptForLanguage returns the prompt for lang, or empty if unset.
|
||||
func PromptForLanguage(m LangPromptMap, lang string) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
code, err := ParseLanguage(lang, true)
|
||||
if err != nil {
|
||||
code = DefaultLanguage
|
||||
}
|
||||
return strings.TrimSpace(m[code])
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user