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>
450 lines
No EOL
19 KiB
Python
Executable file
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 |