feat: rich character profiles — brand voice, target, rules, hashtag profiles

Backend:
- Character model: add brand_voice, target_audience, business_goals,
  products_services, content_rules (JSON do/dont), hashtag_profiles (JSON)
- Content generation: inject full character context into LLM system prompt
  (voice, audience, goals, products, rules)
- Hashtag generation: merge always-on tags from profile with AI-generated tags
- Schema: update CharacterBase and CharacterUpdate with new fields

Frontend:
- CharacterForm: new sections "Identità e Voce", "Regole Contenuti",
  "Profili Hashtag" with dedicated editors
- RulesEditor: do/don't list with add/remove
- HashtagProfileEditor: per-platform tabs, fixed hashtags + max generated count
- All fields loaded on edit, saved on submit

DB migration: 6 new columns added to characters table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michele
2026-04-04 16:34:25 +02:00
parent 8629d145a8
commit befa8b4adc
5 changed files with 290 additions and 14 deletions

View File

@@ -48,6 +48,13 @@ class Character(Base):
niche = Column(String(200), nullable=False) niche = Column(String(200), nullable=False)
topics = Column(JSON, default=list) topics = Column(JSON, default=list)
tone = Column(Text) tone = Column(Text)
# Rich profile fields
brand_voice = Column(Text, nullable=True) # how the character communicates (long description)
target_audience = Column(Text, nullable=True) # who reads the content
business_goals = Column(Text, nullable=True) # why they create content
products_services = Column(Text, nullable=True) # what they offer
content_rules = Column(JSON, default=dict) # {"do": [...], "dont": [...]}
hashtag_profiles = Column(JSON, default=dict) # per-platform hashtag config
visual_style = Column(JSON, default=dict) visual_style = Column(JSON, default=dict)
social_accounts = Column(JSON, default=dict) social_accounts = Column(JSON, default=dict)
affiliate_links = Column(JSON, default=list) affiliate_links = Column(JSON, default=list)

View File

@@ -111,6 +111,12 @@ def generate_content(
"niche": character.niche, "niche": character.niche,
"topics": character.topics or [], "topics": character.topics or [],
"tone": character.tone or "professional", "tone": character.tone or "professional",
"brand_voice": character.brand_voice,
"target_audience": character.target_audience,
"business_goals": character.business_goals,
"products_services": character.products_services,
"content_rules": character.content_rules or {},
"hashtag_profiles": character.hashtag_profiles or {},
} }
base_url = _get_setting(db, "llm_base_url", current_user.id) base_url = _get_setting(db, "llm_base_url", current_user.id)
@@ -145,7 +151,19 @@ def generate_content(
brief=request.brief, brief=request.brief,
) )
hashtags = generate_hashtags(text, llm, platform) # Hashtag generation with profile support
ht_profile = char_dict.get("hashtag_profiles", {}).get(platform, {})
always_tags = ht_profile.get("always", [])
max_generated = ht_profile.get("max_generated", 12)
hashtags_generated = generate_hashtags(text, llm, platform, count=max_generated)
# Merge: always tags first, then generated (no duplicates)
seen = set()
hashtags = []
for tag in always_tags + hashtags_generated:
normalized = tag.lower()
if normalized not in seen:
seen.add(normalized)
hashtags.append(tag)
affiliate_links_used: list[dict] = [] affiliate_links_used: list[dict] = []
if affiliate_link_dicts: if affiliate_link_dicts:

View File

@@ -24,6 +24,12 @@ class CharacterBase(BaseModel):
niche: str niche: str
topics: list[str] = [] topics: list[str] = []
tone: Optional[str] = None tone: Optional[str] = None
brand_voice: Optional[str] = None
target_audience: Optional[str] = None
business_goals: Optional[str] = None
products_services: Optional[str] = None
content_rules: dict = {} # {"do": [...], "dont": [...]}
hashtag_profiles: dict = {} # per-platform: {"instagram": {"always": [], "pool": [], ...}}
visual_style: dict = {} visual_style: dict = {}
social_accounts: dict = {} social_accounts: dict = {}
affiliate_links: list[dict] = [] affiliate_links: list[dict] = []
@@ -40,6 +46,12 @@ class CharacterUpdate(BaseModel):
niche: Optional[str] = None niche: Optional[str] = None
topics: Optional[list[str]] = None topics: Optional[list[str]] = None
tone: Optional[str] = None tone: Optional[str] = None
brand_voice: Optional[str] = None
target_audience: Optional[str] = None
business_goals: Optional[str] = None
products_services: Optional[str] = None
content_rules: Optional[dict] = None
hashtag_profiles: Optional[dict] = None
visual_style: Optional[dict] = None visual_style: Optional[dict] = None
social_accounts: Optional[dict] = None social_accounts: Optional[dict] = None
affiliate_links: Optional[list[dict]] = None affiliate_links: Optional[list[dict]] = None

