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,211 @@
|
||||
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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user