From 4b47b334d7a4bb317c1a639444b7702358329890 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 17:02:10 +0000 Subject: [PATCH] Fix data isolation + conversation/decision 500 errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data isolation: - GET /tasks/: verify requesting user owns the task (403 if not) - DELETE /tasks/: same ownership check - GET /tasks/status: add @jwt_required() - GET /personas/: add ownership check (403 if created_by != user) - GET /focus-groups/: add ownership check - GET /focus-groups//messages: add ownership check - POST/DELETE /focus-groups//participants: add ownership check Fix conversation/decision 500: - Convert POST /conversation/decision to async 202+background (was synchronous LLM → timed out / LLM errors → 500) - Frontend polls waitForTaskResult for decision result before calling generateResponseAsync - GET /conversation/insights: return empty insights (200) on LLM error instead of 500 Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/focus_group_ai.py | 90 +++++++++---------- backend/app/routes/focus_groups.py | 32 +++++-- backend/app/routes/personas.py | 6 +- backend/app/routes/tasks.py | 23 +++-- .../focus-group-session/DiscussionPanel.tsx | 14 +-- src/lib/api.ts | 8 +- 6 files changed, 103 insertions(+), 70 deletions(-) diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 0a2e958e..ed6613ed 100755 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -976,44 +976,49 @@ async def get_conversation_analytics(focus_group_id): async def make_conversation_decision(focus_group_id): """ Make a conversation decision using the LLM decision engine. - - Request body: - { - "temperature": 0.7, # Optional, defaults to 0.7 - "mode": "ai" # Optional, "ai" for autonomous mode, "manual" for manual mode, defaults to "ai" - } - - Returns: - A JSON object containing the decision + Returns 202 immediately; poll GET /tasks/{task_id} for result. """ try: + user_id = get_jwt_identity() data = (await request.get_json()) or {} temperature = data.get('temperature', 0.7) - mode = data.get('mode', 'ai') # Default to 'ai' mode for backward compatibility - - # Make decision - decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature, mode) - - response_data = { - "message": "Conversation decision made", - "decision": decision, - "focus_group_id": focus_group_id - } - - return jsonify(response_data), 200 - - except ConversationDecisionError as e: - current_app.logger.error(f"Error making conversation decision: {str(e)}") - return jsonify({ - "error": "Failed to make conversation decision", - "message": str(e) - }), 500 + mode = data.get('mode', 'ai') + + from app.services.task_manager import get_task_manager + task_manager = get_task_manager() + task_id = task_manager.generate_task_id() + app = current_app._get_current_object() + bg_task = asyncio.create_task( + _run_conversation_decision_bg(app, task_id, user_id, focus_group_id, temperature, mode) + ) + await task_manager.register_task( + bg_task, 'conversation_decision', user_id, + {'focus_group_id': focus_group_id}, task_id=task_id + ) + return jsonify({'task_id': task_id, 'message': 'Decision started'}), 202 + except Exception as e: - current_app.logger.error(f"Unexpected error making conversation decision: {str(e)}") - return jsonify({ - "error": "Failed to make conversation decision", - "message": str(e) - }), 500 + current_app.logger.error(f"Error starting conversation decision: {str(e)}") + return jsonify({"error": "Failed to start conversation decision", "message": str(e)}), 500 + + +async def _run_conversation_decision_bg(app, task_id, user_id, focus_group_id, temperature, mode): + from app.services.task_manager import store_task_result + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + async with app.app_context(): + try: + decision = await ConversationDecisionService.decide_next_action(focus_group_id, temperature, mode) + await store_task_result(task_id, 'completed', result={'decision': decision, 'focus_group_id': focus_group_id}) + await websocket_manager.emit_to_user(user_id, 'task_completed', { + 'task_id': task_id, 'task_type': 'conversation_decision' + }) + except Exception as e: + current_app.logger.error(f"conversation_decision bg error: {str(e)}") + 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': 'conversation_decision', 'error': str(e) + }) @focus_group_ai_bp.route('/conversation/insights/', methods=['GET']) @jwt_required() @@ -1025,27 +1030,20 @@ async def get_conversation_insights(focus_group_id): A JSON object containing conversation insights """ try: - # Get insights insights = await ConversationDecisionService.get_conversation_insights(focus_group_id) - return jsonify({ "message": "Conversation insights generated", "insights": insights, "focus_group_id": focus_group_id }), 200 - - except ConversationDecisionError as e: + except Exception as e: + # Return empty insights on LLM error — dashboard handles missing data gracefully current_app.logger.error(f"Error getting conversation insights: {str(e)}") return jsonify({ - "error": "Failed to get conversation insights", - "message": str(e) - }), 500 - except Exception as e: - current_app.logger.error(f"Unexpected error getting conversation insights: {str(e)}") - return jsonify({ - "error": "Failed to get conversation insights", - "message": str(e) - }), 500 + "message": "Insights unavailable", + "insights": {}, + "focus_group_id": focus_group_id + }), 200 @focus_group_ai_bp.route('/conversation/intervene/', methods=['POST']) @jwt_required() diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 1b44dbda..3b10bbfb 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -326,9 +326,13 @@ async def get_all_focus_groups(): @jwt_required() async def get_focus_group(focus_group_id): try: + user_id = get_jwt_identity() focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return jsonify({"message": "Focus group not found"}), 404 + + if focus_group.get("created_by") and focus_group.get("created_by") != user_id: + return jsonify({"message": "Permission denied"}), 403 # Process participants count if needed if 'participants' in focus_group and focus_group['participants'] and isinstance(focus_group['participants'], list): @@ -445,18 +449,22 @@ async def delete_focus_group(focus_group_id): @focus_groups_bp.route('//participants', methods=['POST']) @jwt_required() async def add_participant(focus_group_id): + user_id = get_jwt_identity() data = await request.get_json() - + if not data or not data.get('persona_id'): return jsonify({"message": "Missing persona_id"}), 400 - + persona_id = data.get('persona_id') - - # Verify focus group exists + + # Verify focus group exists and belongs to user focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return jsonify({"message": "Focus group not found"}), 404 - + + if focus_group.get("created_by") and focus_group.get("created_by") != user_id: + return jsonify({"message": "Permission denied"}), 403 + # Verify persona exists persona = await Persona.find_by_id(persona_id) if not persona: @@ -473,10 +481,14 @@ async def add_participant(focus_group_id): @focus_groups_bp.route('//participants/', methods=['DELETE']) @jwt_required() async def remove_participant(focus_group_id, persona_id): - # Verify focus group exists + user_id = get_jwt_identity() + # Verify focus group exists and belongs to user focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return jsonify({"message": "Focus group not found"}), 404 + + if focus_group.get("created_by") and focus_group.get("created_by") != user_id: + return jsonify({"message": "Permission denied"}), 403 success = await FocusGroup.remove_participant(focus_group_id, persona_id) @@ -491,11 +503,15 @@ async def remove_participant(focus_group_id, persona_id): async def get_focus_group_messages(focus_group_id): """Get all messages for a focus group, including mode events.""" try: - # Verify focus group exists + user_id = get_jwt_identity() + # Verify focus group exists and belongs to user focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return jsonify({"message": "Focus group not found"}), 404 - + + if focus_group.get("created_by") and focus_group.get("created_by") != user_id: + return jsonify({"message": "Permission denied"}), 403 + # Get messages and mode events messages = await FocusGroup.get_messages(focus_group_id) mode_events = await FocusGroup.get_mode_events(focus_group_id) diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index a48035c8..17914442 100755 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -52,10 +52,14 @@ async def get_all_personas(): @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 diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 5bebd328..d954bf32 100755 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -20,12 +20,17 @@ async def get_task_result(task_id: str): Poll for task status and result. Returns 200 with {task_id, status, task_type, result?, error?} if found. Returns 404 if task not found (expired or never existed). + Returns 403 if task belongs to a different user. """ try: + user_id = get_jwt_identity() task_manager = get_task_manager() - data = await task_manager.get_task_status_dict(task_id) - if not data: + task_info = await task_manager.get_task_info(task_id) + if not task_info: return jsonify({'error': 'Task not found or expired', 'task_id': task_id}), 404 + if task_info.user_id and task_info.user_id != user_id: + return jsonify({'error': 'Access denied', 'task_id': task_id}), 403 + data = await task_manager.get_task_status_dict(task_id) return jsonify(data), 200 except Exception as e: logger.error(f"Error fetching task result {task_id}: {str(e)}") @@ -47,12 +52,19 @@ async def cancel_task(task_id: str): try: task_manager = get_task_manager() - # Get task info before cancellation for WebSocket notification + # Get task info before cancellation for ownership check + WebSocket notification task_info = await task_manager.get_task_info(task_id) - + + if not task_info: + return jsonify({'error': 'Task not found or already completed', 'task_id': task_id}), 404 + + user_id = get_jwt_identity() + if task_info.user_id and task_info.user_id != user_id: + return jsonify({'error': 'Access denied', 'task_id': task_id}), 403 + # Attempt to cancel the task cancelled = await task_manager.cancel_task(task_id) - + if not cancelled: return jsonify({ 'error': 'Task not found or already completed', @@ -128,6 +140,7 @@ async def get_user_tasks(): @tasks_bp.route('/status', methods=['GET']) +@jwt_required() async def get_task_status(): """ Get overall task manager status (for debugging/monitoring). diff --git a/src/components/focus-group-session/DiscussionPanel.tsx b/src/components/focus-group-session/DiscussionPanel.tsx index 6bb00b32..77dc5f13 100755 --- a/src/components/focus-group-session/DiscussionPanel.tsx +++ b/src/components/focus-group-session/DiscussionPanel.tsx @@ -1024,13 +1024,15 @@ const DiscussionPanel = ({ }); const decisionResponse = await focusGroupAiApi.makeConversationDecision(focusGroupId, 0.7, 'manual'); - - // Check if we have a valid decision response - if (!decisionResponse || !decisionResponse.data || !decisionResponse.data.decision) { - throw new Error("Empty decision response from AI"); + const decisionTaskId = decisionResponse.data?.task_id; + if (!decisionTaskId) throw new Error("Failed to start decision task"); + + const decisionTaskResult = await waitForTaskResult(decisionTaskId); + if (decisionTaskResult.status !== 'completed' || !decisionTaskResult.result?.decision) { + throw new Error(decisionTaskResult.error || "Failed to get AI decision"); } - - const decision = decisionResponse.data.decision; + + const decision = decisionTaskResult.result.decision; // Check if AI decided a participant should respond if (decision.action === 'participant_respond') { diff --git a/src/lib/api.ts b/src/lib/api.ts index d72b0cbd..0a56de40 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -613,16 +613,16 @@ export const focusGroupAiApi = { api.get(`/focus-group-ai/conversation/analytics/${focusGroupId}`), makeConversationDecision: (focusGroupId: string, temperature: number = 0.7, mode: string = 'ai') => - api.post(`/focus-group-ai/conversation/decision/${focusGroupId}`, { + api.post(`/focus-group-ai/conversation/decision/${focusGroupId}`, { temperature: temperature, mode: mode }, { - timeout: 180000 // 3 minutes for LLM decision making + timeout: 10000 // Returns 202 immediately }), - + getConversationInsights: (focusGroupId: string) => api.get(`/focus-group-ai/conversation/insights/${focusGroupId}`, { - timeout: 180000 // 3 minutes for LLM insight generation + timeout: 180000 // LLM call, keep long timeout }), manualIntervention: (focusGroupId: string, action: string, message?: string, participantId?: string) =>