Fix all async LLM routes: bypass GCP 30s load balancer timeout

Convert 6 synchronous LLM routes to async 202+WebSocket pattern:
- generate-response (focus_group_ai): persona chat response
- generate-key-themes (focus_group_ai): discussion analysis
- modify-with-ai (personas): AI persona modification
- export-profile (personas): markdown profile export
- describe-asset (focus_groups): image AI description

Each route now returns 202 + task_id immediately, runs LLM in
asyncio background task, delivers result via WebSocket task_completed
event. Frontend listeners updated to wait for ws:task_completed
instead of HTTP response body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 15:56:54 +00:00
parent f4a587c4f7
commit c7034634e3
7 changed files with 592 additions and 406 deletions

View file

@ -46,7 +46,7 @@ def _user_key():
async def generate_ai_response():
"""
Generate a response from a persona in a focus group discussion.
Request body:
{
"focus_group_id": "focus_group_id",
@ -55,110 +55,113 @@ async def generate_ai_response():
"temperature": 0.7, # Optional
"message_limit": 10 # Optional, number of previous messages to include
}
Returns:
A JSON object containing the generated response
Returns immediately with 202 + task_id; result delivered via WebSocket task_completed event.
"""
try:
data = (await request.get_json()) or {}
# Validate required fields
required_fields = ['focus_group_id', 'persona_id', 'current_topic']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return jsonify({
"error": "Missing required fields",
"error": "Missing required fields",
"missing": missing_fields
}), 400
focus_group_id = data['focus_group_id']
persona_id = data['persona_id']
current_topic = data['current_topic']
temperature = data.get('temperature', 0.7)
user_id = get_jwt_identity()
# Validate focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Get the LLM model and GPT-5 parameters for this focus group
llm_model = focus_group.get('llm_model')
reasoning_effort = focus_group.get('reasoning_effort', 'low')
verbosity = focus_group.get('verbosity', 'medium')
current_app.logger.info(f"🔍 DEBUG: Focus group data keys: {list(focus_group.keys())}")
current_app.logger.info(f"🔍 DEBUG: Raw llm_model value from DB: '{focus_group.get('llm_model')}' (type: {type(focus_group.get('llm_model'))})")
current_app.logger.info(f"🤖 Generating AI response using model: {llm_model or 'default (gemini-3-pro-preview)'} for focus group {focus_group_id}")
# Validate persona exists
persona = await Persona.find_by_id(persona_id)
if not persona:
return jsonify({"error": "Persona not found"}), 404
# Validate persona is part of the focus group
if 'participants' not in focus_group or persona_id not in focus_group['participants']:
return jsonify({
"error": "Persona is not a participant in this focus group"
}), 400
# Skip discussion guide retrieval - not needed for participant responses
# Get previous messages
messages = await FocusGroup.get_messages(focus_group_id)
# Get all messages, the service will limit to the most recent 50
recent_messages = messages
# Check if this focus group has any active visual context
# This is the new approach - use persistent conversation context instead of detection
print(f"🎨 Checking for active visual context in focus group {focus_group_id}")
from app.services.conversation_context_service import ConversationContextService
has_visual_context = await ConversationContextService.has_visual_context(focus_group_id)
print(f"🎨 Focus group has active visual context: {has_visual_context}")
# Build multimodal conversation context
try:
multimodal_context = await ConversationContextService.build_multimodal_context(focus_group_id, recent_messages)
print(f"✅ Built multimodal context with {multimodal_context['total_visual_assets']} visual assets")
except Exception as e:
print(f"❌ Error building multimodal context: {e}")
# Fallback to empty context
multimodal_context = {
"has_visual_context": False,
"conversation_context": [],
"text_context": "",
"visual_timeline": {},
"total_messages": len(recent_messages),
"total_visual_assets": 0
}
# DEBUG: Log visual context detection
print(f"🎨 VISUAL CONTEXT DEBUG:")
print(f" - focus_group_id: {focus_group_id}")
print(f" - has_visual_context: {has_visual_context}")
print(f" - total_visual_assets: {multimodal_context['total_visual_assets']}")
print(f" - total_context_items: {len(multimodal_context['conversation_context'])}")
current_app.logger.info(f"Visual context detection: has_visual_context={has_visual_context}, total_assets={multimodal_context['total_visual_assets']}")
# Generate the response using the new contextual approach
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()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_generate_response_bg(app, task_id, user_id, focus_group_id, persona_id, current_topic, temperature)
)
await task_manager.register_task(
bg_task, 'generate_response', user_id,
{'focus_group_id': focus_group_id, 'persona_id': persona_id},
task_id=task_id
)
await websocket_manager.emit_to_user(user_id, 'task_started', {
'task_id': task_id,
'task_type': 'generate_response',
'message': f'Started generating response for persona {persona_id}'
})
return jsonify({'task_id': task_id, 'message': 'Response generation started'}), 202
except Exception as e:
current_app.logger.error(f"Unexpected error starting generate_response: {str(e)}")
return jsonify({"error": "Internal server error", "message": str(e)}), 500
async def _run_generate_response_bg(app, task_id, user_id, focus_group_id, persona_id, current_topic, temperature):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
# Get focus group and persona
focus_group = await FocusGroup.find_by_id(focus_group_id)
persona = await Persona.find_by_id(persona_id)
llm_model = focus_group.get('llm_model')
reasoning_effort = focus_group.get('reasoning_effort', 'low')
verbosity = focus_group.get('verbosity', 'medium')
# Get previous messages
messages = await FocusGroup.get_messages(focus_group_id)
recent_messages = messages
# Check for active visual context
from app.services.conversation_context_service import ConversationContextService
has_visual_context = await ConversationContextService.has_visual_context(focus_group_id)
# Build multimodal conversation context
try:
multimodal_context = await ConversationContextService.build_multimodal_context(focus_group_id, recent_messages)
except Exception as e:
app.logger.warning(f"Error building multimodal context: {e}")
multimodal_context = {
"has_visual_context": False,
"conversation_context": [],
"text_context": "",
"visual_timeline": {},
"total_messages": len(recent_messages),
"total_visual_assets": 0
}
# Generate response
if has_visual_context:
print(f"🎨 Using contextual response generation with visual context")
current_app.logger.info(f"Generating contextual response with {multimodal_context['total_visual_assets']} visual assets")
# Import here to avoid circular imports
from app.services.llm_service import LLMService
from app.utils.prompt_loader import load_prompt
# Build persona context for the prompt
persona_details = _format_persona_details_for_context(persona)
# Create the contextual prompt
prompt = load_prompt('focus-group-response', {
'persona_details': persona_details,
'current_topic': current_topic,
'previous_messages': multimodal_context['text_context'], # Fallback text context
'previous_messages': multimodal_context['text_context'],
'length_instructions': _get_response_length_instructions(persona, recent_messages, current_topic),
'is_creative_review': True,
'creative_instructions': """
@ -168,7 +171,7 @@ You are participating in a focus group discussion where visual materials have be
Consider:
- Your first impression of any visuals shown
- How the visual materials relate to the discussion topic
- How the visual materials relate to the discussion topic
- Any specific elements that catch your attention
- How the visuals might appeal to people like you
- Any suggestions or concerns you might have
@ -177,8 +180,6 @@ Consider:
Be genuine and specific in your feedback, drawing on your personal experiences and preferences.
"""
})
# Generate response using contextual conversation method
response_text = await LLMService.generate_contextual_response(
prompt=prompt,
conversation_context=multimodal_context['conversation_context'],
@ -188,9 +189,6 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
verbosity=verbosity if llm_model in ('gpt-5', 'gpt-5.2') else None
)
else:
print(f"💬 Using standard response generation (no visual context)")
current_app.logger.info(f"Generating standard response")
response_text = await generate_persona_response(
persona=persona,
current_topic=current_topic,
@ -201,68 +199,44 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
reasoning_effort=reasoning_effort,
verbosity=verbosity
)
# Log success with response details
response_type = "contextual with visual context" if has_visual_context else "standard"
print(f"✅ Generated {response_type} response for persona {persona_id}")
print(f"🔍 RESPONSE DEBUG:")
print(f" - Response length: {len(response_text) if response_text else 0} characters")
print(f" - Response type: {type(response_text)}")
print(f" - Response preview: '{response_text[:200] if response_text else 'EMPTY'}...'")
print(f" - Response repr: {repr(response_text[:50]) if response_text else 'NONE'}")
current_app.logger.info(f"Generated {response_type} response for persona {persona_id} in focus group {focus_group_id}")
# Save message to DB
message_data = {
"text": response_text,
"type": "response",
"senderId": persona_id
}
message_id = FocusGroup.add_message(focus_group_id, message_data)
if message_id:
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'generate_response',
'response': response_text,
'message_id': message_id,
'persona_id': persona_id,
'focus_group_id': focus_group_id
})
else:
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'generate_response',
'message': 'Failed to save message to database'
})
except asyncio.CancelledError:
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {
'task_id': task_id,
'task_type': 'generate_response',
'message': 'Response generation was cancelled'
})
except Exception as e:
print(f"❌ Error in response generation: {str(e)}")
current_app.logger.error(f"Error generating response: {str(e)}")
import traceback
print(f"❌ Full traceback: {traceback.format_exc()}")
raise
# Prepare and save the message
print(f"💾 Preparing to save message to database...")
message_data = {
"text": response_text,
"type": "response",
"senderId": persona_id
}
print(f"💾 Message data keys: {list(message_data.keys())}")
print(f"💾 Message text length: {len(message_data['text']) if message_data['text'] else 0}")
print(f"💾 Message text preview: '{message_data['text'][:100] if message_data['text'] else 'EMPTY'}...'")
print(f"💾 Message text repr: {repr(message_data['text'][:20]) if message_data['text'] else 'NONE'}")
print(f"💾 Calling FocusGroup.add_message...")
message_id = FocusGroup.add_message(focus_group_id, message_data)
print(f"💾 Message saved with ID: {message_id}")
if not message_id:
print(f"❌ Failed to save message to database - no message ID returned")
current_app.logger.error("Failed to save message to database")
return jsonify({
"error": "Failed to save message",
"message": "The AI response was generated but could not be saved to the database"
}), 500
return jsonify({
"message": "Response generated successfully",
"response": response_text,
"message_id": message_id,
"persona_id": persona_id,
"focus_group_id": focus_group_id
}), 200
except FocusGroupResponseError as e:
current_app.logger.error(f"Focus group response generation error: {str(e)}")
return jsonify({
"error": "Failed to generate response",
"message": str(e)
}), 500
except Exception as e:
current_app.logger.error(f"Unexpected error in focus group response: {str(e)}")
return jsonify({
"error": "Internal server error",
"message": "An unexpected error occurred"
}), 500
app.logger.error(f"Error in _run_generate_response_bg: {str(e)}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'generate_response',
'message': str(e)
})
@focus_group_ai_bp.route('/generate-key-themes', methods=['POST'])
@jwt_required()
@ -270,132 +244,116 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
async def generate_key_themes():
"""
Generate key themes from a focus group discussion.
Request body:
{
"focus_group_id": "focus_group_id",
"temperature": 0.7 # Optional
}
Returns:
A JSON object containing the generated key themes
Returns immediately with 202 + task_id; result delivered via WebSocket task_completed event.
"""
try:
data = (await request.get_json()) or {}
# Validate required fields
if 'focus_group_id' not in data:
return jsonify({
"error": "Missing required field: focus_group_id"
}), 400
focus_group_id = data['focus_group_id']
temperature = data.get('temperature', 0.7)
user_id = get_jwt_identity()
# Validate focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Get user_id for task tracking (optional for development mode)
user_id = None
try:
user_id = get_jwt_identity()
except Exception as jwt_err:
current_app.logger.warning(f"Could not retrieve JWT identity for task tracking: {jwt_err}")
# Register current task for cancellation
async with CancellableTask("key_themes_generation", user_id, {"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': 'key_themes_generation',
'message': f'Started generating key themes for focus group {focus_group_id}'
}
)
# Get the LLM model for this focus group
llm_model = focus_group.get('llm_model')
# Generate key themes
try:
themes = await KeyThemeService.generate_key_themes(
focus_group_id=focus_group_id,
temperature=temperature,
llm_model=llm_model
)
# Log success
current_app.logger.info(f"Generated {len(themes)} key themes for focus group {focus_group_id}")
# Save themes to database
theme_ids = await FocusGroup.add_generated_themes(focus_group_id, themes)
if not theme_ids:
current_app.logger.error("Failed to save themes to database")
return jsonify({
"error": "Failed to save themes",
"message": "The themes were generated but could not be saved to the database"
}), 500
# Format the themes for response
formatted_themes = []
for i, theme in enumerate(themes):
if i < len(theme_ids):
formatted_themes.append({
"id": theme_ids[i],
"title": theme["title"],
"description": theme["description"],
"quotes": theme.get("quotes", []),
"source": "generated"
})
# Emit completion event via WebSocket
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_completed',
{
'task_id': task_id,
'task_type': 'key_themes_generation',
'message': f'Successfully generated {len(formatted_themes)} key themes',
'themes_created': len(formatted_themes)
}
)
return jsonify({
"message": "Key themes generated successfully",
"themes": formatted_themes,
"focus_group_id": focus_group_id,
"task_id": task_id
}), 200
except KeyThemeServiceError as e:
current_app.logger.error(f"Error generating key themes: {str(e)}")
return jsonify({
"error": "Failed to generate key themes",
"message": str(e)
}), 500
except asyncio.CancelledError:
current_app.logger.info(f"Key themes generation cancelled for focus group {focus_group_id}")
return jsonify({
"error": "Generation cancelled",
"message": "Key themes generation was cancelled by user"
}), 499
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()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_key_themes_bg(app, task_id, user_id, focus_group_id, temperature)
)
await task_manager.register_task(
bg_task, 'key_themes_generation', user_id,
{'focus_group_id': focus_group_id},
task_id=task_id
)
await websocket_manager.emit_to_user(user_id, 'task_started', {
'task_id': task_id,
'task_type': 'key_themes_generation',
'message': f'Started generating key themes for focus group {focus_group_id}'
})
return jsonify({'task_id': task_id, 'message': 'Key themes generation started'}), 202
except Exception as e:
current_app.logger.error(f"Unexpected error in key theme generation: {str(e)}")
return jsonify({
"error": "Internal server error",
"message": "An unexpected error occurred"
}), 500
current_app.logger.error(f"Unexpected error starting generate_key_themes: {str(e)}")
return jsonify({"error": "Internal server error", "message": str(e)}), 500
async def _run_key_themes_bg(app, task_id, user_id, focus_group_id, temperature):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
llm_model = focus_group.get('llm_model')
themes = await KeyThemeService.generate_key_themes(
focus_group_id=focus_group_id,
temperature=temperature,
llm_model=llm_model
)
app.logger.info(f"Generated {len(themes)} key themes for focus group {focus_group_id}")
theme_ids = await FocusGroup.add_generated_themes(focus_group_id, themes)
if not theme_ids:
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'key_themes_generation',
'message': 'The themes were generated but could not be saved to the database'
})
return
formatted_themes = []
for i, theme in enumerate(themes):
if i < len(theme_ids):
formatted_themes.append({
"id": theme_ids[i],
"title": theme["title"],
"description": theme["description"],
"quotes": theme.get("quotes", []),
"source": "generated"
})
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'key_themes_generation',
'themes': formatted_themes,
'focus_group_id': focus_group_id
})
except asyncio.CancelledError:
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {
'task_id': task_id,
'task_type': 'key_themes_generation',
'message': 'Key themes generation was cancelled'
})
except Exception as e:
app.logger.error(f"Error in _run_key_themes_bg: {str(e)}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'key_themes_generation',
'message': str(e)
})
@focus_group_ai_bp.route('/key-themes/<focus_group_id>', methods=['GET'])
@jwt_required()

