semblance/backend/app/routes/personas.py
Vadym Samoilenko bc4138f332 Final pieces: decorators on LLM routes, usage self-service, billing page, WS events
Backend:
- @active_required + @with_user_context applied to all LLM-invoking routes
  in personas.py, focus_group_ai.py, ai_personas.py
- backend/app/routes/usage.py: GET /api/usage/me (MTD summary by feature),
  GET /api/usage/focus-groups/<id> (owner or admin)
- Registered usage_bp in app/__init__.py
- llm_service._record_usage now emits usage_update WS event to focus group room

Frontend:
- useMyUsage + useFocusGroupUsage hooks
- MyUsage.tsx: personal billing dashboard (cost cards + per-feature table)
- /billing route (ProtectedRoute) + Billing nav link
- FocusGroupSession: quota_warning amber banner with Progress bar,
  quota_exceeded + quota_warning WS events wired via websocketServiceNew

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:43:13 +01:00

450 lines
No EOL
19 KiB
Python
Executable file

import logging
from quart import Blueprint, request, jsonify, send_file, Response, current_app
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.models.persona import Persona
import json
logger = logging.getLogger(__name__)
from app.services.persona_export_service import PersonaExportService
from app.services.bulk_persona_export_service import BulkPersonaExportService
from app.services.persona_modification_service import PersonaModificationService, PersonaModificationError
from bson import ObjectId
import datetime
import asyncio
import os
import uuid
# Helper function for safe JSON responses (avoids async issues)
def json_response(payload: dict, status: int = 200) -> Response:
"""Create a JSON response without async complications."""
return Response(json.dumps(payload), status=status, mimetype="application/json")
from app.utils import make_serializable, active_required, with_user_context
personas_bp = Blueprint('personas', __name__)
@personas_bp.route('', methods=['GET'])
@personas_bp.route('/', methods=['GET'])
@jwt_required()
async def get_personas():
try:
user_id = get_jwt_identity()
personas = await Persona.find_by_user(user_id)
serializable_personas = make_serializable(personas)
return jsonify(serializable_personas), 200
except Exception as e:
logger.error(f"Error in get_personas: {e}")
return jsonify({"error": str(e)}), 500
@personas_bp.route('/all', methods=['GET'])
@jwt_required()
async def get_all_personas():
try:
user_id = get_jwt_identity()
personas = await Persona.find_by_user(user_id)
serializable_personas = make_serializable(personas)
return jsonify(serializable_personas), 200
except Exception as e:
logger.error(f"Error in get_all_personas: {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
except Exception as e:
logger.error(f"Error in get_persona: {e}")
return jsonify({"error": str(e)}), 500
@personas_bp.route('', methods=['POST'])
@personas_bp.route('/', methods=['POST'])
@jwt_required()
async def create_persona():
user_id = get_jwt_identity()
data = await request.get_json()
if not data:
return jsonify({"message": "No data provided"}), 400
persona_id = await Persona.create(data, user_id)
return jsonify({
"message": "Persona created successfully",
"persona_id": persona_id
}), 201
@personas_bp.route('/<persona_id>', methods=['PUT'])
@jwt_required()
async def update_persona(persona_id):
try:
data = await request.get_json()
if not data:
return jsonify({"message": "No data provided"}), 400
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") != user_id:
return jsonify({"message": "Permission denied"}), 403
success = await Persona.update(persona_id, data, user_id=user_id)
if success:
updated_persona = await Persona.find_by_id(persona_id)
return jsonify({
"message": "Persona updated successfully",
"persona": make_serializable(updated_persona)
}), 200
else:
return jsonify({"message": "No changes made to persona"}), 200
except Exception as e:
logger.error(f"Error updating persona: {e}")
return jsonify({"message": f"Failed to update persona: {str(e)}"}), 500
@personas_bp.route('/<persona_id>', methods=['DELETE'])
@jwt_required()
async def delete_persona(persona_id):
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") != user_id:
return jsonify({"message": "Permission denied"}), 403
success = await Persona.delete(persona_id, user_id=user_id)
if success:
return jsonify({"message": "Persona deleted successfully"}), 200
else:
return jsonify({"message": "Failed to delete persona"}), 500
@personas_bp.route('/batch', methods=['POST'])
@jwt_required()
async def create_multiple_personas():
user_id = get_jwt_identity()
data = await request.get_json()
if not data or not isinstance(data, list):
return jsonify({"message": "Invalid data format. Expected list of personas"}), 400
persona_ids = []
for persona_data in data:
persona_id = await Persona.create(persona_data, user_id)
persona_ids.append(persona_id)
return jsonify({
"message": f"Successfully created {len(persona_ids)} personas",
"persona_ids": persona_ids
}), 201
@personas_bp.route('/<persona_id>/modify-with-ai', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def modify_persona_with_ai(persona_id):
"""
Modify a persona using AI based on natural language instructions.
Returns 202 immediately; result delivered via WebSocket task_completed event.
Request body should include:
- modification_prompt: Natural language description of desired changes
- llm_model: Model to use (defaults to 'gemini-3.1-pro-preview')
- reasoning_effort: For GPT-5 (minimal, low, medium, high)
- verbosity: For GPT-5 (low, medium, high)
- preview_only: If true, returns modified data without saving to database (defaults to false)
"""
try:
request_data = await request.get_json()
if not request_data:
return jsonify({"error": "No request data provided"}), 400
modification_prompt = request_data.get('modification_prompt')
if not modification_prompt:
return jsonify({"error": "modification_prompt is required"}), 400
llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview')
reasoning_effort = request_data.get('reasoning_effort', 'medium')
verbosity = request_data.get('verbosity', 'medium')
preview_only = request_data.get('preview_only', False)
user_id = get_jwt_identity()
from app.services.task_manager import get_task_manager
from app.websocket_manager_async import get_async_websocket_manager
task_manager = get_task_manager()
task_id = task_manager.generate_task_id()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_modify_persona_bg(app, task_id, user_id, persona_id, modification_prompt, llm_model, reasoning_effort, verbosity, preview_only)
)
await task_manager.register_task(bg_task, 'persona_modification', user_id, {'persona_id': persona_id, 'preview_only': preview_only}, task_id=task_id)
await websocket_manager.emit_to_user(user_id, 'task_started', {'task_id': task_id, 'task_type': 'persona_modification'})
return jsonify({'task_id': task_id, 'message': 'Persona modification started'}), 202
except Exception as e:
logger.error(f"Unexpected error in persona modification: {e}")
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
async def _run_modify_persona_bg(app, task_id, user_id, persona_id, modification_prompt, llm_model, reasoning_effort, verbosity, preview_only):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
modified_persona_data = await PersonaModificationService.modify_persona(
persona_id=persona_id,
modification_prompt=modification_prompt,
llm_model=llm_model,
reasoning_effort=reasoning_effort,
verbosity=verbosity,
preview_only=preview_only
)
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'completed', result={
'persona': make_serializable(modified_persona_data),
'preview_only': preview_only
})
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'persona_modification'
})
except asyncio.CancelledError:
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'cancelled')
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {'task_id': task_id})
except PersonaModificationError as e:
logger.error(f"Persona modification error: {e}")
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'failed', error=str(e))
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'task_type': 'persona_modification', 'message': str(e)})
except Exception as e:
logger.error(f"Unexpected error in _run_modify_persona_bg: {e}")
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'failed', error=str(e))
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'task_type': 'persona_modification', 'message': str(e)})
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
@jwt_required()
@active_required
@with_user_context
async def export_persona_profile(persona_id):
"""
Export a persona profile as beautifully formatted markdown.
Returns 202 immediately; result delivered via WebSocket task_completed event.
Request body can optionally include:
- llm_model: Model to use (defaults to 'gemini-3.1-pro-preview')
- temperature: Temperature for generation (defaults to 0.3)
"""
try:
persona = await Persona.find_by_id(persona_id)
if not persona:
return jsonify({"error": "Persona not found"}), 404
request_data = await request.get_json() or {}
llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview')
temperature = request_data.get('temperature', 0.3)
user_id = get_jwt_identity()
from app.services.task_manager import get_task_manager
from app.websocket_manager_async import get_async_websocket_manager
task_manager = get_task_manager()
task_id = task_manager.generate_task_id()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_export_profile_bg(app, task_id, user_id, persona_id, llm_model, temperature)
)
await task_manager.register_task(bg_task, 'export_profile', user_id, {'persona_id': persona_id}, task_id=task_id)
await websocket_manager.emit_to_user(user_id, 'task_started', {'task_id': task_id, 'task_type': 'export_profile'})
return jsonify({'task_id': task_id, 'message': 'Profile export started'}), 202
except Exception as e:
logger.error(f"Error in export_persona_profile: {e}")
return jsonify({"error": f"Failed to export persona profile: {str(e)}"}), 500
async def _run_export_profile_bg(app, task_id, user_id, persona_id, llm_model, temperature):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
persona = await Persona.find_by_id(persona_id)
persona_data = make_serializable(persona)
export_service = PersonaExportService()
result = await export_service.generate_profile_markdown(
persona_data=persona_data,
llm_model=llm_model,
temperature=temperature
)
from app.services.task_manager import store_task_result
if result.get('success'):
await store_task_result(task_id, 'completed', result={
'success': True,
'markdown_content': result['markdown_content'],
'persona_name': result['persona_name'],
'model_used': result.get('model_used')
})
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'export_profile'
})
else:
logger.debug(f"LLM generation failed, using fallback for persona {persona_id}")
fallback_markdown = export_service.generate_fallback_markdown(persona_data)
await store_task_result(task_id, 'completed', result={
'success': True,
'markdown_content': fallback_markdown,
'persona_name': persona_data.get('name', 'Unknown'),
'model_used': 'fallback',
'warning': 'Used fallback formatting due to LLM error'
})
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'export_profile'
})
except asyncio.CancelledError:
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'cancelled')
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {'task_id': task_id})
except Exception as e:
logger.error(f"Unexpected error in _run_export_profile_bg: {e}")
from app.services.task_manager import store_task_result
await store_task_result(task_id, 'failed', error=str(e))
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'task_type': 'export_profile', 'message': str(e)})
@personas_bp.route('/bulk-export', methods=['POST'])
@jwt_required()
async def bulk_export_personas():
"""
Export multiple personas in bulk to specified format (markdown, JSON, CSV).
Request body should include:
- persona_ids: List of persona IDs to export
- export_format: Format for export ('markdown', 'json', 'csv')
"""
try:
# Check if get_jwt_identity is async and handle accordingly
user_id = get_jwt_identity()
if hasattr(user_id, "__await__"): # cheap awaitable check
user_id = await user_id
request_data = await request.get_json()
if not request_data:
return json_response({"error": "No request data provided"}, 400)
persona_ids = request_data.get('persona_ids', [])
if not persona_ids or not isinstance(persona_ids, list):
return json_response({"error": "persona_ids must be a non-empty list"}, 400)
export_format = request_data.get('export_format', 'markdown')
if export_format not in ['markdown', 'json', 'csv']:
return json_response({"error": "export_format must be 'markdown', 'json', or 'csv'"}, 400)
logger.debug(f"🚀 Backend: Starting bulk export for {len(persona_ids)} personas (format: {export_format})")
# Initialize bulk export service
bulk_export_service = BulkPersonaExportService()
# Start export asynchronously (returns task_id immediately)
success, result_message, task_id = await bulk_export_service.export_personas_bulk(
persona_ids=persona_ids,
user_id=user_id,
export_format=export_format
)
if success:
# Since export is instant, directly serve the file instead of returning path
try:
file_path = result_message
if os.path.exists(file_path):
filename = os.path.basename(file_path)
logger.debug(f"📥 Direct serving: {filename} ({os.path.getsize(file_path)} bytes)")
return await send_file(
file_path,
as_attachment=True
)
else:
return json_response({"error": "Export file not found"}, 500)
except Exception as file_error:
logger.error(f"Error serving export file: {file_error}")
return json_response({"error": f"Failed to serve file: {str(file_error)}"}, 500)
else:
return json_response({
"error": result_message,
"task_id": task_id
}, 400)
except Exception as e:
logger.error(f"Error in bulk_export_personas: {e}")
return json_response({"error": f"Failed to start bulk export: {str(e)}"}, 500)
@personas_bp.route('/download/<path:file_path>', methods=['GET'])
@jwt_required()
async def download_export_file(file_path):
"""
Download an exported file by its file path.
This endpoint serves files from the temp directory.
"""
try:
user_id = get_jwt_identity()
# Security: Only allow files from temp directory
temp_dir = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"temp"
)
# Build full file path
full_file_path = os.path.join(temp_dir, file_path)
# Security check: ensure path is within temp directory
temp_dir_real = os.path.realpath(temp_dir)
full_file_path_real = os.path.realpath(full_file_path)
if not full_file_path_real.startswith(temp_dir_real):
logger.debug(f"⚠️ Security: Attempted access outside temp directory: {file_path}")
return jsonify({"error": "File not found"}), 404
# Check if file exists
if not os.path.exists(full_file_path):
logger.debug(f"📁 File not found: {full_file_path}")
return jsonify({"error": "File not found or expired"}), 404
filename = os.path.basename(full_file_path)
logger.debug(f"📥 Serving download: {filename} to user {user_id}")
# Use Quart's send_file with correct parameters for v0.20.0
return await send_file(
full_file_path,
as_attachment=True,
download_name=filename # Quart 0.20+ uses download_name, not attachment_filename
)
except Exception as e:
logger.error(f"Error in download_export_file: {e}")
return jsonify({"error": f"Failed to download file: {str(e)}"}), 500