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

@@ -7,10 +7,18 @@ const EMPTY_FORM = {
niche: '',
topics: [],
tone: '',
brand_voice: '',
target_audience: '',
business_goals: '',
products_services: '',
content_rules: { do: [], dont: [] },
hashtag_profiles: {},
visual_style: { primary_color: '#E85A4F', secondary_color: '#1A1A1A', font: '' },
is_active: true,
}
const HASHTAG_PLATFORMS = ['instagram', 'facebook', 'youtube', 'tiktok']
const NICHE_CHIPS = [
'Food & Ricette', 'Fitness & Sport', 'Tech & AI', 'Beauty & Skincare',
'Fashion & Style', 'Travel & Lifestyle', 'Finance & Investimenti', 'Salute & Wellness',
@@ -45,6 +53,12 @@ export default function CharacterForm() {
niche: data.niche || '',
topics: data.topics || [],
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: {
primary_color: data.visual_style?.primary_color || '#E85A4F',
secondary_color: data.visual_style?.secondary_color || '#1A1A1A',
@@ -222,6 +236,64 @@ export default function CharacterForm() {
)}
</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 ──────────────────────────────────────── */}
<Section title="Stile visivo">
<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 = {
width: '100%', padding: '0.625rem 0.875rem',
border: '1px solid var(--border)', borderRadius: 0,