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>
This commit is contained in:
parent
915c81b8f1
commit
bc4138f332
12 changed files with 315 additions and 3 deletions
|
|
@ -148,6 +148,7 @@ def create_app():
|
||||||
from app.routes.folders import folders_bp
|
from app.routes.folders import folders_bp
|
||||||
from app.routes.tasks import tasks_bp
|
from app.routes.tasks import tasks_bp
|
||||||
from app.routes.admin import admin_bp
|
from app.routes.admin import admin_bp
|
||||||
|
from app.routes.usage import usage_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||||
app.register_blueprint(personas_bp, url_prefix='/api/personas')
|
app.register_blueprint(personas_bp, url_prefix='/api/personas')
|
||||||
|
|
@ -157,6 +158,7 @@ def create_app():
|
||||||
app.register_blueprint(folders_bp, url_prefix='/api/folders')
|
app.register_blueprint(folders_bp, url_prefix='/api/folders')
|
||||||
app.register_blueprint(tasks_bp, url_prefix='/api/tasks')
|
app.register_blueprint(tasks_bp, url_prefix='/api/tasks')
|
||||||
app.register_blueprint(admin_bp, url_prefix='/api/admin')
|
app.register_blueprint(admin_bp, url_prefix='/api/admin')
|
||||||
|
app.register_blueprint(usage_bp, url_prefix='/api/usage')
|
||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def start_task_sweeper():
|
async def start_task_sweeper():
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from app.services.task_manager import register_cancellable_task, CancellableTask
|
||||||
from app.services.customer_data_service import customer_data_service, CustomerDataServiceError
|
from app.services.customer_data_service import customer_data_service, CustomerDataServiceError
|
||||||
from app.models.persona import Persona
|
from app.models.persona import Persona
|
||||||
from app.utils.rate_limiter import rate_limit, ip_key
|
from app.utils.rate_limiter import rate_limit, ip_key
|
||||||
|
from app.utils import active_required, with_user_context
|
||||||
|
|
||||||
# Get timeout for AI requests
|
# Get timeout for AI requests
|
||||||
AI_REQUEST_TIMEOUT = 300 # 5 minutes in seconds
|
AI_REQUEST_TIMEOUT = 300 # 5 minutes in seconds
|
||||||
|
|
@ -36,6 +37,8 @@ def _user_key():
|
||||||
|
|
||||||
@ai_personas_bp.route('/generate-basic-profiles', methods=['POST'])
|
@ai_personas_bp.route('/generate-basic-profiles', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def generate_basic_profiles():
|
async def generate_basic_profiles():
|
||||||
"""
|
"""
|
||||||
|
|
@ -115,6 +118,8 @@ async def generate_basic_profiles():
|
||||||
|
|
||||||
@ai_personas_bp.route('/complete-persona', methods=['POST'])
|
@ai_personas_bp.route('/complete-persona', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def complete_persona():
|
async def complete_persona():
|
||||||
"""
|
"""
|
||||||
|
|
@ -175,6 +180,8 @@ async def complete_persona():
|
||||||
|
|
||||||
@ai_personas_bp.route('/complete-and-save-persona', methods=['POST'])
|
@ai_personas_bp.route('/complete-and-save-persona', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def complete_and_save_persona():
|
async def complete_and_save_persona():
|
||||||
"""
|
"""
|
||||||
Second stage of the two-stage persona generation process that also saves the
|
Second stage of the two-stage persona generation process that also saves the
|
||||||
|
|
@ -299,6 +306,8 @@ async def complete_and_save_persona():
|
||||||
|
|
||||||
@ai_personas_bp.route('/generate', methods=['POST'])
|
@ai_personas_bp.route('/generate', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def generate_ai_persona():
|
async def generate_ai_persona():
|
||||||
"""
|
"""
|
||||||
Generate a synthetic persona using AI and return it without saving.
|
Generate a synthetic persona using AI and return it without saving.
|
||||||
|
|
@ -357,6 +366,8 @@ async def generate_ai_persona():
|
||||||
|
|
||||||
@ai_personas_bp.route('/generate-and-save', methods=['POST'])
|
@ai_personas_bp.route('/generate-and-save', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def generate_and_save_persona():
|
async def generate_and_save_persona():
|
||||||
"""
|
"""
|
||||||
Generate a synthetic persona using AI and save it to the database.
|
Generate a synthetic persona using AI and save it to the database.
|
||||||
|
|
@ -438,6 +449,8 @@ async def generate_and_save_persona():
|
||||||
|
|
||||||
@ai_personas_bp.route('/batch-generate', methods=['POST'])
|
@ai_personas_bp.route('/batch-generate', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def batch_generate_personas():
|
async def batch_generate_personas():
|
||||||
"""
|
"""
|
||||||
Generate multiple synthetic personas using AI.
|
Generate multiple synthetic personas using AI.
|
||||||
|
|
@ -539,6 +552,8 @@ async def batch_generate_personas():
|
||||||
|
|
||||||
@ai_personas_bp.route('/batch-generate-and-save', methods=['POST'])
|
@ai_personas_bp.route('/batch-generate-and-save', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def batch_generate_and_save_personas():
|
async def batch_generate_and_save_personas():
|
||||||
"""
|
"""
|
||||||
Generate multiple synthetic personas using AI and save them to the database.
|
Generate multiple synthetic personas using AI and save them to the database.
|
||||||
|
|
@ -668,6 +683,8 @@ async def batch_generate_and_save_personas():
|
||||||
|
|
||||||
@ai_personas_bp.route('/generate-persona-summary', methods=['POST'])
|
@ai_personas_bp.route('/generate-persona-summary', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def generate_summary_for_persona():
|
async def generate_summary_for_persona():
|
||||||
"""
|
"""
|
||||||
Generate an AI-synthesized summary for an existing persona.
|
Generate an AI-synthesized summary for an existing persona.
|
||||||
|
|
@ -733,6 +750,8 @@ async def generate_summary_for_persona():
|
||||||
|
|
||||||
@ai_personas_bp.route('/enhance-audience-brief', methods=['POST'])
|
@ai_personas_bp.route('/enhance-audience-brief', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def enhance_audience_brief_endpoint():
|
async def enhance_audience_brief_endpoint():
|
||||||
"""
|
"""
|
||||||
Enhance an audience brief and research objective using AI.
|
Enhance an audience brief and research objective using AI.
|
||||||
|
|
@ -801,6 +820,8 @@ async def enhance_audience_brief_endpoint():
|
||||||
|
|
||||||
@ai_personas_bp.route('/batch-generate-summaries', methods=['POST'])
|
@ai_personas_bp.route('/batch-generate-summaries', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def batch_generate_summaries():
|
async def batch_generate_summaries():
|
||||||
"""
|
"""
|
||||||
Generate comprehensive markdown summaries for multiple personas for download/client review.
|
Generate comprehensive markdown summaries for multiple personas for download/client review.
|
||||||
|
|
@ -1170,6 +1191,8 @@ async def _run_persona_generation_bg(
|
||||||
|
|
||||||
@ai_personas_bp.route('/generate-personas-full', methods=['POST'])
|
@ai_personas_bp.route('/generate-personas-full', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def generate_personas_full():
|
async def generate_personas_full():
|
||||||
"""
|
"""
|
||||||
Async persona generation: returns task_id immediately (202),
|
Async persona generation: returns task_id immediately (202),
|
||||||
|
|
@ -1238,6 +1261,7 @@ async def generate_personas_full():
|
||||||
|
|
||||||
@ai_personas_bp.route('/upload-customer-data', methods=['POST'])
|
@ai_personas_bp.route('/upload-customer-data', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def upload_customer_data():
|
async def upload_customer_data():
|
||||||
"""
|
"""
|
||||||
Upload customer data files and parse them using LlamaParse.
|
Upload customer data files and parse them using LlamaParse.
|
||||||
|
|
@ -1286,6 +1310,7 @@ async def upload_customer_data():
|
||||||
|
|
||||||
@ai_personas_bp.route('/cleanup-customer-data/<session_id>', methods=['DELETE'])
|
@ai_personas_bp.route('/cleanup-customer-data/<session_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def cleanup_customer_data(session_id):
|
async def cleanup_customer_data(session_id):
|
||||||
"""
|
"""
|
||||||
Clean up customer data files for a specific session.
|
Clean up customer data files for a specific session.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.services.image_description_service import ImageDescriptionService, Imag
|
||||||
from app.models.focus_group import FocusGroup
|
from app.models.focus_group import FocusGroup
|
||||||
from app.models.persona import Persona
|
from app.models.persona import Persona
|
||||||
from app.utils.rate_limiter import rate_limit
|
from app.utils.rate_limiter import rate_limit
|
||||||
|
from app.utils import active_required, with_user_context
|
||||||
|
|
||||||
# Create the blueprint
|
# Create the blueprint
|
||||||
focus_group_ai_bp = Blueprint('focus_group_ai', __name__)
|
focus_group_ai_bp = Blueprint('focus_group_ai', __name__)
|
||||||
|
|
@ -42,6 +43,8 @@ def _user_key():
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/generate-response', methods=['POST'])
|
@focus_group_ai_bp.route('/generate-response', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def generate_ai_response():
|
async def generate_ai_response():
|
||||||
"""
|
"""
|
||||||
|
|
@ -250,6 +253,8 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/generate-key-themes', methods=['POST'])
|
@focus_group_ai_bp.route('/generate-key-themes', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def generate_key_themes():
|
async def generate_key_themes():
|
||||||
"""
|
"""
|
||||||
|
|
@ -375,6 +380,7 @@ async def _run_key_themes_bg(app, task_id, user_id, focus_group_id, temperature)
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/key-themes/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/key-themes/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_key_themes(focus_group_id):
|
async def get_key_themes(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get all generated key themes for a focus group.
|
Get all generated key themes for a focus group.
|
||||||
|
|
@ -417,6 +423,7 @@ async def get_key_themes(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/key-themes/<focus_group_id>/<theme_id>', methods=['DELETE'])
|
@focus_group_ai_bp.route('/key-themes/<focus_group_id>/<theme_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def delete_key_theme(focus_group_id, theme_id):
|
async def delete_key_theme(focus_group_id, theme_id):
|
||||||
"""
|
"""
|
||||||
Delete a key theme from a focus group.
|
Delete a key theme from a focus group.
|
||||||
|
|
@ -454,6 +461,7 @@ async def delete_key_theme(focus_group_id, theme_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/moderator/status/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/moderator/status/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_moderator_status(focus_group_id):
|
async def get_moderator_status(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get the current moderator status for a focus group.
|
Get the current moderator status for a focus group.
|
||||||
|
|
@ -482,6 +490,8 @@ async def get_moderator_status(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/moderator/advance/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/moderator/advance/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def advance_moderator_discussion(focus_group_id):
|
async def advance_moderator_discussion(focus_group_id):
|
||||||
"""
|
"""
|
||||||
|
|
@ -682,6 +692,7 @@ async def advance_moderator_discussion(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/moderator/position/<focus_group_id>', methods=['PUT'])
|
@focus_group_ai_bp.route('/moderator/position/<focus_group_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def set_moderator_position(focus_group_id):
|
async def set_moderator_position(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Set the moderator position to a specific section and item.
|
Set the moderator position to a specific section and item.
|
||||||
|
|
@ -726,6 +737,8 @@ async def set_moderator_position(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/autonomous/start/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/autonomous/start/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def start_autonomous_conversation(focus_group_id):
|
async def start_autonomous_conversation(focus_group_id):
|
||||||
"""
|
"""
|
||||||
|
|
@ -820,6 +833,7 @@ async def start_autonomous_conversation(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/autonomous/stop/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/autonomous/stop/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def stop_autonomous_conversation(focus_group_id):
|
async def stop_autonomous_conversation(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Stop autonomous conversation for a focus group.
|
Stop autonomous conversation for a focus group.
|
||||||
|
|
@ -892,6 +906,7 @@ async def stop_autonomous_conversation(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/autonomous/status/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/autonomous/status/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_autonomous_conversation_status(focus_group_id):
|
async def get_autonomous_conversation_status(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get the status of autonomous conversation for a focus group.
|
Get the status of autonomous conversation for a focus group.
|
||||||
|
|
@ -921,6 +936,7 @@ async def get_autonomous_conversation_status(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/state/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/conversation/state/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_conversation_state(focus_group_id):
|
async def get_conversation_state(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get the current conversation state for a focus group.
|
Get the current conversation state for a focus group.
|
||||||
|
|
@ -953,6 +969,7 @@ async def get_conversation_state(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/analytics/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/conversation/analytics/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_conversation_analytics(focus_group_id):
|
async def get_conversation_analytics(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get detailed conversation analytics for a focus group.
|
Get detailed conversation analytics for a focus group.
|
||||||
|
|
@ -985,6 +1002,8 @@ async def get_conversation_analytics(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/decision/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/conversation/decision/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def make_conversation_decision(focus_group_id):
|
async def make_conversation_decision(focus_group_id):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1035,6 +1054,8 @@ async def _run_conversation_decision_bg(app, task_id, user_id, focus_group_id, t
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/insights/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/conversation/insights/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def get_conversation_insights(focus_group_id):
|
async def get_conversation_insights(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get LLM-generated insights about the conversation.
|
Get LLM-generated insights about the conversation.
|
||||||
|
|
@ -1060,6 +1081,7 @@ async def get_conversation_insights(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/intervene/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/conversation/intervene/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def manual_intervention(focus_group_id):
|
async def manual_intervention(focus_group_id):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1133,6 +1155,7 @@ async def manual_intervention(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/conversation/reasoning-history/<focus_group_id>', methods=['GET'])
|
@focus_group_ai_bp.route('/conversation/reasoning-history/<focus_group_id>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
async def get_reasoning_history(focus_group_id):
|
async def get_reasoning_history(focus_group_id):
|
||||||
"""
|
"""
|
||||||
Get the AI reasoning history for an autonomous conversation.
|
Get the AI reasoning history for an autonomous conversation.
|
||||||
|
|
@ -1163,6 +1186,8 @@ async def get_reasoning_history(focus_group_id):
|
||||||
|
|
||||||
@focus_group_ai_bp.route('/moderator/end-session/<focus_group_id>', methods=['POST'])
|
@focus_group_ai_bp.route('/moderator/end-session/<focus_group_id>', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
@rate_limit(max_requests=10, window_seconds=60, key_func=_user_key)
|
||||||
async def end_focus_group_session(focus_group_id):
|
async def end_focus_group_session(focus_group_id):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ def json_response(payload: dict, status: int = 200) -> Response:
|
||||||
"""Create a JSON response without async complications."""
|
"""Create a JSON response without async complications."""
|
||||||
return Response(json.dumps(payload), status=status, mimetype="application/json")
|
return Response(json.dumps(payload), status=status, mimetype="application/json")
|
||||||
|
|
||||||
from app.utils import make_serializable
|
from app.utils import make_serializable, active_required, with_user_context
|
||||||
|
|
||||||
personas_bp = Blueprint('personas', __name__)
|
personas_bp = Blueprint('personas', __name__)
|
||||||
|
|
||||||
|
|
@ -154,6 +154,8 @@ async def create_multiple_personas():
|
||||||
|
|
||||||
@personas_bp.route('/<persona_id>/modify-with-ai', methods=['POST'])
|
@personas_bp.route('/<persona_id>/modify-with-ai', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def modify_persona_with_ai(persona_id):
|
async def modify_persona_with_ai(persona_id):
|
||||||
"""
|
"""
|
||||||
Modify a persona using AI based on natural language instructions.
|
Modify a persona using AI based on natural language instructions.
|
||||||
|
|
@ -240,6 +242,8 @@ async def _run_modify_persona_bg(app, task_id, user_id, persona_id, modification
|
||||||
|
|
||||||
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
|
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
|
@active_required
|
||||||
|
@with_user_context
|
||||||
async def export_persona_profile(persona_id):
|
async def export_persona_profile(persona_id):
|
||||||
"""
|
"""
|
||||||
Export a persona profile as beautifully formatted markdown.
|
Export a persona profile as beautifully formatted markdown.
|
||||||
|
|
|
||||||
105
backend/app/routes/usage.py
Normal file
105
backend/app/routes/usage.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""User self-service usage endpoints."""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from app.auth.quart_jwt import jwt_required, get_jwt_identity
|
||||||
|
from app.utils import active_required
|
||||||
|
from app.models.usage_event import UsageEvent
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
usage_bp = Blueprint('usage', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _month_start() -> datetime:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_bp.route('/me', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
async def my_usage():
|
||||||
|
"""GET /api/usage/me — current user's own MTD cost summary."""
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
try:
|
||||||
|
period_start = _month_start()
|
||||||
|
db = await get_db()
|
||||||
|
pipeline = [
|
||||||
|
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
|
||||||
|
{'$group': {
|
||||||
|
'_id': None,
|
||||||
|
'total_cost': {'$sum': '$cost_usd.total'},
|
||||||
|
'prompt_tokens': {'$sum': '$prompt_tokens'},
|
||||||
|
'completion_tokens': {'$sum': '$completion_tokens'},
|
||||||
|
'calls': {'$sum': 1},
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
agg = await db.usage_events.aggregate(pipeline).to_list(1)
|
||||||
|
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
|
||||||
|
totals.pop('_id', None)
|
||||||
|
|
||||||
|
# By feature
|
||||||
|
feat_pipeline = [
|
||||||
|
{'$match': {'user_id': user_id, 'ts': {'$gte': period_start}}},
|
||||||
|
{'$group': {
|
||||||
|
'_id': '$feature',
|
||||||
|
'total_cost': {'$sum': '$cost_usd.total'},
|
||||||
|
'calls': {'$sum': 1},
|
||||||
|
}},
|
||||||
|
{'$sort': {'total_cost': -1}},
|
||||||
|
]
|
||||||
|
by_feature = await db.usage_events.aggregate(feat_pipeline).to_list(20)
|
||||||
|
|
||||||
|
from app.utils import make_serializable
|
||||||
|
return jsonify({
|
||||||
|
'totals': make_serializable(totals),
|
||||||
|
'by_feature': make_serializable(by_feature),
|
||||||
|
'period_start': period_start.isoformat(),
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"my_usage error: {e}", exc_info=True)
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@usage_bp.route('/focus-groups/<fg_id>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
async def focus_group_usage(fg_id: str):
|
||||||
|
"""GET /api/usage/focus-groups/<id> — usage for a specific focus group (owner or admin)."""
|
||||||
|
user_id = get_jwt_identity()
|
||||||
|
try:
|
||||||
|
# Auth check — owner or admin
|
||||||
|
db = await get_db()
|
||||||
|
from bson import ObjectId
|
||||||
|
try:
|
||||||
|
fg = await db.focus_groups.find_one({'_id': ObjectId(fg_id)})
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'error': 'Invalid id'}), 400
|
||||||
|
if not fg:
|
||||||
|
return jsonify({'error': 'Not found'}), 404
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
user = await User.find_by_id(user_id)
|
||||||
|
is_admin = user and user.get('role') == 'admin'
|
||||||
|
is_owner = fg.get('created_by') == user_id or str(fg.get('user_id', '')) == user_id
|
||||||
|
if not is_admin and not is_owner:
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
|
pipeline = [
|
||||||
|
{'$match': {'focus_group_id': fg_id}},
|
||||||
|
{'$group': {
|
||||||
|
'_id': None,
|
||||||
|
'total_cost': {'$sum': '$cost_usd.total'},
|
||||||
|
'prompt_tokens': {'$sum': '$prompt_tokens'},
|
||||||
|
'completion_tokens': {'$sum': '$completion_tokens'},
|
||||||
|
'calls': {'$sum': 1},
|
||||||
|
}},
|
||||||
|
]
|
||||||
|
agg = await db.usage_events.aggregate(pipeline).to_list(1)
|
||||||
|
totals = agg[0] if agg else {'total_cost': 0, 'prompt_tokens': 0, 'completion_tokens': 0, 'calls': 0}
|
||||||
|
totals.pop('_id', None)
|
||||||
|
|
||||||
|
from app.utils import make_serializable
|
||||||
|
return jsonify({'totals': make_serializable(totals), 'focus_group_id': fg_id}), 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"focus_group_usage error: {e}", exc_info=True)
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
@ -259,6 +259,22 @@ class LLMService:
|
||||||
feature=ctx.feature,
|
feature=ctx.feature,
|
||||||
task_id=ctx.task_id,
|
task_id=ctx.task_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify focus group room of cost delta (non-fatal)
|
||||||
|
try:
|
||||||
|
if ctx.focus_group_id:
|
||||||
|
from app.models.focus_group import emit_websocket_event
|
||||||
|
asyncio.create_task(emit_websocket_event(
|
||||||
|
'usage_update',
|
||||||
|
ctx.focus_group_id,
|
||||||
|
{
|
||||||
|
'cost_delta': cost.get('total', 0),
|
||||||
|
'tokens_delta': tokens['prompt'] + tokens['completion'],
|
||||||
|
'feature': ctx.feature,
|
||||||
|
}
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.getLogger(__name__).warning("_record_usage failed (non-fatal)", exc_info=True)
|
logging.getLogger(__name__).warning("_record_usage failed (non-fatal)", exc_info=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import Dashboard from "./pages/Dashboard";
|
||||||
import PersonaProfile from "./components/persona/PersonaProfile";
|
import PersonaProfile from "./components/persona/PersonaProfile";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Admin from "./pages/Admin";
|
import Admin from "./pages/Admin";
|
||||||
|
import MyUsage from "./pages/MyUsage";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
import AdminRoute from "./components/admin/AdminRoute";
|
import AdminRoute from "./components/admin/AdminRoute";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
|
|
@ -82,6 +83,12 @@ const App = () => (
|
||||||
{/* Redirect legacy paths */}
|
{/* Redirect legacy paths */}
|
||||||
<Route path="/old-path" element={<Navigate to="/" replace />} />
|
<Route path="/old-path" element={<Navigate to="/" replace />} />
|
||||||
|
|
||||||
|
<Route path="/billing" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MyUsage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck } from 'lucide-react';
|
import { Menu, X, LayoutDashboard, Users, MessageSquare, Home, LogIn, LogOut, ShieldCheck, CreditCard } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
|
@ -32,6 +32,11 @@ export default function Navigation() {
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Billing',
|
||||||
|
href: '/billing',
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
const toggleMobileMenu = () => {
|
||||||
|
|
|
||||||
19
src/hooks/useMyUsage.ts
Normal file
19
src/hooks/useMyUsage.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
export function useMyUsage() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['usage', 'me'],
|
||||||
|
queryFn: () => api.get('/usage/me').then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFocusGroupUsage(fgId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['usage', 'focus-group', fgId],
|
||||||
|
queryFn: () => api.get(`/usage/focus-groups/${fgId}`).then(r => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
enabled: !!fgId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import Navigation from '@/components/Navigation';
|
import Navigation from '@/components/Navigation';
|
||||||
import ParticipantPanel from '@/components/focus-group-session/ParticipantPanel';
|
import ParticipantPanel from '@/components/focus-group-session/ParticipantPanel';
|
||||||
import DiscussionPanel from '@/components/focus-group-session/DiscussionPanel';
|
import DiscussionPanel from '@/components/focus-group-session/DiscussionPanel';
|
||||||
|
|
@ -109,6 +110,7 @@ const FocusGroupSession = () => {
|
||||||
|
|
||||||
// Quota exceeded banner state
|
// Quota exceeded banner state
|
||||||
const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null);
|
const [quotaExceeded, setQuotaExceeded] = useState<{ scope: string; limit_usd: number; used_usd: number } | null>(null);
|
||||||
|
const [quotaWarning, setQuotaWarning] = useState<{ scope: string; pct: number; limit_usd: number; used_usd: number } | null>(null);
|
||||||
|
|
||||||
// Admin-only: fetch focus group cost summary
|
// Admin-only: fetch focus group cost summary
|
||||||
const { data: fgCostData } = useQuery({
|
const { data: fgCostData } = useQuery({
|
||||||
|
|
@ -161,7 +163,15 @@ const FocusGroupSession = () => {
|
||||||
setQuotaExceeded(detail);
|
setQuotaExceeded(detail);
|
||||||
};
|
};
|
||||||
window.addEventListener('quota_exceeded', handler);
|
window.addEventListener('quota_exceeded', handler);
|
||||||
return () => window.removeEventListener('quota_exceeded', handler);
|
const warnHandler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
setQuotaWarning(detail);
|
||||||
|
};
|
||||||
|
window.addEventListener('quota_warning', warnHandler);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('quota_exceeded', handler);
|
||||||
|
window.removeEventListener('quota_warning', warnHandler);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize singleton socket (GPT-5 fix: avoid useMemo issues)
|
// Initialize singleton socket (GPT-5 fix: avoid useMemo issues)
|
||||||
|
|
@ -1927,6 +1937,20 @@ const FocusGroupSession = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quota warning banner */}
|
||||||
|
{quotaWarning && (
|
||||||
|
<div className="mx-4 mt-2 p-3 bg-amber-50 border border-amber-200 rounded-md flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<span className="text-sm text-amber-700">
|
||||||
|
Usage at {Math.round(quotaWarning.pct * 100)}% of {quotaWarning.scope} quota
|
||||||
|
(${quotaWarning.used_usd.toFixed(4)} of ${quotaWarning.limit_usd.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
<Progress value={quotaWarning.pct * 100} className="w-24 h-2" />
|
||||||
|
</div>
|
||||||
|
<button className="text-xs text-amber-500 hover:text-amber-700 ml-2" onClick={() => setQuotaWarning(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quota exceeded banner */}
|
{/* Quota exceeded banner */}
|
||||||
{quotaExceeded && (
|
{quotaExceeded && (
|
||||||
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
|
<div className="mx-0 mt-2 mb-2 p-3 bg-red-50 border border-red-200 rounded-md flex items-center justify-between">
|
||||||
|
|
|
||||||
74
src/pages/MyUsage.tsx
Normal file
74
src/pages/MyUsage.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useMyUsage } from '@/hooks/useMyUsage';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Loader2, DollarSign, Zap, Activity } from 'lucide-react';
|
||||||
|
import Navigation from '@/components/Navigation';
|
||||||
|
|
||||||
|
export default function MyUsage() {
|
||||||
|
const { data, isLoading } = useMyUsage();
|
||||||
|
const totals = data?.totals ?? {};
|
||||||
|
const byFeature: any[] = data?.by_feature ?? [];
|
||||||
|
const periodStart = data?.period_start ? new Date(data.period_start).toLocaleDateString() : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Navigation />
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-24">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">My Usage</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Month-to-date since {periodStart}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Cost (MTD)', value: `$${(totals.total_cost ?? 0).toFixed(4)}`, icon: DollarSign },
|
||||||
|
{ label: 'LLM Calls', value: (totals.calls ?? 0).toLocaleString(), icon: Activity },
|
||||||
|
{ label: 'Total Tokens', value: (((totals.prompt_tokens ?? 0) + (totals.completion_tokens ?? 0)) / 1000).toFixed(1) + 'k', icon: Zap },
|
||||||
|
].map(({ label, value, icon: Icon }) => (
|
||||||
|
<Card key={label}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-1 pt-3 px-4">
|
||||||
|
<CardTitle className="text-xs font-medium text-slate-500">{label}</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-slate-400" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3">
|
||||||
|
<div className="text-xl font-bold">{value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Feature</TableHead>
|
||||||
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
|
<TableHead className="text-right">Calls</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{byFeature.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-slate-500 py-8">No usage data yet</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{byFeature.map((row: any, i: number) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="text-sm">{row._id ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">${(row.total_cost ?? 0).toFixed(6)}</TableCell>
|
||||||
|
<TableCell className="text-right text-sm">{row.calls}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,12 @@ export function initSocket(getToken: () => string): Socket {
|
||||||
case 'bulk_export_progress':
|
case 'bulk_export_progress':
|
||||||
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
|
window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload }));
|
||||||
break;
|
break;
|
||||||
|
case 'quota_warning':
|
||||||
|
window.dispatchEvent(new CustomEvent("quota_warning", { detail: payload }));
|
||||||
|
break;
|
||||||
|
case 'quota_exceeded':
|
||||||
|
window.dispatchEvent(new CustomEvent("quota_exceeded", { detail: payload }));
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
console.error('[WebSocket] Error:', payload);
|
console.error('[WebSocket] Error:', payload);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue