fix(personas): expand PERSONA_ALLOWED_FIELDS + add library/clone endpoints

Port fixes from Semblance:
- PERSONA_ALLOWED_FIELDS: 9 → 62 fields — oceanTraits, frustrations,
  motivations, scenarios, aiSynthesizedBio, household/lifestyle fields,
  audience_brief, research_objective, etc. were silently discarded at
  Persona.create(), causing all OCEAN traits to render as 50% on frontend
- GET /personas/library — shared library endpoint (all users, bulk user lookup)
- POST /personas/:id/clone — clone any persona to current user
- GET /personas/:id — remove 403 for non-owners (read is public to auth users)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-29 14:12:04 +01:00
parent dd816783e7
commit a8b0a40e08
2 changed files with 98 additions and 12 deletions

View file

@ -7,12 +7,39 @@ logger = logging.getLogger(__name__)
# Allowed fields for create/update (mass assignment protection)
PERSONA_ALLOWED_FIELDS = {
# Core demographics
"name", "age", "gender", "occupation", "education", "location",
"techSavviness", "personality", "interests", "brandLoyalty",
"priceConsciousness", "environmentalConcern", "hasPurchasingPower",
"hasChildren", "thinkFeelDo", "description", "imageUrl",
"folder_ids", "llm_model", "traits", "background", "goals",
"communication_style", "values", "demographics", "marketplace",
"ethnicity", "socialGrade",
# Psychographic sliders
"techSavviness", "brandLoyalty", "priceConsciousness",
"environmentalConcern", "hasPurchasingPower", "hasChildren",
# Personality
"personality", "oceanTraits",
# Interests and rich text
"interests", "description", "imageUrl",
# AI-generated narrative fields
"goals", "frustrations", "motivations", "scenarios", "scenarioType",
"thinkFeelDo",
# Household / lifestyle
"householdComposition", "householdIncome", "livingSituation",
"mediaConsumption", "deviceUsage", "shoppingHabits",
"brandPreferences", "communicationPreferences", "additionalInformation",
# Extended attitudinal fields
"coreValues", "lifestyleChoices", "socialActivities",
"categoryKnowledge", "paymentMethods", "purchaseBehaviour",
"decisionInfluences", "painPoints", "journeyContext",
"keyTouchpoints", "selfDeterminationNeeds", "fears", "narrative",
# AI-synthesised summary fields (for persona cards)
"aiSynthesizedBio", "qualitativeAttributes", "topPersonalityTraits",
# Generation provenance
"audience_brief", "research_objective",
# Organisation
"folder_ids", "folderId", "folder_id",
# Cohorta-specific
"marketplace",
# Legacy / misc
"llm_model", "traits", "background",
"communication_style", "values", "demographics",
}

View file

@ -48,21 +48,80 @@ async def get_all_personas():
logger.error(f"Error in get_all_personas: {e}")
return jsonify({"error": str(e)}), 500
@personas_bp.route('/library', methods=['GET'])
@jwt_required()
@active_required
async def get_library_personas():
"""Return all personas from all users, enriched with owner username."""
try:
from app.db import get_db
db = await get_db()
limit = min(int(request.args.get('limit', 500)), 1000)
# Single query for all users to build the username map
user_map = {}
async for u in db.users.find({}, {'_id': 1, 'username': 1, 'email': 1}):
uid = str(u['_id'])
user_map[uid] = u.get('username') or u.get('email', 'Unknown')
# Fetch personas — only fields needed for the library card
LIBRARY_FIELDS = {
'name': 1, 'age': 1, 'gender': 1, 'occupation': 1, 'location': 1,
'personality': 1, 'interests': 1, 'avatar': 1,
'oceanTraits': 1, 'aiSynthesizedBio': 1,
'qualitativeAttributes': 1, 'topPersonalityTraits': 1,
'research_objective': 1, 'audience_brief': 1, 'marketplace': 1,
'created_by': 1, 'created_at': 1,
}
personas = []
async for p in db.personas.find({}, LIBRARY_FIELDS).sort('created_at', -1).limit(limit):
p['_id'] = str(p['_id'])
p['owner_name'] = user_map.get(p.get('created_by', ''), 'Unknown')
personas.append(p)
return jsonify(make_serializable(personas)), 200
except Exception as e:
logger.error(f"Error in get_library_personas: {e}")
return jsonify({"error": str(e)}), 500
@personas_bp.route('/<persona_id>/clone', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def clone_persona(persona_id):
"""Clone any persona into the current user's library."""
user_id = get_jwt_identity()
try:
original = await Persona.find_by_id(persona_id)
if not original:
return jsonify({"error": "Persona not found"}), 404
clone = {k: v for k, v in original.items()
if k not in ('_id', 'created_at', 'created_by', 'folder_ids', 'updated_at')}
clone['folder_ids'] = []
new_id = await Persona.create(clone, user_id)
clone['_id'] = str(new_id)
return jsonify({"message": "Persona cloned", "persona": make_serializable(clone), "persona_id": str(new_id)}), 201
except Exception as e:
logger.error(f"Error cloning persona {persona_id}: {e}")
return jsonify({"error": str(e)}), 500
@personas_bp.route('/<persona_id>', methods=['GET'])
@jwt_required()
async def get_persona(persona_id):
try:
user_id = get_jwt_identity()
persona = await Persona.find_by_id(persona_id)
if not persona:
return jsonify({"message": "Persona not found"}), 404
if persona.get("created_by") and persona.get("created_by") != user_id:
return jsonify({"message": "Permission denied"}), 403
# Make persona serializable
serializable_persona = make_serializable(persona)
return jsonify(serializable_persona), 200
# Any authenticated user may read any persona (needed for shared library).
# Write/delete endpoints retain ownership checks.
return jsonify(make_serializable(persona)), 200
except Exception as e:
logger.error(f"Error in get_persona: {e}")
return jsonify({"error": str(e)}), 500