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:
parent
6917518d11
commit
d8a5d6643f
2 changed files with 151 additions and 201 deletions
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue