feat(marketplace): full persona page + categories + enrichment script

- GET /api/marketplace/personas/:id — returns all fields of a published persona
- Expand _COPY_FIELDS to include all new schema fields (oceanTraits, scenarios, aiSynthesizedBio, etc.)
- New MarketplacePersonaView page (/marketplace/personas/:id) with full sidebar + tabs
- Preview button navigates to full page instead of opening a summary dialog
- Clicking card body also navigates to persona page
- scripts/enrich_old_personas.py — LLM enrichment for 12 pre-schema personas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-29 14:38:42 +01:00
parent a8b0a40e08
commit 604fdb9458
6 changed files with 371 additions and 81 deletions

View file

@ -27,13 +27,28 @@ _PAGE_SIZE = 24
_COPY_FIELDS = {
"name", "age", "gender", "occupation", "education", "location",
"techSavviness", "personality", "interests", "brandLoyalty",
"priceConsciousness", "environmentalConcern", "hasPurchasingPower",
"hasChildren", "thinkFeelDo", "description", "imageUrl",
"llm_model", "traits", "background", "goals",
"ethnicity", "socialGrade",
"techSavviness", "brandLoyalty", "priceConsciousness",
"environmentalConcern", "hasPurchasingPower", "hasChildren",
"personality", "oceanTraits",
"interests", "description", "imageUrl",
"goals", "frustrations", "motivations", "scenarios", "scenarioType",
"thinkFeelDo",
"householdComposition", "householdIncome", "livingSituation",
"mediaConsumption", "deviceUsage", "shoppingHabits",
"brandPreferences", "communicationPreferences", "additionalInformation",
"coreValues", "lifestyleChoices", "socialActivities",
"categoryKnowledge", "paymentMethods", "purchaseBehaviour",
"decisionInfluences", "painPoints", "journeyContext",
"keyTouchpoints", "selfDeterminationNeeds", "fears", "narrative",
"aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits",
"audience_brief", "research_objective",
"llm_model", "traits", "background",
"communication_style", "values", "demographics",
}
_PUBLIC_FIELDS = {k: 1 for k in _COPY_FIELDS} | {"marketplace": 1}
@marketplace_bp.route('/personas', methods=['GET'])
@jwt_required()
@ -89,6 +104,34 @@ async def list_personas():
}), 200
@marketplace_bp.route('/personas/<persona_id>', methods=['GET'])
@jwt_required()
async def get_persona(persona_id: str):
db = await get_db()
try:
oid = ObjectId(persona_id)
except Exception:
return jsonify({"message": "Invalid persona id"}), 400
persona = await db.personas.find_one(
{"_id": oid, "marketplace.published": True},
{k: 1 for k in _COPY_FIELDS} | {"marketplace": 1},
)
if not persona:
return jsonify({"message": "Persona not found"}), 404
persona["_id"] = str(persona["_id"])
buyer_id = get_jwt_identity()
existing = await db.personas.find_one(
{"created_by": buyer_id, "marketplace.source_persona_id": persona_id},
{"_id": 1},
)
persona["already_owned"] = existing is not None
return jsonify(persona), 200
@marketplace_bp.route('/personas/<persona_id>/purchase', methods=['POST'])
@jwt_required()
async def purchase_persona(persona_id: str):

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Enrich old marketplace personas with new fields (aiSynthesizedBio, oceanTraits,
frustrations, motivations, scenarios, etc.) that were added to the schema later.
Run inside the backend container:
docker compose exec backend python scripts/enrich_old_personas.py
Or locally (if env vars set):
cd backend && python scripts/enrich_old_personas.py
"""
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorClient
# These are the 12 personas that existed before the schema expansion
OLD_PERSONA_IDS = [
"6a142d1d80c6d60341c9c158", # Marcus Johnson
"6a142d1d80c6d60341c9c15a", # Sophie Rahman
"6a142d2180c6d60341c9c15f", # Priya Nair
"6a142d2180c6d60341c9c161", # Olivia Tran
"6a142d2280c6d60341c9c163", # Daniel O'Connor
"6a147f76326d8df15a5ef7c4", # Alex Chen
"6a147f76326d8df15a5ef7c5", # Emma Clarke
"6a147f76326d8df15a5ef7c6", # Marcus Thorne
"6a147f76326d8df15a5ef7c7", # Jordan Blake
"6a147f76326d8df15a5ef7c8", # Marcus Webb
"6a147f76326d8df15a5ef7c9", # Sofia Park
"6a147f76326d8df15a5ef7ca", # Victor Stone
]
# Fields present in old personas that are missing from new schema
ENRICHMENT_FIELDS = [
"frustrations", "motivations", "scenarios",
"oceanTraits", "householdComposition", "householdIncome",
"livingSituation", "mediaConsumption", "deviceUsage",
"shoppingHabits", "brandPreferences", "communicationPreferences",
"aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits",
]
async def enrich_persona(db, persona: dict) -> bool:
from app.services.ai_persona_service import generate_persona, generate_persona_summary
name = persona.get("name", "Unknown")
pid = str(persona["_id"])
missing = [f for f in ENRICHMENT_FIELDS if f not in persona]
if not missing:
print(f"{name}: already complete, skipping")
return True
print(f"\n→ Enriching: {name} ({pid})")
print(f" Missing fields: {missing}")
update = {}
# Summary fields (aiSynthesizedBio, qualitativeAttributes, topPersonalityTraits)
summary_missing = [f for f in ["aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits"] if f not in persona]
if summary_missing:
try:
print(f" 🤖 Generating summary...")
summary = await generate_persona_summary(persona, llm_model="gpt-5.4-mini")
for f in summary_missing:
if f in summary:
update[f] = summary[f]
print(f" ✓ Summary fields: {list(summary.keys())}")
except Exception as e:
print(f" ✗ Summary generation failed: {e}")
# Detailed fields via full generation with old persona as base
detail_missing = [f for f in missing if f not in ["aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits"]]
if detail_missing:
try:
print(f" 🤖 Generating detailed enrichment...")
detailed = await generate_persona(basic_persona=persona, llm_model="gpt-5.4")
for f in detail_missing:
if f in detailed and f not in persona:
update[f] = detailed[f]
print(f" ✓ Detailed fields added: {[f for f in detail_missing if f in update]}")
except Exception as e:
print(f" ✗ Detailed generation failed: {e}")
if update:
await db.personas.update_one({"_id": persona["_id"]}, {"$set": update})
print(f" ✅ Saved {len(update)} new fields to DB")
else:
print(f" ⚠ Nothing to save")
return True
async def main():
mongo_uri = os.environ.get("MONGO_URI", "mongodb://mongo:27017/cohorta_db")
client = AsyncIOMotorClient(mongo_uri)
db = client.get_default_database()
print(f"Connected to: {mongo_uri}")
print(f"Processing {len(OLD_PERSONA_IDS)} old personas...\n")
ok = 0
for pid in OLD_PERSONA_IDS:
persona = await db.personas.find_one({"_id": ObjectId(pid)})
if not persona:
print(f"⚠ Not found: {pid}")
continue
success = await enrich_persona(db, persona)
if success:
ok += 1
print(f"\n✅ Done — enriched {ok}/{len(OLD_PERSONA_IDS)} personas")
client.close()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -25,6 +25,7 @@ import Admin from "./pages/Admin";
import MyUsage from "./pages/MyUsage";
import Billing from "./pages/Billing";
import PersonaMarketplace from "./pages/PersonaMarketplace";
import MarketplacePersonaView from "./pages/MarketplacePersonaView";
import ProtectedRoute from "./components/ProtectedRoute";
import AdminRoute from "./components/admin/AdminRoute";
import { AuthProvider } from "./contexts/AuthContext";
@ -83,6 +84,7 @@ const App = () => (
<Route path="/billing" element={<Billing />} />
<Route path="/usage" element={<MyUsage />} />
<Route path="/marketplace" element={<PersonaMarketplace />} />
<Route path="/marketplace/personas/:id" element={<MarketplacePersonaView />} />
</Route>
{/* Admin */}

View file

@ -823,6 +823,8 @@ export const adminApi = {
export const marketplaceApi = {
list: (params?: { search?: string; category?: string; page?: number }) =>
api.get('/marketplace/personas', { params }),
getById: (id: string) =>
api.get(`/marketplace/personas/${id}`),
purchase: (id: string) =>
api.post(`/marketplace/personas/${id}/purchase`, {}),
};

View file

@ -0,0 +1,186 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { marketplaceApi } from '@/lib/api';
import { Persona } from '@/types/persona';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, ShoppingCart, CheckCircle2, Zap, Loader2,
Target, Sparkles, BookOpen, SlidersHorizontal,
} from 'lucide-react';
import { toast } from '@/lib/toast';
import { PersonaSidebar } from '@/components/persona/PersonaSidebar';
import { PersonaAttitudinalProfile } from '@/components/persona/PersonaAttitudinalProfile';
import { PersonaPersonality } from '@/components/persona/PersonaPersonality';
import { PersonaScenarios } from '@/components/persona/PersonaScenarios';
import { PersonaGenerationPrompts } from '@/components/persona/PersonaGenerationPrompts';
import { PersonaProfileSkeleton } from '@/components/persona/PersonaProfileSkeleton';
export default function MarketplacePersonaView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [persona, setPersona] = useState<(Persona & { already_owned?: boolean; marketplace?: { price?: number; category?: string } }) | null>(null);
const [loading, setLoading] = useState(true);
const [buying, setBuying] = useState(false);
const [confirm, setConfirm] = useState(false);
useEffect(() => {
if (!id) return;
setLoading(true);
marketplaceApi.getById(id)
.then(r => setPersona({ ...r.data, id: r.data._id }))
.catch(() => { toast.error(t('common.error', 'Error')); navigate('/marketplace'); })
.finally(() => setLoading(false));
}, [id]);
const handleBuy = async () => {
if (!persona) return;
setBuying(true);
try {
await marketplaceApi.purchase(persona._id!);
setPersona(p => p ? { ...p, already_owned: true } : p);
toast.success(`${persona.name} ${t('marketplace.purchase_success', 'added to your personas!')}`);
setConfirm(false);
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
if (msg === 'Insufficient credits') toast.error(t('marketplace.insufficient_credits', 'Not enough credits'));
else if (msg === 'Already owned') toast.info(t('marketplace.already_owned', 'Already in your collection'));
else toast.error(t('common.error', 'Purchase failed'));
} finally {
setBuying(false);
}
};
if (loading) return <PersonaProfileSkeleton />;
if (!persona) return null;
const price = persona.marketplace?.price ?? 0;
return (
<div className="min-h-screen bg-background">
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6 gap-4">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/marketplace')}
className="h-8 w-8 p-0 rounded-lg border border-border/60 hover:border-primary/40"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<p
className="text-[10px] font-medium text-muted-foreground uppercase tracking-widest"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{t('marketplace.title', 'Persona Marketplace')}
</p>
<h1
className="text-2xl font-bold text-foreground leading-tight"
style={{ fontFamily: "'Montserrat', sans-serif" }}
>
{persona.name}
</h1>
</div>
</div>
<div className="flex-shrink-0">
{persona.already_owned ? (
<Button variant="outline" size="sm" disabled className="gap-1.5">
<CheckCircle2 className="h-4 w-4 text-primary" />
{t('marketplace.already_owned', 'In your collection')}
</Button>
) : (
<Button size="sm" className="gap-1.5" onClick={() => setConfirm(true)} disabled={buying}>
{buying ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShoppingCart className="h-4 w-4" />}
{price === 0
? t('marketplace.get_free', 'Get free')
: <><Zap className="h-3.5 w-3.5" />{price} {t('marketplace.credits', 'cr')}</>
}
</Button>
)}
</div>
</div>
{/* Body */}
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6">
<PersonaSidebar persona={persona} />
<Tabs defaultValue="attitudinal-profile" className="w-full">
<TabsList className="flex items-center gap-1 p-1 bg-secondary/40 rounded-xl border border-border mb-5 w-fit h-auto">
{[
{ value: 'attitudinal-profile', icon: Target, label: 'Profile' },
{ value: 'personality', icon: Sparkles, label: 'Personality' },
{ value: 'scenarios', icon: BookOpen, label: 'Scenarios' },
{ value: 'generation-prompts', icon: SlidersHorizontal, label: 'Inputs' },
].map(({ value, icon: Icon, label }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium text-muted-foreground transition-all data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-primary/25 hover:text-foreground"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="uppercase tracking-wider">{label}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="attitudinal-profile" className="mt-0">
<PersonaAttitudinalProfile persona={persona} />
</TabsContent>
<TabsContent value="personality" className="mt-0">
<PersonaPersonality persona={persona} />
</TabsContent>
<TabsContent value="scenarios" className="mt-0">
<PersonaScenarios persona={persona} />
</TabsContent>
<TabsContent value="generation-prompts" className="mt-0">
<PersonaGenerationPrompts persona={persona} />
</TabsContent>
</Tabs>
</div>
</main>
{/* Purchase confirmation */}
<AlertDialog open={confirm} onOpenChange={setConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('marketplace.confirm_title', 'Purchase persona?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('marketplace.confirm_desc', 'You are about to purchase')} <strong>{persona.name}</strong>{' '}
{t('marketplace.confirm_for', 'for')}{' '}
<span className="font-semibold text-primary">
<Zap className="inline h-3 w-3 mr-0.5" />{price} {t('marketplace.credits', 'cr')}
</span>.{' '}
{t('marketplace.confirm_copy', 'A copy will be added to your personas.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction disabled={buying} onClick={handleBuy}>
{buying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
{t('marketplace.confirm_buy', 'Buy')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { marketplaceApi } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@ -15,13 +16,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Loader2, Search, Zap, ShoppingCart, CheckCircle2, Eye } from 'lucide-react';
import { toast } from '@/lib/toast';
import { getGenderAvatarPath } from '@/utils/avatarUtils';
@ -66,12 +60,15 @@ function PersonaCard({
const price = persona.marketplace.price ?? 0;
return (
<Card className="overflow-hidden hover:border-primary/40 transition-colors flex flex-col">
<Card
className="overflow-hidden hover:border-primary/40 transition-colors flex flex-col cursor-pointer group"
onClick={() => onPreview(persona)}
>
<div className="h-32 bg-gradient-to-br from-primary/10 to-secondary/30 relative flex-shrink-0">
<img
src={avatarUrl}
alt={persona.name}
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-20 w-20 rounded-full object-cover border-4 border-background shadow"
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-20 w-20 rounded-full object-cover border-4 border-background shadow group-hover:scale-105 transition-transform"
/>
</div>
@ -94,13 +91,13 @@ function PersonaCard({
size="sm"
variant="ghost"
className="w-full gap-1.5 text-xs text-muted-foreground"
onClick={() => onPreview(persona)}
onClick={e => { e.stopPropagation(); onPreview(persona); }}
>
<Eye className="h-3.5 w-3.5" />
{t('marketplace.preview', 'Preview')}
</Button>
{persona.already_owned ? (
<Button size="sm" variant="outline" disabled className="w-full gap-1.5 text-xs">
<Button size="sm" variant="outline" disabled className="w-full gap-1.5 text-xs" onClick={e => e.stopPropagation()}>
<CheckCircle2 className="h-3.5 w-3.5 text-primary" />
{t('marketplace.already_owned', 'In your collection')}
</Button>
@ -109,7 +106,7 @@ function PersonaCard({
size="sm"
className="w-full gap-1.5 text-xs"
disabled={buying}
onClick={() => onBuy(persona)}
onClick={e => { e.stopPropagation(); onBuy(persona); }}
>
{buying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -130,6 +127,7 @@ function PersonaCard({
export default function PersonaMarketplace() {
const { t } = useTranslation();
const navigate = useNavigate();
const [personas, setPersonas] = useState<MarketPersona[]>([]);
const [total, setTotal] = useState(0);
@ -140,7 +138,6 @@ export default function PersonaMarketplace() {
const [loading, setLoading] = useState(true);
const [buying, setBuying] = useState<string | null>(null);
const [confirm, setConfirm] = useState<MarketPersona | null>(null);
const [preview, setPreview] = useState<MarketPersona | null>(null);
const load = useCallback(async (p = 1) => {
setLoading(true);
@ -241,7 +238,7 @@ export default function PersonaMarketplace() {
key={p._id}
persona={p}
onBuy={p2 => setConfirm(p2)}
onPreview={p2 => setPreview(p2)}
onPreview={p2 => navigate(`/marketplace/personas/${p2._id}`)}
buying={buying === p._id}
/>
))}
@ -260,69 +257,6 @@ export default function PersonaMarketplace() {
</div>
)}
{/* Persona preview dialog */}
<Dialog open={!!preview} onOpenChange={open => !open && setPreview(null)}>
<DialogContent className="max-w-lg">
{preview && (() => {
const avatarUrl = preview.imageUrl || getGenderAvatarPath(preview.gender ?? '');
const price = preview.marketplace.price ?? 0;
return (
<>
<DialogHeader>
<div className="flex items-center gap-4">
<img src={avatarUrl} alt={preview.name} className="h-16 w-16 rounded-full object-cover flex-shrink-0" />
<div className="min-w-0">
<DialogTitle className="text-lg">{preview.name}</DialogTitle>
{preview.occupation && <p className="text-sm text-muted-foreground">{preview.occupation}</p>}
<div className="flex flex-wrap gap-1.5 mt-1">
{preview.age && <Badge variant="outline" className="text-xs">{preview.age}</Badge>}
{preview.gender && <Badge variant="outline" className="text-xs">{preview.gender}</Badge>}
{preview.location && <Badge variant="outline" className="text-xs">{preview.location}</Badge>}
{preview.marketplace.category && <Badge className="text-xs">{preview.marketplace.category}</Badge>}
</div>
</div>
</div>
</DialogHeader>
{preview.description && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('marketplace.preview_description', 'Description')}</p>
<p className="text-sm leading-relaxed">{preview.description}</p>
</div>
)}
{preview.personality && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('marketplace.preview_personality', 'Personality')}</p>
<p className="text-sm leading-relaxed">{preview.personality}</p>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setPreview(null)}>{t('common.close', 'Close')}</Button>
{preview.already_owned ? (
<Button disabled variant="outline" className="gap-1.5">
<CheckCircle2 className="h-4 w-4 text-primary" />
{t('marketplace.already_owned', 'In your collection')}
</Button>
) : (
<Button
className="gap-1.5"
onClick={() => { setPreview(null); setConfirm(preview); }}
>
<ShoppingCart className="h-4 w-4" />
{price === 0
? t('marketplace.get_free', 'Get free')
: <><Zap className="h-3.5 w-3.5" />{price} {t('marketplace.credits', 'cr')}</>
}
</Button>
)}
</DialogFooter>
</>
);
})()}
</DialogContent>
</Dialog>
{/* Purchase confirmation dialog */}
<AlertDialog open={!!confirm} onOpenChange={open => !open && setConfirm(null)}>
<AlertDialogContent>