View File

@@ -20,7 +20,9 @@ def generate_post_text(
"""Generate social media post text based on a character profile. """Generate social media post text based on a character profile.
Args: Args:
character: Dict with keys: name, niche, topics (list), tone (str). character: Dict with keys: name, niche, topics (list), tone (str),
and optional rich profile: brand_voice, target_audience,
business_goals, products_services, content_rules.
topic_hint: Optional topic suggestion to guide generation. topic_hint: Optional topic suggestion to guide generation.
llm_provider: LLM provider instance for text generation. llm_provider: LLM provider instance for text generation.
platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube'). platform: Target platform (e.g. 'instagram', 'facebook', 'tiktok', 'youtube').
@@ -36,18 +38,56 @@ def generate_post_text(
topics_str = ", ".join(topics) if topics else "general topics" topics_str = ", ".join(topics) if topics else "general topics"
system_prompt = ( # Base identity
f"You are {name}, a social media content creator in the {niche} niche. " system_parts = [
f"Your expertise covers: {topics_str}. " f"You are {name}, a social media content creator in the {niche} niche.",
f"Your communication style is {tone}. " f"Your expertise covers: {topics_str}.",
f"You create authentic, engaging content that resonates with your audience. " f"Your communication style is {tone}.",
f"Never reveal you are an AI. Write as {name} would naturally write.\n\n" ]
f"REGOLA CRITICA: Se ti viene indicata una tecnica narrativa (PAS, AIDA, Storytelling, ecc.), "
f"usala SOLO come struttura invisibile del testo. " # Rich profile: brand voice
f"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', " brand_voice = character.get("brand_voice")
f"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). " if brand_voice:
f"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo." system_parts.append(f"\nVOCE E STILE DI COMUNICAZIONE:\n{brand_voice}")
)
# Rich profile: target audience
target_audience = character.get("target_audience")
if target_audience:
system_parts.append(f"\nPUBBLICO TARGET:\n{target_audience}")
# Rich profile: business goals
business_goals = character.get("business_goals")
if business_goals:
system_parts.append(f"\nOBIETTIVI BUSINESS:\n{business_goals}")
# Rich profile: products/services
products_services = character.get("products_services")
if products_services:
system_parts.append(f"\nPRODOTTI/SERVIZI OFFERTI:\n{products_services}")
# Rich profile: content rules (do/don't)
content_rules = character.get("content_rules") or {}
do_rules = content_rules.get("do", [])
dont_rules = content_rules.get("dont", [])
if do_rules or dont_rules:
rules_text = "\nREGOLE CONTENUTI:"
if do_rules:
rules_text += "\nFA SEMPRE: " + " | ".join(do_rules)
if dont_rules:
rules_text += "\nNON FARE MAI: " + " | ".join(dont_rules)
system_parts.append(rules_text)
system_parts.extend([
"\nYou create authentic, engaging content that resonates with your audience.",
"Never reveal you are an AI. Write as {name} would naturally write.",
"\nREGOLA CRITICA: Se ti viene indicata una tecnica narrativa (PAS, AIDA, Storytelling, ecc.), "
"usala SOLO come struttura invisibile del testo. "
"NON scrivere MAI le etichette del framework nel post (es. non scrivere 'PROBLEMA:', "
"'AGITAZIONE:', 'SOLUZIONE:', 'ATTENZIONE:', 'INTERESSE:', ecc.). "
"Il lettore non deve percepire alcun framework — deve sembrare un post naturale e spontaneo.",
])
system_prompt = "\n".join(system_parts)
# Platform-specific instructions # Platform-specific instructions
platform_guidance = { platform_guidance = {

View File

@@ -7,10 +7,18 @@ const EMPTY_FORM = {
niche: '', niche: '',
topics: [], topics: [],
tone: '', tone: '',
brand_voice: '',
target_audience: '',
business_goals: '',
products_services: '',
content_rules: { do: [], dont: [] },
hashtag_profiles: {},
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' }, visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
is_active: true, is_active: true,
} }
const HASHTAG_PLATFORMS = ['instagram', 'facebook', 'youtube', 'tiktok']
const NICHE_CHIPS = [ const NICHE_CHIPS = [
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare', 'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness', 'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
@@ -45,6 +53,12 @@ export default function CharacterForm() {
niche: data.niche || '', niche: data.niche || '',
topics: data.topics || [], topics: data.topics || [],
tone: data.tone || '', tone: data.tone || '',
brand_voice: data.brand_voice || '',
target_audience: data.target_audience || '',
business_goals: data.business_goals || '',
products_services: data.products_services || '',
content_rules: data.content_rules || { do: [], dont: [] },
hashtag_profiles: data.hashtag_profiles || {},
visual_style: { visual_style: {
primary_color: data.visual_style?.primary_color || '#E85A4F', primary_color: data.visual_style?.primary_color || '#E85A4F',
secondary_color: data.visual_style?.secondary_color || '#1A1A1A', secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
@@ -222,6 +236,64 @@ export default function CharacterForm() {
)} )}
</Section> </Section>
{/* ── Identità e Voce ──────────────────────────────────── */}
<Section title="Identità e Voce">
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
Queste informazioni vengono usate automaticamente dall'AI ogni volta che genera contenuti. Compilale una volta sola con cura.
</p>
<Field label="Brand Voice — Come comunica questo personaggio">
<textarea value={form.brand_voice} onChange={e => handleChange('brand_voice', e.target.value)}
placeholder="Descrivi il modo in cui parla: diretto e provocatorio, usa metafore pratiche, mai aziendalese. Parla come al bar con un amico imprenditore. Usa il tu, frasi brevi, battute secche."
rows={4} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Pubblico Target — A chi si rivolge">
<textarea value={form.target_audience} onChange={e => handleChange('target_audience', e.target.value)}
placeholder="Es. Freelance IT italiani 30-45 anni che sanno di dover automatizzare ma procrastinano. Imprenditori con budget limitato che vogliono risultati concreti."
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Obiettivi Business — Perché crea contenuti">
<textarea value={form.business_goals} onChange={e => handleChange('business_goals', e.target.value)}
placeholder="Es. Vendere consulenze AI automation a 2.500€, posizionarsi come esperto nel settore, costruire una community di freelance tech."
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
<Field label="Prodotti / Servizi offerti">
<textarea value={form.products_services} onChange={e => handleChange('products_services', e.target.value)}
placeholder="Es. Consulenza AI automation (2.500€), Corso online 'AI per Freelance' (297€), Audit gratuito di 30 min. Link: micheleborraccia.it/consulenza"
rows={3} style={{ ...inputStyle, resize: 'vertical', lineHeight: 1.6 }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
</Field>
</Section>
{/* ── Regole Contenuti ────────────────────────────────── */}
<Section title="Regole Contenuti">
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
Istruzioni vincolanti: l'AI le seguirà sempre. Compila per evitare di ripeterti ad ogni generazione.
</p>
<RulesEditor
doRules={form.content_rules?.do || []}
dontRules={form.content_rules?.dont || []}
onChange={(doR, dontR) => handleChange('content_rules', { do: doR, dont: dontR })}
/>
</Section>
{/* ── Hashtag Profiles ────────────────────────────────── */}
<Section title="Profili Hashtag">
<p style={{ fontSize: '0.82rem', color: 'var(--ink-muted)', margin: '0 0 0.5rem', lineHeight: 1.5 }}>
Hashtag fissi per piattaforma: verranno sempre inclusi nei post generati. L'AI ne aggiungerà altri variabili.
</p>
<HashtagProfileEditor
profiles={form.hashtag_profiles || {}}
onChange={p => handleChange('hashtag_profiles', p)}
/>
</Section>
{/* ── Stile visivo ──────────────────────────────────────── */} {/* ── Stile visivo ──────────────────────────────────────── */}
<Section title="Stile visivo"> <Section title="Stile visivo">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
@@ -311,6 +383,133 @@ function Field({ label, children }) {
) )
} }
function RulesEditor({ doRules, dontRules, onChange }) {
const [doInput, setDoInput] = useState('')
const [dontInput, setDontInput] = useState('')
const addDo = () => { const v = doInput.trim(); if (v && !doRules.includes(v)) { onChange([...doRules, v], dontRules) }; setDoInput('') }
const addDont = () => { const v = dontInput.trim(); if (v && !dontRules.includes(v)) { onChange(doRules, [...dontRules, v]) }; setDontInput('') }
const removeDo = (i) => onChange(doRules.filter((_, idx) => idx !== i), dontRules)
const removeDont = (i) => onChange(doRules, dontRules.filter((_, idx) => idx !== i))
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label style={miniLabelStyle}>FA SEMPRE</label>
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
<input type="text" value={doInput} onChange={e => setDoInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDo() } }}
placeholder="Es. Usa sempre il tu" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={addDo} style={{ ...btnSmall, backgroundColor: 'var(--success-light)', color: 'var(--success)' }}>+</button>
</div>
{doRules.map((r, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--success)' }}>
<span style={{ flexShrink: 0 }}>✓</span>
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
<button type="button" onClick={() => removeDo(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
</div>
))}
</div>
<div>
<label style={miniLabelStyle}>NON FARE MAI</label>
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
<input type="text" value={dontInput} onChange={e => setDontInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addDont() } }}
placeholder="Es. Non usare 'rivoluzionario'" style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={addDont} style={{ ...btnSmall, backgroundColor: 'var(--error-light)', color: 'var(--error)' }}>+</button>
</div>
{dontRules.map((r, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.8rem', padding: '0.25rem 0', color: 'var(--error)' }}>
<span style={{ flexShrink: 0 }}>✗</span>
<span style={{ flex: 1, color: 'var(--ink)' }}>{r}</span>
<button type="button" onClick={() => removeDont(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-muted)', fontSize: '0.9rem', padding: 0 }}>×</button>
</div>
))}
</div>
</div>
)
}
function HashtagProfileEditor({ profiles, onChange }) {
const [activeTab, setActiveTab] = useState('instagram')
const [tagInput, setTagInput] = useState('')
const getProfile = (platform) => profiles[platform] || { always: [], max_generated: 12 }
const setProfile = (platform, profile) => onChange({ ...profiles, [platform]: profile })
const addTag = (platform) => {
let tag = tagInput.trim()
if (!tag) return
if (!tag.startsWith('#')) tag = `#${tag}`
const p = getProfile(platform)
if (!p.always.includes(tag)) {
setProfile(platform, { ...p, always: [...p.always, tag] })
}
setTagInput('')
}
const removeTag = (platform, idx) => {
const p = getProfile(platform)
setProfile(platform, { ...p, always: p.always.filter((_, i) => i !== idx) })
}
const profile = getProfile(activeTab)
return (
<div>
<div style={{ display: 'flex', gap: '0', borderBottom: '2px solid var(--border)', marginBottom: '1rem' }}>
{HASHTAG_PLATFORMS.map(p => (
<button key={p} type="button" onClick={() => { setActiveTab(p); setTagInput('') }} style={{
padding: '0.4rem 0.85rem', fontSize: '0.78rem', fontWeight: activeTab === p ? 700 : 400,
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
backgroundColor: activeTab === p ? 'var(--surface)' : 'transparent',
color: activeTab === p ? 'var(--ink)' : 'var(--ink-muted)',
borderBottom: activeTab === p ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: '-2px', textTransform: 'capitalize',
}}>
{p}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
<input type="text" value={tagInput} onChange={e => setTagInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTag(activeTab) } }}
placeholder={`Hashtag fisso per ${activeTab}…`} style={{ ...inputStyle, flex: 1, fontSize: '0.82rem' }}
onFocus={e => e.target.style.borderColor = 'var(--ink)'} onBlur={e => e.target.style.borderColor = 'var(--border)'} />
<button type="button" onClick={() => addTag(activeTab)} style={btnSmall}>+</button>
</div>
{profile.always.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{profile.always.map((tag, i) => (
<span key={i} style={{ fontSize: '0.78rem', padding: '0.15rem 0.5rem', backgroundColor: 'var(--accent-light)', color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: '0.3rem' }}>
{tag}
<button type="button" onClick={() => removeTag(activeTab, i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.7rem', padding: 0, lineHeight: 1, opacity: 0.7 }}>✕</button>
</span>
))}
</div>
) : (
<p style={{ fontSize: '0.78rem', color: 'var(--ink-muted)', margin: '0.25rem 0 0', fontStyle: 'italic' }}>
Nessun hashtag fisso per {activeTab}. L'AI genererà tutti gli hashtag automaticamente.
</p>
)}
<div style={{ marginTop: '0.75rem' }}>
<label style={miniLabelStyle}>Hashtag generati dall'AI (max)</label>
<input type="number" min={0} max={30} value={profile.max_generated ?? 12}
onChange={e => setProfile(activeTab, { ...profile, max_generated: parseInt(e.target.value) || 0 })}
style={{ ...inputStyle, width: 80, fontSize: '0.82rem' }} />
</div>
</div>
)
}
const miniLabelStyle = { display: 'block', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-muted)', marginBottom: '0.35rem' }
const btnSmall = { padding: '0.35rem 0.65rem', fontSize: '0.8rem', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: "'DM Sans', sans-serif", backgroundColor: 'var(--cream-dark)', color: 'var(--ink-light)' }
const inputStyle = { const inputStyle = {
width: '100%', padding: '0.625rem 0.875rem', width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0, border: '1px solid var(--border)', borderRadius: 0,