diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index fdb4ea6e..4dc56443 100755 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -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/', methods=['GET']) @jwt_required() diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 330de6c4..41ef5993 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -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('//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 \ No newline at end of file diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 07da5f8d..355e7d65 100755 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -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('//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(): diff --git a/src/components/focus-group-session/DiscussionPanel.tsx b/src/components/focus-group-session/DiscussionPanel.tsx index 8894bc88..50b5f56d 100755 --- a/src/components/focus-group-session/DiscussionPanel.tsx +++ b/src/components/focus-group-session/DiscussionPanel.tsx @@ -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, diff --git a/src/components/persona/PersonaModificationModal.tsx b/src/components/persona/PersonaModificationModal.tsx index 8a9958fd..d3863fdd 100755 --- a/src/components/persona/PersonaModificationModal.tsx +++ b/src/components/persona/PersonaModificationModal.tsx @@ -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((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 { diff --git a/src/components/persona/PersonaProfile.tsx b/src/components/persona/PersonaProfile.tsx index d4d9e366..b696be4d 100755 --- a/src/components/persona/PersonaProfile.tsx +++ b/src/components/persona/PersonaProfile.tsx @@ -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((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", { diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 6d014cd1..58ef6c2a 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -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((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((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