Fix discussion guide 504: async flow + WebSocket delivery

- Backend: /generate-discussion-guide now returns task_id immediately (202)
  and runs generation as a background asyncio task, delivering the guide
  via WebSocket task_completed event (bypasses GCP LB 30s timeout)
- Frontend: useDiscussionGuideGeneration awaits ws:task_completed event
  to resolve the guide Promise instead of waiting on the HTTP response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 15:32:08 +00:00
parent 6917518d11
commit d8a5d6643f
2 changed files with 151 additions and 201 deletions

View file

@ -732,101 +732,82 @@ async def delete_focus_group_note(focus_group_id, note_id):
@focus_groups_bp.route('/<focus_group_id>/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):
"""

View file

@ -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<string>((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;
}