View file

@ -1592,52 +1592,69 @@ def test_websocket_emission(focus_group_id):
"event": "message_update"
}), 200
async def _run_describe_asset_bg(app, task_id, user_id, focus_group_id, asset_filename):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
description = await ImageDescriptionService.generate_description(focus_group_id, asset_filename)
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'describe_asset',
'description': description,
'asset_filename': asset_filename
})
except asyncio.CancelledError:
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {
'task_id': task_id,
'task_type': 'describe_asset'
})
except Exception as e:
await websocket_manager.emit_to_user(user_id, 'task_failed', {
'task_id': task_id,
'task_type': 'describe_asset',
'message': str(e)
})
@focus_groups_bp.route('/<focus_group_id>/describe-asset', methods=['POST'])
@jwt_required()
async def describe_asset(focus_group_id):
"""Generate AI description of an asset for enhanced creative review questions."""
logger.debug(f"🔍 API ENDPOINT: describe-asset called for focus group {focus_group_id}")
logger.debug(f"API ENDPOINT: describe-asset called for focus group {focus_group_id}")
try:
# Verify focus group exists
logger.debug(f"🔍 API: Looking up focus group {focus_group_id}")
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
logger.error(f"API: Focus group {focus_group_id} not found")
return jsonify({"error": "Focus group not found"}), 404
logger.debug(f"API: Focus group {focus_group_id} found")
# Get asset filename from request
data = await request.get_json()
logger.debug(f"🔍 API: Request data: {data}")
if not data or 'asset_filename' not in data:
logger.error(f"API: Missing asset_filename in request")
return jsonify({"error": "Missing asset_filename in request"}), 400
asset_filename = data['asset_filename']
logger.debug(f"🔍 API: Asset filename: {asset_filename}")
logger.debug(f"API: Generating description for asset {asset_filename} in focus group {focus_group_id}")
# Generate AI description
try:
description = await ImageDescriptionService.generate_description(focus_group_id, asset_filename)
return jsonify({
"message": "Asset description generated successfully",
"asset_filename": asset_filename,
"description": description
}), 200
except ImageDescriptionError as e:
error_msg = f"Failed to generate description: {str(e)}"
logger.error(f"API: {error_msg}")
return jsonify({
"error": error_msg,
"asset_filename": asset_filename,
"fallback": True
}), 422 # Unprocessable Entity - client should fallback to original text
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()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_describe_asset_bg(app, task_id, user_id, focus_group_id, asset_filename)
)
await task_manager.register_task(bg_task, 'describe_asset', user_id, {'focus_group_id': focus_group_id, 'asset_filename': asset_filename}, task_id=task_id)
await websocket_manager.emit_to_user(user_id, 'task_started', {
'task_id': task_id,
'task_type': 'describe_asset',
'message': f'Started generating description for {asset_filename}'
})
return jsonify({'task_id': task_id, 'message': 'Asset description generation started'}), 202
except Exception as e:
logger.error(f"Error in describe_asset: {e}")
return jsonify({"error": str(e)}), 500

