Fix data isolation + conversation/decision 500 errors

Data isolation:
- GET /tasks/<id>: verify requesting user owns the task (403 if not)
- DELETE /tasks/<id>: same ownership check
- GET /tasks/status: add @jwt_required()
- GET /personas/<id>: add ownership check (403 if created_by != user)
- GET /focus-groups/<id>: add ownership check
- GET /focus-groups/<id>/messages: add ownership check
- POST/DELETE /focus-groups/<id>/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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 17:02:10 +00:00
parent f0876649e0
commit 4b47b334d7
6 changed files with 103 additions and 70 deletions

View file

@ -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/<focus_group_id>', 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/<focus_group_id>', methods=['POST'])
@jwt_required()

View file

@ -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('/<focus_group_id>/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('/<focus_group_id>/participants/<persona_id>', 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)

View file

@ -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

View file

@ -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).

View file

@ -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') {

View file

@ -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) =>