diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 5f61f0ed..6b18803a 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -732,101 +732,82 @@ async def delete_focus_group_note(focus_group_id, note_id): @focus_groups_bp.route('//generate-discussion-guide', methods=['POST']) @jwt_required() async def generate_discussion_guide(focus_group_id=None): - """Generate a discussion guide for a focus group using the LLM service.""" - import logging - logger = logging.getLogger(__name__) - - # Log the start of the request - logger.info("Discussion guide generation request received") - + """Generate a discussion guide — returns task_id immediately (202), delivers result via WebSocket.""" try: - # Get request data data = await request.get_json() - - if not data: - logger.warning("Discussion guide generation failed: Missing request data") - return jsonify({ - "error": "Missing request data", - "details": "Request body is required", - "can_retry": False - }), 400 - - # Check for required fields - required_fields = ['name', 'description', 'objective', 'topic'] - missing_fields = [field for field in required_fields if field not in data or not data[field]] - - if missing_fields: - error_msg = f"Missing required fields: {', '.join(missing_fields)}" - logger.warning(f"Discussion guide generation failed: {error_msg}") - return jsonify({ - "error": error_msg, - "details": "Please fill in all required fields", - "missing_fields": missing_fields, - "can_retry": False - }), 400 - - # Extract data for guide generation - focus_group_name = data['name'] - research_brief = f"{data['description']}\n\nResearch Objective: {data['objective']}" - discussion_topics = data['topic'] - duration = data.get('duration', 60) - - logger.info(f"Generating discussion guide for: '{focus_group_name}' (duration: {duration}min)") - - # Get user_id for task tracking (optional for development mode) - user_id = None - try: - user_id = get_jwt_identity() - except Exception as jwt_err: - logger.warning(f"Could not retrieve JWT identity for task tracking: {jwt_err}") - # Register current task for cancellation - async with CancellableTask("discussion_guide_generation", user_id, {"focus_group_name": focus_group_name, "focus_group_id": focus_group_id}) as task_id: - - # Emit task_started event via WebSocket for immediate frontend tracking - from app.websocket_manager_async import get_async_websocket_manager - websocket_manager = get_async_websocket_manager() - if user_id: - await websocket_manager.emit_to_user( - user_id, - 'task_started', - { - 'task_id': task_id, - 'task_type': 'discussion_guide_generation', - 'message': f'Started generating discussion guide for {focus_group_name}' - } - ) - - # Add topic as a discussion topic if not already there - if discussion_topics and isinstance(discussion_topics, str): - # Convert to a specific topic if it's from the selection dropdown - topic_mapping = { - 'product-feedback': 'Product Feedback', - 'creative-testing': 'Creative Testing', - 'messaging-evaluation': 'Messaging Evaluation', - 'user-experience': 'User Experience', - 'market-research': 'Market Research' - } - formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) - else: - formatted_topic = 'General Discussion' - - # Get the LLM model for this focus group if it exists - llm_model = None - if focus_group_id: + if not data: + return jsonify({"error": "Missing request data", "can_retry": False}), 400 + + required_fields = ['name', 'description', 'objective', 'topic'] + missing_fields = [f for f in required_fields if f not in data or not data[f]] + if missing_fields: + return jsonify({ + "error": f"Missing required fields: {', '.join(missing_fields)}", + "can_retry": False + }), 400 + + 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() + app = current_app._get_current_object() + + bg_task = asyncio.create_task( + _run_discussion_guide_generation_bg(app, task_id, user_id, focus_group_id, data) + ) + await task_manager.register_task(bg_task, 'discussion_guide_generation', user_id, { + 'focus_group_name': data['name'], + 'focus_group_id': focus_group_id + }, task_id=task_id) + + websocket_manager = get_async_websocket_manager() + await websocket_manager.emit_to_user(user_id, 'task_started', { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Started generating discussion guide for {data["name"]}' + }) + + return jsonify({'task_id': task_id, 'message': 'Discussion guide generation started'}), 202 + + except Exception as e: + logger.error(f"Discussion guide generation setup failed: {str(e)}") + return jsonify({"error": str(e), "can_retry": True}), 500 + + +async def _run_discussion_guide_generation_bg(app, task_id, user_id, focus_group_id, data): + """Background coroutine: generates discussion guide and delivers result via WebSocket.""" + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + + async with app.app_context(): + try: + focus_group_name = data['name'] + research_brief = f"{data['description']}\n\nResearch Objective: {data['objective']}" + discussion_topics = data['topic'] + duration = data.get('duration', 60) + + topic_mapping = { + 'product-feedback': 'Product Feedback', + 'creative-testing': 'Creative Testing', + 'messaging-evaluation': 'Messaging Evaluation', + 'user-experience': 'User Experience', + 'market-research': 'Market Research' + } + formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) if isinstance(discussion_topics, str) else 'General Discussion' + + llm_model = data.get('llm_model') + if focus_group_id and not llm_model: try: - focus_group = await FocusGroup.find_by_id(focus_group_id) - if focus_group: - llm_model = focus_group.get('llm_model') - logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}") - except Exception as e: - logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}") - - # Use default model from request data if provided - if not llm_model: - llm_model = data.get('llm_model') - - # Generate the discussion guide + fg = await FocusGroup.find_by_id(focus_group_id) + if fg: + llm_model = fg.get('llm_model') + except Exception: + pass + discussion_guide = await FocusGroupService.generate_discussion_guide( focus_group_name=focus_group_name, research_brief=research_brief, @@ -837,106 +818,40 @@ async def generate_discussion_guide(focus_group_id=None): llm_model=llm_model ) - # Generate one-line summary for list view display summary = None try: from app.services.focus_group_summary_service import generate_focus_group_summary - - summary_data = { + summary = await generate_focus_group_summary({ 'name': focus_group_name, 'topic': formatted_topic, 'duration': duration, 'description': data.get('description', ''), 'discussionGuide': discussion_guide - } - - summary = await generate_focus_group_summary( - summary_data, - llm_model=llm_model - ) - - # Save summary to focus group if we have an ID + }, llm_model=llm_model) if focus_group_id and summary: await FocusGroup.update(focus_group_id, {'summary': summary}) - logger.info(f"Saved summary for focus group {focus_group_id}: {summary}") + except Exception as summary_err: + app.logger.warning(f"Failed to generate summary (non-critical): {summary_err}") - except Exception as summary_error: - logger.warning(f"Failed to generate summary (non-critical): {summary_error}") - # Don't fail the request - summary is optional + await websocket_manager.emit_to_user(user_id, 'task_completed', { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Successfully generated discussion guide for {focus_group_name}', + 'discussionGuide': discussion_guide, + 'summary': summary + }) - # Emit completion event via WebSocket - if user_id: - await websocket_manager.emit_to_user( - user_id, - 'task_completed', - { - 'task_id': task_id, - 'task_type': 'discussion_guide_generation', - 'message': f'Successfully generated discussion guide for {focus_group_name}' - } - ) - - logger.info(f"Discussion guide successfully generated for '{focus_group_name}'") - return jsonify({ - "message": "Discussion guide generated successfully", - "discussionGuide": discussion_guide, - "summary": summary, # Include the generated summary - "success": True, - "task_id": task_id - }), 200 - - except asyncio.CancelledError: - logger.info(f"Discussion guide generation cancelled for focus group: {data.get('name', 'Unknown') if 'data' in locals() else 'Unknown'}") - return jsonify({ - "error": "Generation cancelled", - "details": "Discussion guide generation was cancelled by user", - "can_retry": True, - "error_type": "cancelled" - }), 499 - except Exception as e: - error_msg = str(e) - logger.error(f"Discussion guide generation failed with error: {error_msg}") - - # Categorize errors for better user experience - if "prompt" in error_msg.lower() or "template" in error_msg.lower(): - # Prompt/template related error - return jsonify({ - "error": "System configuration error", - "details": "Discussion guide template is not configured properly. Please contact support.", - "technical_details": error_msg, - "can_retry": False, - "error_type": "configuration" - }), 500 - elif "failed after" in error_msg and "attempts" in error_msg: - # All retry attempts exhausted - return jsonify({ - "error": "AI generation temporarily unavailable", - "details": "The discussion guide generator failed after multiple attempts. This is usually temporary - please try again in a few minutes.", - "technical_details": error_msg, - "can_retry": True, - "error_type": "generation_failed", - "suggestion": "Wait a few minutes and try again" - }), 500 - elif "timeout" in error_msg.lower() or "connection" in error_msg.lower(): - # Network/timeout related error - return jsonify({ - "error": "Service temporarily unavailable", - "details": "Connection to the AI service timed out. Please try again.", - "technical_details": error_msg, - "can_retry": True, - "error_type": "timeout", - "suggestion": "Try again immediately" - }), 500 - else: - # Generic error - return jsonify({ - "error": "Discussion guide generation failed", - "details": "An unexpected error occurred during generation. Please try again.", - "technical_details": error_msg, - "can_retry": True, - "error_type": "unknown", - "suggestion": "Try again or contact support if the problem persists" - }), 500 + except asyncio.CancelledError: + await websocket_manager.emit_to_user(user_id, 'task_cancelled', { + 'task_id': task_id, + 'message': 'Discussion guide generation cancelled' + }) + except Exception as e: + app.logger.error(f"Discussion guide generation background task failed: {str(e)}") + await websocket_manager.emit_to_user(user_id, 'task_failed', { + 'task_id': task_id, + 'message': str(e) + }) def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None): """ diff --git a/src/hooks/useDiscussionGuideGeneration.ts b/src/hooks/useDiscussionGuideGeneration.ts index e6743a32..2cb1c4ac 100755 --- a/src/hooks/useDiscussionGuideGeneration.ts +++ b/src/hooks/useDiscussionGuideGeneration.ts @@ -82,16 +82,61 @@ export function useDiscussionGuideGeneration({ ? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData) : await focusGroupsApi.generateDiscussionGuide(requestData); - if (response.data?.task_id) { - guideGenerationControls.setTaskId(response.data.task_id); + const taskId = response.data?.task_id; + if (taskId) { + guideGenerationControls.setTaskId(taskId); } - if (response.data && response.data.discussionGuide) { + // Backend returns 202 immediately — wait for result via WebSocket + if (response.status === 202 && taskId) { + return new Promise((resolve, reject) => { + const handleCompleted = (event: CustomEvent) => { + const detail = event.detail; + if (detail.task_id !== taskId) return; + cleanup(); + if (detail.discussionGuide) { + guideGenerationControls.completeGeneration(); + resolve(detail.discussionGuide); + } else { + guideGenerationControls.failGeneration('No guide returned'); + reject(new Error('No discussion guide returned')); + } + }; + + const handleFailed = (event: CustomEvent) => { + const detail = event.detail; + if (detail.task_id !== taskId) return; + cleanup(); + guideGenerationControls.failGeneration(detail.message || 'Generation failed'); + reject(new Error(detail.message || 'Generation failed')); + }; + + const handleCancelled = (event: CustomEvent) => { + const detail = event.detail; + if (detail.task_id !== taskId) return; + cleanup(); + resolve(''); + }; + + const cleanup = () => { + window.removeEventListener('ws:task_completed', handleCompleted as EventListener); + window.removeEventListener('ws:task_failed', handleFailed as EventListener); + window.removeEventListener('ws:task_cancelled', handleCancelled as EventListener); + }; + + window.addEventListener('ws:task_completed', handleCompleted as EventListener); + window.addEventListener('ws:task_failed', handleFailed as EventListener); + window.addEventListener('ws:task_cancelled', handleCancelled as EventListener); + }); + } + + // Fallback: synchronous response with guide in body + if (response.data?.discussionGuide) { guideGenerationControls.completeGeneration(); return response.data.discussionGuide; - } else { - throw new Error("Failed to generate discussion guide"); } + + throw new Error("Failed to generate discussion guide"); } catch (error: any) { if (error.response?.status === 499) { return ''; @@ -107,23 +152,13 @@ export function useDiscussionGuideGeneration({ errorMessage = error.message; } - if (errorMessage.includes('500') || errorMessage.includes('internal error') || errorMessage.includes('Internal Server Error')) { - toast.error("AI service temporarily unavailable", { - description: "The discussion guide generator is experiencing issues. Please try again in a few minutes.", - action: { - label: "Retry", - onClick: () => generateDiscussionGuide(values, focusGroupId) - } - }); - } else { - toast.error("Failed to generate discussion guide", { - description: errorMessage, - action: { - label: "Retry", - onClick: () => generateDiscussionGuide(values, focusGroupId) - } - }); - } + toast.error("Failed to generate discussion guide", { + description: errorMessage, + action: { + label: "Retry", + onClick: () => generateDiscussionGuide(values, focusGroupId) + } + }); throw error; }