View file

@ -1,5 +1,5 @@
import logging
from quart import Blueprint, request, jsonify, send_file, Response
from quart import Blueprint, request, jsonify, send_file, Response, current_app
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.models.persona import Persona
import json
@ -8,7 +8,6 @@ logger = logging.getLogger(__name__)
from app.services.persona_export_service import PersonaExportService
from app.services.bulk_persona_export_service import BulkPersonaExportService
from app.services.persona_modification_service import PersonaModificationService, PersonaModificationError
from app.services.task_manager import CancellableTask
from bson import ObjectId
import datetime
import asyncio
@ -154,7 +153,8 @@ async def create_multiple_personas():
async def modify_persona_with_ai(persona_id):
"""
Modify a persona using AI based on natural language instructions.
Returns 202 immediately; result delivered via WebSocket task_completed event.
Request body should include:
- modification_prompt: Natural language description of desired changes
- llm_model: Model to use (defaults to 'gemini-3-pro-preview')
@ -163,49 +163,45 @@ async def modify_persona_with_ai(persona_id):
- preview_only: If true, returns modified data without saving to database (defaults to false)
"""
try:
# Get request data
request_data = await request.get_json()
if not request_data:
return jsonify({"error": "No request data provided"}), 400
modification_prompt = request_data.get('modification_prompt')
if not modification_prompt:
return jsonify({"error": "modification_prompt is required"}), 400
llm_model = request_data.get('llm_model', 'gemini-3-pro-preview')
reasoning_effort = request_data.get('reasoning_effort', 'medium')
verbosity = request_data.get('verbosity', 'medium')
preview_only = request_data.get('preview_only', False)
mode_text = "previewing" if preview_only else "modifying"
logger.debug(f"Backend: {mode_text.title()} persona {persona_id} with {llm_model}")
logger.debug(f"Modification prompt: {modification_prompt[:100]}...")
# 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("persona_modification", user_id, {"persona_id": persona_id, "preview_only": preview_only}) 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': 'persona_modification',
'message': f'Started {"previewing" if preview_only else "modifying"} persona {persona_id}'
}
)
# Call the modification service
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()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_modify_persona_bg(app, task_id, user_id, persona_id, modification_prompt, llm_model, reasoning_effort, verbosity, preview_only)
)
await task_manager.register_task(bg_task, 'persona_modification', user_id, {'persona_id': persona_id, 'preview_only': preview_only}, task_id=task_id)
await websocket_manager.emit_to_user(user_id, 'task_started', {'task_id': task_id, 'task_type': 'persona_modification'})
return jsonify({'task_id': task_id, 'message': 'Persona modification started'}), 202
except Exception as e:
logger.error(f"Unexpected error in persona modification: {e}")
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
async def _run_modify_persona_bg(app, task_id, user_id, persona_id, modification_prompt, llm_model, reasoning_effort, verbosity, preview_only):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
modified_persona_data = await PersonaModificationService.modify_persona(
persona_id=persona_id,
modification_prompt=modification_prompt,
@ -214,102 +210,102 @@ async def modify_persona_with_ai(persona_id):
verbosity=verbosity,
preview_only=preview_only
)
# Emit completion event via WebSocket
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_completed',
{
'task_id': task_id,
'task_type': 'persona_modification',
'message': f'Successfully {"previewed" if preview_only else "modified"} persona'
}
)
success_message = "Persona preview generated successfully" if preview_only else "Persona modified successfully"
return jsonify({
"success": True,
"message": success_message,
"persona": make_serializable(modified_persona_data),
"preview_only": preview_only,
"task_id": task_id
}), 200
except asyncio.CancelledError:
logger.debug(f"⏹️ Persona modification cancelled for persona {persona_id}")
return jsonify({
"error": "Generation cancelled",
"message": "Persona modification was cancelled by user"
}), 499
except PersonaModificationError as e:
logger.error(f"Persona modification error: {e}")
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"Unexpected error in persona modification: {e}")
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'persona_modification',
'persona': make_serializable(modified_persona_data),
'preview_only': preview_only
})
except asyncio.CancelledError:
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {'task_id': task_id})
except PersonaModificationError as e:
logger.error(f"Persona modification error: {e}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'message': str(e)})
except Exception as e:
logger.error(f"Unexpected error in _run_modify_persona_bg: {e}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'message': str(e)})
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
@jwt_required()
async def export_persona_profile(persona_id):
"""
Export a persona profile as beautifully formatted markdown.
Returns 202 immediately; result delivered via WebSocket task_completed event.
Request body can optionally include:
- llm_model: Model to use (defaults to 'gpt-4.1')
- temperature: Temperature for generation (defaults to 0.3)
"""
try:
# Get the persona data
persona = await Persona.find_by_id(persona_id)
if not persona:
return jsonify({"error": "Persona not found"}), 404
# Get optional parameters from request
request_data = await request.get_json() or {}
llm_model = request_data.get('llm_model', 'gpt-4.1')
temperature = request_data.get('temperature', 0.3)
# Initialize export service
export_service = PersonaExportService()
# Make persona data serializable for JSON processing
persona_data = make_serializable(persona)
logger.debug(f"Backend: Exporting profile for persona {persona_data.get('name', persona_id)} using {llm_model}")
# Generate the markdown profile
result = await export_service.generate_profile_markdown(
persona_data=persona_data,
llm_model=llm_model,
temperature=temperature
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()
websocket_manager = get_async_websocket_manager()
app = current_app._get_current_object()
bg_task = asyncio.create_task(
_run_export_profile_bg(app, task_id, user_id, persona_id, llm_model, temperature)
)
if result.get('success'):
return jsonify({
"success": True,
"markdown_content": result['markdown_content'],
"persona_name": result['persona_name'],
"model_used": result.get('model_used'),
"content_length": result.get('content_length')
}), 200
else:
# If LLM generation failed, try fallback
logger.debug(f"⚠️ LLM generation failed, using fallback for {persona_data.get('name', persona_id)}")
fallback_markdown = export_service.generate_fallback_markdown(persona_data)
return jsonify({
"success": True,
"markdown_content": fallback_markdown,
"persona_name": persona_data.get('name', 'Unknown Persona'),
"model_used": "fallback",
"warning": "Used fallback formatting due to LLM error"
}), 200
await task_manager.register_task(bg_task, 'export_profile', user_id, {'persona_id': persona_id}, task_id=task_id)
await websocket_manager.emit_to_user(user_id, 'task_started', {'task_id': task_id, 'task_type': 'export_profile'})
return jsonify({'task_id': task_id, 'message': 'Profile export started'}), 202
except Exception as e:
logger.error(f"Error in export_persona_profile: {e}")
return jsonify({"error": f"Failed to export persona profile: {str(e)}"}), 500
async def _run_export_profile_bg(app, task_id, user_id, persona_id, llm_model, temperature):
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
async with app.app_context():
try:
persona = await Persona.find_by_id(persona_id)
persona_data = make_serializable(persona)
export_service = PersonaExportService()
result = await export_service.generate_profile_markdown(
persona_data=persona_data,
llm_model=llm_model,
temperature=temperature
)
if result.get('success'):
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'export_profile',
'success': True,
'markdown_content': result['markdown_content'],
'persona_name': result['persona_name'],
'model_used': result.get('model_used')
})
else:
logger.debug(f"LLM generation failed, using fallback for persona {persona_id}")
fallback_markdown = export_service.generate_fallback_markdown(persona_data)
await websocket_manager.emit_to_user(user_id, 'task_completed', {
'task_id': task_id,
'task_type': 'export_profile',
'success': True,
'markdown_content': fallback_markdown,
'persona_name': persona_data.get('name', 'Unknown'),
'model_used': 'fallback',
'warning': 'Used fallback formatting due to LLM error'
})
except asyncio.CancelledError:
await websocket_manager.emit_to_user(user_id, 'task_cancelled', {'task_id': task_id})
except Exception as e:
logger.error(f"Unexpected error in _run_export_profile_bg: {e}")
await websocket_manager.emit_to_user(user_id, 'task_failed', {'task_id': task_id, 'message': str(e)})
@personas_bp.route('/bulk-export', methods=['POST'])
@jwt_required()
async def bulk_export_personas():

View file

@ -895,6 +895,60 @@ const DiscussionPanel = ({
}
};
// Helper: wraps focusGroupAiApi.generateResponse with 202+WebSocket async pattern
const generateResponseAsync = async (
participantId: string,
topicContext: string,
temperature?: number
): Promise<{ data: { response: string; message_id: string; persona_id: string; focus_group_id: string; timestamp?: string } } | null> => {
const response = await focusGroupAiApi.generateResponse(focusGroupId, participantId, topicContext, temperature);
const taskId = response.data?.task_id;
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();
resolve({
data: {
response: detail.response,
message_id: detail.message_id,
persona_id: detail.persona_id,
focus_group_id: detail.focus_group_id,
timestamp: detail.timestamp
}
});
};
const handleFailed = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
reject(new Error(event.detail.message || 'Generation failed'));
};
const handleCancelled = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
resolve(null);
};
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: sync response
if (response.data?.response) {
return response;
}
return response;
};
// Generate targeted responses for mentioned participants
const generateMentionedResponses = async (mentionedParticipantIds: string[], topicContext: string) => {
if (!focusGroupId || mentionedParticipantIds.length === 0) return;
@ -921,12 +975,11 @@ const DiscussionPanel = ({
try {
// Generate the response from the mentioned participant
const response = await focusGroupAiApi.generateResponse(
focusGroupId,
const response = await generateResponseAsync(
participantId,
topicContext || "Continue the conversation based on the latest moderator message."
);
if (response?.data?.response) {
const aiMessage: Message = {
id: response.data.message_id || `msg-${Date.now()}-${participantId}`,
@ -1014,17 +1067,13 @@ const DiscussionPanel = ({
});
// Generate the response from the AI-selected participant
const response = await focusGroupAiApi.generateResponse(
focusGroupId,
participantId,
topicContext
);
const response = await generateResponseAsync(participantId, topicContext);
// Check if we have a valid response before proceeding
if (!response || !response.data) {
throw new Error("Empty response from API");
}
// If the response was successful, the backend has already saved the message
if (response?.data?.message_id && response?.data?.response) {
// Create a new message object for the UI
@ -1078,12 +1127,8 @@ const DiscussionPanel = ({
const currentTopic = getLastModeratorMessage();
const personaId = nextPersona._id || nextPersona.id;
const response = await focusGroupAiApi.generateResponse(
focusGroupId,
personaId,
currentTopic
);
const response = await generateResponseAsync(personaId, currentTopic);
if (response?.data?.message_id && response?.data?.response) {
const newMessage: Message = {
id: response.data.message_id,

View file

@ -115,13 +115,51 @@ export default function PersonaModificationModal({
modificationControls.setTaskId(response.data.task_id);
}
if (response.status === 202 && response.data?.task_id) {
const taskId = response.data.task_id;
await new Promise<void>((resolve, reject) => {
const handleCompleted = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
modificationControls.completeGeneration();
toastService.success("Preview generated successfully!", {
description: `Ready to review proposed changes to ${persona.name}`
});
onPersonaPreview(event.detail.persona);
handleClose();
resolve();
};
const handleFailed = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
modificationControls.failGeneration(event.detail.message);
reject(new Error(event.detail.message));
};
const handleCancelled = (event: CustomEvent) => {
if (event.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);
});
return;
}
// Fallback: sync response
if (response.data && response.data.persona) {
modificationControls.completeGeneration();
toastService.success("Preview generated successfully!", {
description: `Ready to review proposed changes to ${persona.name}`
});
onPersonaPreview(response.data.persona);
handleClose();
} else {

View file

@ -110,14 +110,68 @@ export default function PersonaProfile() {
temperature: 0.3
});
if (response.status === 202 && response.data?.task_id) {
const taskId = response.data.task_id;
await new Promise<void>((resolve, reject) => {
const handleCompleted = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
const { markdown_content, persona_name, model_used, warning } = event.detail;
if (markdown_content) {
const currentDate = new Date().toISOString().split('T')[0];
const safePersonaName = persona_name.replace(/[^a-zA-Z0-9\-\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const filename = `${safePersonaName}-profile-${currentDate}.md`;
const element = document.createElement('a');
const file = new Blob([markdown_content], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
if (warning) {
toastService.success("Profile downloaded with fallback formatting", {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});
}
}
resolve();
};
const handleFailed = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
reject(new Error(event.detail.message));
};
const handleCancelled = (event: CustomEvent) => {
if (event.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);
});
return;
}
// Fallback: sync response
const { markdown_content, persona_name, model_used, warning } = response.data;
if (markdown_content) {
// Generate filename with current date
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const safePersonaName = persona_name.replace(/[^a-zA-Z0-9\-\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const filename = `${safePersonaName}-profile-${currentDate}.md`;
// Create and download the file
const element = document.createElement('a');
const file = new Blob([markdown_content], { type: 'text/markdown' });
@ -126,7 +180,7 @@ export default function PersonaProfile() {
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
// Show success toast
if (warning) {
toastService.success("Profile downloaded with fallback formatting", {

View file

@ -1568,11 +1568,59 @@ const FocusGroupSession = () => {
try {
const response = await focusGroupAiApi.generateKeyThemes(id);
if (response.data && response.data.themes) {
// Update themes state immediately
setThemes(prevThemes => [...prevThemes, ...response.data.themes]);
const taskId = response.data?.task_id;
if (taskId) themeGenerationControls.setTaskId(taskId);
// Allow progress to animate for at least 3 seconds before completing
if (response.status === 202 && taskId) {
await new Promise<void>((resolve) => {
const handleCompleted = (event: CustomEvent) => {
const detail = event.detail;
if (detail.task_id !== taskId) return;
cleanup();
if (detail.themes) {
setThemes(prev => [...prev, ...detail.themes]);
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.success(`Generated ${detail.themes.length} key themes`, {
description: "New themes have been added to the analysis."
});
}, 3000);
} else {
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.warning("No new themes were generated", {
description: "Try again when the discussion has more content."
});
}, 3000);
}
resolve();
};
const handleFailed = (event: CustomEvent) => {
if (event.detail.task_id !== taskId) return;
cleanup();
themeGenerationControls.failGeneration(event.detail.message || 'Failed to generate key themes');
toastService.error("Failed to generate key themes", {
description: "There was an error analyzing the discussion. Please try again."
});
resolve();
};
const handleCancelled = (event: CustomEvent) => {
if (event.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);
});
} else if (response.data && response.data.themes) {
// Fallback: sync response
setThemes(prevThemes => [...prevThemes, ...response.data.themes]);
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.success(`Generated ${response.data.themes.length} key themes`, {
@ -1580,7 +1628,6 @@ const FocusGroupSession = () => {
});
}, 3000);
} else {
// Allow progress to animate for at least 3 seconds before completing
setTimeout(() => {
themeGenerationControls.completeGeneration();
toastService.warning("No new themes were generated", {
@ -2048,7 +2095,38 @@ const FocusGroupSession = () => {
// Generate AI description and enhance the question
try {
const descriptionResponse = await focusGroupsApi.describeAsset(id, assetFilename);
const descriptionResponse = await (async () => {
const res = await focusGroupsApi.describeAsset(id, assetFilename);
if (res.status === 202 && res.data?.task_id) {
const taskId = res.data.task_id;
return new Promise<typeof res>((resolve, reject) => {
const cleanup = () => {
window.removeEventListener('ws:task_completed', onCompleted as EventListener);
window.removeEventListener('ws:task_failed', onFailed as EventListener);
window.removeEventListener('ws:task_cancelled', onCancelled as EventListener);
};
const onCompleted = (e: CustomEvent) => {
if (e.detail.task_id !== taskId) return;
cleanup();
resolve({ ...res, data: { ...res.data, description: e.detail.description } });
};
const onFailed = (e: CustomEvent) => {
if (e.detail.task_id !== taskId) return;
cleanup();
reject(new Error(e.detail.message || 'Description failed'));
};
const onCancelled = (e: CustomEvent) => {
if (e.detail.task_id !== taskId) return;
cleanup();
resolve({ ...res, data: { ...res.data, description: null } });
};
window.addEventListener('ws:task_completed', onCompleted as EventListener);
window.addEventListener('ws:task_failed', onFailed as EventListener);
window.addEventListener('ws:task_cancelled', onCancelled as EventListener);
});
}
return res;
})();
if (descriptionResponse.data.description) {
// Enhance the question text with the AI description using display reference