feat: Phase B learning + hashtag profiles Pro-only lock
- Approve action saves post as reference example in character's content_rules - Keep last 5 approved examples per character (auto-rotating) - Inject last 3 approved examples as few-shot in LLM system prompt - Lock YouTube/TikTok hashtag profile tabs for Freemium users (Pro only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -315,12 +315,28 @@ def approve_post(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Approve a post (set status to 'approved')."""
|
"""Approve a post (set status to 'approved'). Also saves it as a reference example for the character."""
|
||||||
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
|
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
|
||||||
if not post:
|
if not post:
|
||||||
raise HTTPException(status_code=404, detail="Post not found")
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
post.status = "approved"
|
post.status = "approved"
|
||||||
post.updated_at = datetime.utcnow()
|
post.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Save as reference example for future few-shot learning (keep last 5 per character)
|
||||||
|
if post.text_content and post.character_id:
|
||||||
|
character = db.query(Character).filter(Character.id == post.character_id).first()
|
||||||
|
if character:
|
||||||
|
examples = character.content_rules or {}
|
||||||
|
approved_examples = examples.get("approved_examples", [])
|
||||||
|
approved_examples.append({
|
||||||
|
"platform": post.platform_hint or "general",
|
||||||
|
"text": post.text_content[:500], # truncate for prompt efficiency
|
||||||
|
})
|
||||||
|
# Keep only last 5
|
||||||
|
examples["approved_examples"] = approved_examples[-5:]
|
||||||
|
character.content_rules = examples
|
||||||
|
character.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(post)
|
db.refresh(post)
|
||||||
return post
|
return post
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ def generate_post_text(
|
|||||||
rules_text += "\nNON FARE MAI: " + " | ".join(dont_rules)
|
rules_text += "\nNON FARE MAI: " + " | ".join(dont_rules)
|
||||||
system_parts.append(rules_text)
|
system_parts.append(rules_text)
|
||||||
|
|
||||||
|
# Few-shot: approved examples from past posts
|
||||||
|
approved_examples = content_rules.get("approved_examples", [])
|
||||||
|
if approved_examples:
|
||||||
|
examples_text = "\nESEMPI DI POST APPROVATI (usa come riferimento per stile e tono):"
|
||||||
|
for i, ex in enumerate(approved_examples[-3:], 1): # last 3 in prompt
|
||||||
|
plat = ex.get("platform", "")
|
||||||
|
examples_text += f"\n--- Esempio {i} ({plat}) ---\n{ex.get('text', '')}"
|
||||||
|
system_parts.append(examples_text)
|
||||||
|
|
||||||
system_parts.extend([
|
system_parts.extend([
|
||||||
"\nYou create authentic, engaging content that resonates with your audience.",
|
"\nYou create authentic, engaging content that resonates with your audience.",
|
||||||
"Never reveal you are an AI. Write as {name} would naturally write.",
|
"Never reveal you are an AI. Write as {name} would naturally write.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { useAuth } from '../AuthContext'
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -37,6 +38,7 @@ export default function CharacterForm() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isPro } = useAuth()
|
||||||
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
const [topicInput, setTopicInput] = useState('')
|
const [topicInput, setTopicInput] = useState('')
|
||||||
@@ -291,6 +293,7 @@ export default function CharacterForm() {
|
|||||||
<HashtagProfileEditor
|
<HashtagProfileEditor
|
||||||
profiles={form.hashtag_profiles || {}}
|
profiles={form.hashtag_profiles || {}}
|
||||||
onChange={p => handleChange('hashtag_profiles', p)}
|
onChange={p => handleChange('hashtag_profiles', p)}
|
||||||
|
isPro={isPro}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -432,9 +435,11 @@ function RulesEditor({ doRules, dontRules, onChange }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HashtagProfileEditor({ profiles, onChange }) {
|
function HashtagProfileEditor({ profiles, onChange, isPro }) {
|
||||||
const [activeTab, setActiveTab] = useState('instagram')
|
const [activeTab, setActiveTab] = useState('instagram')
|
||||||
const [tagInput, setTagInput] = useState('')
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
const availablePlatforms = isPro ? HASHTAG_PLATFORMS : HASHTAG_PLATFORMS.filter(p => ['instagram', 'facebook'].includes(p))
|
||||||
|
const proOnlyPlatforms = ['youtube', 'tiktok']
|
||||||
|
|
||||||
const getProfile = (platform) => profiles[platform] || { always: [], max_generated: 12 }
|
const getProfile = (platform) => profiles[platform] || { always: [], max_generated: 12 }
|
||||||
const setProfile = (platform, profile) => onChange({ ...profiles, [platform]: profile })
|
const setProfile = (platform, profile) => onChange({ ...profiles, [platform]: profile })
|
||||||
@@ -460,18 +465,22 @@ function HashtagProfileEditor({ profiles, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: '0', borderBottom: '2px solid var(--border)', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0', borderBottom: '2px solid var(--border)', marginBottom: '1rem' }}>
|
||||||
{HASHTAG_PLATFORMS.map(p => (
|
{HASHTAG_PLATFORMS.map(p => {
|
||||||
<button key={p} type="button" onClick={() => { setActiveTab(p); setTagInput('') }} style={{
|
const isLocked = !isPro && proOnlyPlatforms.includes(p)
|
||||||
|
return (
|
||||||
|
<button key={p} type="button" onClick={() => { if (!isLocked) { setActiveTab(p); setTagInput('') } }} style={{
|
||||||
padding: '0.4rem 0.85rem', fontSize: '0.78rem', fontWeight: activeTab === p ? 700 : 400,
|
padding: '0.4rem 0.85rem', fontSize: '0.78rem', fontWeight: activeTab === p ? 700 : 400,
|
||||||
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
fontFamily: "'DM Sans', sans-serif", border: 'none', cursor: 'pointer',
|
||||||
backgroundColor: activeTab === p ? 'var(--surface)' : 'transparent',
|
backgroundColor: activeTab === p ? 'var(--surface)' : 'transparent',
|
||||||
color: activeTab === p ? 'var(--ink)' : 'var(--ink-muted)',
|
color: isLocked ? 'var(--border-strong)' : (activeTab === p ? 'var(--ink)' : 'var(--ink-muted)'),
|
||||||
borderBottom: activeTab === p ? '2px solid var(--accent)' : '2px solid transparent',
|
borderBottom: activeTab === p ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
marginBottom: '-2px', textTransform: 'capitalize',
|
marginBottom: '-2px', textTransform: 'capitalize',
|
||||||
|
opacity: isLocked ? 0.5 : 1, cursor: isLocked ? 'not-allowed' : 'pointer',
|
||||||
}}>
|
}}>
|
||||||
{p}
|
{p}{isLocked ? ' 🔒' : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.5rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user