Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
212 lines
6.2 KiB
Go
212 lines
6.2 KiB
Go
package company
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// BrandKit stores company brand voice, guidelines, and visual identity.
|
|
type BrandKit struct {
|
|
CompanyID uuid.UUID `json:"company_id"`
|
|
VoiceTone string `json:"voice_tone"`
|
|
Dos []string `json:"dos"`
|
|
Donts []string `json:"donts"`
|
|
PrimaryColor string `json:"primary_color"`
|
|
SecondaryColor string `json:"secondary_color"`
|
|
LogoURL string `json:"logo_url"`
|
|
PreferredTerms []string `json:"preferred_terms"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// EmptyBrand returns a zero kit for a company (no row yet).
|
|
func EmptyBrand(companyID uuid.UUID) BrandKit {
|
|
return BrandKit{
|
|
CompanyID: companyID,
|
|
Dos: []string{},
|
|
Donts: []string{},
|
|
PreferredTerms: []string{},
|
|
}
|
|
}
|
|
|
|
// LoadBrand returns the company brand kit, or an empty kit when none is saved.
|
|
func LoadBrand(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (BrandKit, error) {
|
|
var b BrandKit
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT company_id, voice_tone, COALESCE(dos, '{}'), COALESCE(donts, '{}'),
|
|
primary_color, secondary_color, logo_url, COALESCE(preferred_terms, '{}'), updated_at
|
|
FROM company_brand WHERE company_id = $1`, companyID).
|
|
Scan(&b.CompanyID, &b.VoiceTone, &b.Dos, &b.Donts,
|
|
&b.PrimaryColor, &b.SecondaryColor, &b.LogoURL, &b.PreferredTerms, &b.UpdatedAt)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return EmptyBrand(companyID), nil
|
|
}
|
|
if err != nil {
|
|
return BrandKit{}, err
|
|
}
|
|
b.Dos = cleanStrings(b.Dos)
|
|
b.Donts = cleanStrings(b.Donts)
|
|
b.PreferredTerms = cleanStrings(b.PreferredTerms)
|
|
return b, nil
|
|
}
|
|
|
|
// UpsertBrand saves the brand kit for a company.
|
|
func UpsertBrand(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, in BrandKit) (BrandKit, error) {
|
|
in.VoiceTone = security.SanitizePrompt(in.VoiceTone, security.MaxBrandFieldRunes)
|
|
in.PrimaryColor = security.TruncateRunes(strings.TrimSpace(in.PrimaryColor), 32)
|
|
in.SecondaryColor = security.TruncateRunes(strings.TrimSpace(in.SecondaryColor), 32)
|
|
logo, err := ValidateLogoURL(in.LogoURL, companyID)
|
|
if err != nil {
|
|
return BrandKit{}, err
|
|
}
|
|
in.LogoURL = logo
|
|
in.Dos = security.SanitizeBrandList(in.Dos)
|
|
in.Donts = security.SanitizeBrandList(in.Donts)
|
|
in.PreferredTerms = security.SanitizeBrandList(in.PreferredTerms)
|
|
|
|
var b BrandKit
|
|
err = pool.QueryRow(ctx, `
|
|
INSERT INTO company_brand (
|
|
company_id, voice_tone, dos, donts, primary_color, secondary_color, logo_url, preferred_terms, updated_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
|
|
ON CONFLICT (company_id) DO UPDATE SET
|
|
voice_tone = EXCLUDED.voice_tone,
|
|
dos = EXCLUDED.dos,
|
|
donts = EXCLUDED.donts,
|
|
primary_color = EXCLUDED.primary_color,
|
|
secondary_color = EXCLUDED.secondary_color,
|
|
logo_url = EXCLUDED.logo_url,
|
|
preferred_terms = EXCLUDED.preferred_terms,
|
|
updated_at = now()
|
|
RETURNING company_id, voice_tone, COALESCE(dos, '{}'), COALESCE(donts, '{}'),
|
|
primary_color, secondary_color, logo_url, COALESCE(preferred_terms, '{}'), updated_at`,
|
|
companyID, in.VoiceTone, in.Dos, in.Donts, in.PrimaryColor, in.SecondaryColor, in.LogoURL, in.PreferredTerms,
|
|
).Scan(&b.CompanyID, &b.VoiceTone, &b.Dos, &b.Donts,
|
|
&b.PrimaryColor, &b.SecondaryColor, &b.LogoURL, &b.PreferredTerms, &b.UpdatedAt)
|
|
if err != nil {
|
|
return BrandKit{}, err
|
|
}
|
|
if b.Dos == nil {
|
|
b.Dos = []string{}
|
|
}
|
|
if b.Donts == nil {
|
|
b.Donts = []string{}
|
|
}
|
|
if b.PreferredTerms == nil {
|
|
b.PreferredTerms = []string{}
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// HasContent reports whether any brand guidance is configured.
|
|
func (b BrandKit) HasContent() bool {
|
|
return strings.TrimSpace(b.VoiceTone) != "" ||
|
|
len(b.Dos) > 0 ||
|
|
len(b.Donts) > 0 ||
|
|
len(b.PreferredTerms) > 0 ||
|
|
strings.TrimSpace(b.PrimaryColor) != "" ||
|
|
strings.TrimSpace(b.SecondaryColor) != "" ||
|
|
strings.TrimSpace(b.LogoURL) != ""
|
|
}
|
|
|
|
// PromptBlock formats brand voice instructions for AI system prompts.
|
|
// Returns empty string when the kit has no usable voice content.
|
|
// Kept short (bullet lines) for weak local models / 8k context.
|
|
func (b BrandKit) PromptBlock() string {
|
|
var parts []string
|
|
if t := security.SanitizePrompt(b.VoiceTone, 160); t != "" {
|
|
parts = append(parts, "- tone: "+t)
|
|
}
|
|
dos := security.SanitizeBrandList(b.Dos)
|
|
donts := security.SanitizeBrandList(b.Donts)
|
|
terms := security.SanitizeBrandList(b.PreferredTerms)
|
|
if len(dos) > 4 {
|
|
dos = dos[:4]
|
|
}
|
|
if len(donts) > 4 {
|
|
donts = donts[:4]
|
|
}
|
|
if len(terms) > 6 {
|
|
terms = terms[:6]
|
|
}
|
|
if len(dos) > 0 {
|
|
parts = append(parts, "- do: "+strings.Join(dos, "; "))
|
|
}
|
|
if len(donts) > 0 {
|
|
parts = append(parts, "- don't: "+strings.Join(donts, "; "))
|
|
}
|
|
if len(terms) > 0 {
|
|
parts = append(parts, "- terms: "+strings.Join(terms, ", "))
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
block := "Brand:\n" + strings.Join(parts, "\n")
|
|
return security.TruncateRunes(block, 500)
|
|
}
|
|
|
|
// FormulaTips returns short brand-aware tips for formula/preview UI.
|
|
func (b BrandKit) FormulaTips() []string {
|
|
tips := make([]string, 0, 4)
|
|
if t := strings.TrimSpace(b.VoiceTone); t != "" {
|
|
tips = append(tips, "Match brand tone: "+truncateTip(t, 120))
|
|
}
|
|
if len(b.PreferredTerms) > 0 {
|
|
n := len(b.PreferredTerms)
|
|
if n > 5 {
|
|
n = 5
|
|
}
|
|
tips = append(tips, "Prefer terms: "+strings.Join(b.PreferredTerms[:n], ", "))
|
|
}
|
|
if len(b.Donts) > 0 {
|
|
n := len(b.Donts)
|
|
if n > 3 {
|
|
n = 3
|
|
}
|
|
tips = append(tips, "Avoid: "+strings.Join(b.Donts[:n], "; "))
|
|
}
|
|
if len(b.Dos) > 0 {
|
|
n := len(b.Dos)
|
|
if n > 3 {
|
|
n = 3
|
|
}
|
|
tips = append(tips, "Do: "+strings.Join(b.Dos[:n], "; "))
|
|
}
|
|
return tips
|
|
}
|
|
|
|
func cleanStrings(in []string) []string {
|
|
if len(in) == 0 {
|
|
return []string{}
|
|
}
|
|
out := make([]string, 0, len(in))
|
|
seen := map[string]struct{}{}
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(s)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func truncateTip(s string, max int) string {
|
|
s = strings.TrimSpace(s)
|
|
if max <= 0 || len(s) <= max {
|
|
return s
|
|
}
|
|
return strings.TrimSpace(s[:max]) + "…"
|
|
}
|