diff --git a/.DS_Store b/.DS_Store index ec5e5dcc..06b6cb84 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/.DS_Store b/backend/.DS_Store index b2dc3ede..a3aa0abb 100644 Binary files a/backend/.DS_Store and b/backend/.DS_Store differ diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 50276f30..01729c7d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -118,6 +118,7 @@ def create_app(): from app.routes.ai_personas import ai_personas_bp from app.routes.focus_group_ai import focus_group_ai_bp from app.routes.folders import folders_bp + from app.routes.tasks import tasks_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(personas_bp, url_prefix='/api/personas') @@ -125,6 +126,7 @@ def create_app(): app.register_blueprint(ai_personas_bp, url_prefix='/api/ai-personas') app.register_blueprint(focus_group_ai_bp, url_prefix='/api/focus-group-ai') app.register_blueprint(folders_bp, url_prefix='/api/folders') + app.register_blueprint(tasks_bp, url_prefix='/api/tasks') # Health check endpoint @app.route('/api/health', methods=['GET']) diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc index 6f7028e1..e2b97a41 100644 Binary files a/backend/app/__pycache__/__init__.cpython-313.pyc and b/backend/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/backend/app/models/__pycache__/focus_group.cpython-313.pyc b/backend/app/models/__pycache__/focus_group.cpython-313.pyc index f5181f78..f9d8e230 100644 Binary files a/backend/app/models/__pycache__/focus_group.cpython-313.pyc and b/backend/app/models/__pycache__/focus_group.cpython-313.pyc differ diff --git a/backend/app/models/focus_group.py b/backend/app/models/focus_group.py index 772bddf3..bc819754 100644 --- a/backend/app/models/focus_group.py +++ b/backend/app/models/focus_group.py @@ -6,6 +6,10 @@ import uuid import os import threading import eventlet +import logging + +# Set up logger for this module +logger = logging.getLogger(__name__) async def emit_websocket_event(event_name: str, focus_group_id: str, data: dict): """Helper function to emit WebSocket events using async WebSocket manager.""" @@ -116,9 +120,6 @@ class FocusGroup: @staticmethod async def get_all(limit=50): - import logging - logger = logging.getLogger('app.focus_group_model') - try: logger.debug(f"=== FocusGroup.get_all() called with limit={limit} ===") db = await get_db() diff --git a/backend/app/routes/__pycache__/ai_personas.cpython-313.pyc b/backend/app/routes/__pycache__/ai_personas.cpython-313.pyc index 72fcafbf..112127a6 100644 Binary files a/backend/app/routes/__pycache__/ai_personas.cpython-313.pyc and b/backend/app/routes/__pycache__/ai_personas.cpython-313.pyc differ diff --git a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc index e416ec31..d55458d6 100644 Binary files a/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc and b/backend/app/routes/__pycache__/focus_group_ai.cpython-313.pyc differ diff --git a/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc b/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc index 0aad1eb9..f186e389 100644 Binary files a/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc and b/backend/app/routes/__pycache__/focus_groups.cpython-313.pyc differ diff --git a/backend/app/routes/__pycache__/personas.cpython-313.pyc b/backend/app/routes/__pycache__/personas.cpython-313.pyc index 4e303915..89f68d86 100644 Binary files a/backend/app/routes/__pycache__/personas.cpython-313.pyc and b/backend/app/routes/__pycache__/personas.cpython-313.pyc differ diff --git a/backend/app/routes/ai_personas.py b/backend/app/routes/ai_personas.py index d5383c09..79d5aba1 100644 --- a/backend/app/routes/ai_personas.py +++ b/backend/app/routes/ai_personas.py @@ -18,6 +18,7 @@ from app.services.ai_persona_service import ( enhance_audience_brief, PersonaGenerationError ) +from app.services.task_manager import register_cancellable_task, CancellableTask from app.services.customer_data_service import customer_data_service, CustomerDataServiceError from app.models.persona import Persona @@ -61,36 +62,42 @@ async def generate_basic_profiles(): if count < 1 or count > 10: # Limit the number for performance reasons return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 - temperature = data.get('temperature', 0.8) - if not (0 <= temperature <= 1): - temperature = 0.8 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default try: - # Log the request with model information - print(f"🔄 Backend: Received generate-basic-profiles request with model: {llm_model}") - current_app.logger.info(f"Generating {count} basic profiles using model: {llm_model}") - - # Generate basic profiles - basic_profiles = await generate_basic_personas( - audience_brief=audience_brief, - research_objective=research_objective, - count=count, - temperature=temperature, - customer_data_session_id=customer_data_session_id, - llm_model=llm_model - ) - - # Log successful generation - print(f"✅ Backend: Successfully generated {len(basic_profiles)} basic profiles using model: {llm_model}") - - return jsonify({ - "message": f"Successfully generated {len(basic_profiles)} basic persona profiles using {llm_model}", - "profiles": basic_profiles - }), 200 + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "basic_profiles"}) as task_id: + # Log the request with model information + print(f"🔄 Backend: Received generate-basic-profiles request with model: {llm_model}") + current_app.logger.info(f"Generating {count} basic profiles using model: {llm_model}") + + # Generate basic profiles + basic_profiles = await generate_basic_personas( + audience_brief=audience_brief, + research_objective=research_objective, + count=count, + temperature=temperature, + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + # Log successful generation + print(f"✅ Backend: Successfully generated {len(basic_profiles)} basic profiles using model: {llm_model}") + + return jsonify({ + "message": f"Successfully generated {len(basic_profiles)} basic persona profiles using {llm_model}", + "profiles": basic_profiles, + "task_id": task_id + }), 200 + except asyncio.CancelledError: + current_app.logger.info(f"Basic profiles generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Basic profiles generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"Basic profiles generation error: {str(e)}") return jsonify({"error": "Failed to generate basic profiles", "message": str(e)}), 500 @@ -129,9 +136,9 @@ async def complete_persona(): if not basic_profile: return jsonify({"error": "Missing basic profile", "message": "Basic profile is required"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Complete the persona @@ -185,9 +192,9 @@ async def complete_and_save_persona(): if not basic_profile: return jsonify({"error": "Missing basic profile", "message": "Basic profile is required"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default @@ -313,9 +320,9 @@ async def generate_ai_persona(): ) # Set temperature if provided - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 # Generate the persona persona_data = await generate_persona( @@ -361,9 +368,9 @@ async def generate_and_save_persona(): ) # Set temperature if provided - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 # Generate the persona persona_data = await generate_persona( @@ -435,7 +442,7 @@ async def batch_generate_personas(): } Returns: - A JSON object containing an array of generated personas + A JSON object containing an array of generated personas and task_id for cancellation """ user_id = get_jwt_identity() data = await request.get_json() or {} @@ -445,55 +452,68 @@ async def batch_generate_personas(): return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 customizations = data.get('customizations', []) - temperature = data.get('temperature', 0.7) + temperature = data.get('temperature', 1.0) try: - # Prepare customization prompts for each persona - generation_tasks = [] - for i in range(count): - # Use a customization if available for this index - custom_prompt = None - if i < len(customizations): - custom_data = customizations[i] - custom_prompt = customize_persona_prompt( - age_range=custom_data.get('age_range'), - gender=custom_data.get('gender'), - occupation_type=custom_data.get('occupation_type'), - education_level=custom_data.get('education_level'), - location_type=custom_data.get('location_type'), - personality_traits=custom_data.get('personality_traits'), - interests=custom_data.get('interests'), - audience_brief=custom_data.get('audience_brief') - ) + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "batch"}) as task_id: + # Prepare customization prompts for each persona + generation_tasks = [] + for i in range(count): + # Check for cancellation before each persona setup + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Use a customization if available for this index + custom_prompt = None + if i < len(customizations): + custom_data = customizations[i] + custom_prompt = customize_persona_prompt( + age_range=custom_data.get('age_range'), + gender=custom_data.get('gender'), + occupation_type=custom_data.get('occupation_type'), + education_level=custom_data.get('education_level'), + location_type=custom_data.get('location_type'), + personality_traits=custom_data.get('personality_traits'), + interests=custom_data.get('interests'), + audience_brief=custom_data.get('audience_brief') + ) + + # Add to the list of tasks to be executed + generation_tasks.append({ + 'prompt_customization': custom_prompt, + 'temperature': temperature + }) + + # Generate personas using asyncio.gather for concurrent async execution + try: + generation_coroutines = [ + generate_persona( + task['prompt_customization'], + None, # No basic_persona for this endpoint + task['temperature'] + ) for task in generation_tasks + ] + + # Execute all persona generations concurrently + personas = await asyncio.gather(*generation_coroutines) + + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except Exception as exc: + current_app.logger.error(f"Persona generation task failed with error: {exc}") + raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - # Add to the list of tasks to be executed - generation_tasks.append({ - 'prompt_customization': custom_prompt, - 'temperature': temperature - }) - - # Generate personas using asyncio.gather for concurrent async execution - try: - generation_coroutines = [ - generate_persona( - task['prompt_customization'], - None, # No basic_persona for this endpoint - task['temperature'] - ) for task in generation_tasks - ] - - # Execute all persona generations concurrently - personas = await asyncio.gather(*generation_coroutines) - - except Exception as exc: - current_app.logger.error(f"Persona generation task failed with error: {exc}") - raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - - return jsonify({ - "message": f"Successfully generated {len(personas)} personas", - "personas": personas - }), 200 + return jsonify({ + "message": f"Successfully generated {len(personas)} personas", + "personas": personas, + "task_id": task_id + }), 200 + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"AI Persona batch generation error: {str(e)}") return jsonify({"error": "Failed to generate personas", "message": str(e)}), 500 @@ -511,7 +531,7 @@ async def batch_generate_and_save_personas(): Request body format is the same as batch-generate endpoint. Returns: - A JSON object containing the array of generated and saved personas with their IDs + A JSON object containing the array of generated and saved personas with their IDs and task_id """ user_id = get_jwt_identity() data = await request.get_json() or {} @@ -521,91 +541,108 @@ async def batch_generate_and_save_personas(): return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 customizations = data.get('customizations', []) - temperature = data.get('temperature', 0.7) + temperature = data.get('temperature', 1.0) try: - # Prepare customization prompts for each persona - generation_tasks = [] - for i in range(count): - # Use a customization if available for this index - custom_prompt = None - if i < len(customizations): - custom_data = customizations[i] - custom_prompt = customize_persona_prompt( - age_range=custom_data.get('age_range'), - gender=custom_data.get('gender'), - occupation_type=custom_data.get('occupation_type'), - education_level=custom_data.get('education_level'), - location_type=custom_data.get('location_type'), - personality_traits=custom_data.get('personality_traits'), - interests=custom_data.get('interests'), - audience_brief=custom_data.get('audience_brief') - ) + # Register current task for cancellation + async with CancellableTask("persona_generation", user_id, {"count": count, "type": "batch_and_save"}) as task_id: + # Prepare customization prompts for each persona + generation_tasks = [] + for i in range(count): + # Check for cancellation before each persona setup + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Use a customization if available for this index + custom_prompt = None + if i < len(customizations): + custom_data = customizations[i] + custom_prompt = customize_persona_prompt( + age_range=custom_data.get('age_range'), + gender=custom_data.get('gender'), + occupation_type=custom_data.get('occupation_type'), + education_level=custom_data.get('education_level'), + location_type=custom_data.get('location_type'), + personality_traits=custom_data.get('personality_traits'), + interests=custom_data.get('interests'), + audience_brief=custom_data.get('audience_brief') + ) + + # Add to the list of tasks to be executed + generation_tasks.append({ + 'prompt_customization': custom_prompt, + 'temperature': temperature + }) - # Add to the list of tasks to be executed - generation_tasks.append({ - 'prompt_customization': custom_prompt, - 'temperature': temperature - }) - - # Generate personas using asyncio.gather for concurrent async execution - try: - generation_coroutines = [ - generate_persona( - task['prompt_customization'], - None, # No basic_persona for this endpoint - task['temperature'] - ) for task in generation_tasks - ] - - # Execute all persona generations concurrently - generated_personas = await asyncio.gather(*generation_coroutines) - - except Exception as exc: - current_app.logger.error(f"Persona generation task failed with error: {exc}") - raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - - # Save all generated personas to the database - personas = [] - persona_ids = [] - for persona_data in generated_personas: - # Generate AI summary for each persona + # Generate personas using asyncio.gather for concurrent async execution try: - summary_data = await generate_persona_summary( - persona_data=persona_data, - temperature=temperature - ) + generation_coroutines = [ + generate_persona( + task['prompt_customization'], + None, # No basic_persona for this endpoint + task['temperature'] + ) for task in generation_tasks + ] - # Add summary fields to the persona data - persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] - persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] - persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + # Execute all persona generations concurrently + generated_personas = await asyncio.gather(*generation_coroutines) - print(f"Generated summary for persona {persona_data.get('name', 'Unknown')}") - - except Exception as summary_error: - # Log the error but don't fail the entire persona creation - current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") - print(f"Warning: Could not generate summary for persona: {str(summary_error)}") + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation and save cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except Exception as exc: + current_app.logger.error(f"Persona generation task failed with error: {exc}") + raise PersonaGenerationError(f"Failed to generate one of the personas: {str(exc)}") - # Remove generated ID before saving - if 'id' in persona_data: - del persona_data['id'] + # Save all generated personas to the database + personas = [] + persona_ids = [] + for persona_data in generated_personas: + # Check for cancellation before processing each persona + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") - # Save to database - persona_id = await Persona.create(persona_data, user_id) + # Generate AI summary for each persona + try: + summary_data = await generate_persona_summary( + persona_data=persona_data, + temperature=temperature + ) + + # Add summary fields to the persona data + persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] + persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] + persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + + print(f"Generated summary for persona {persona_data.get('name', 'Unknown')}") + + except Exception as summary_error: + # Log the error but don't fail the entire persona creation + current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") + print(f"Warning: Could not generate summary for persona: {str(summary_error)}") + + # Remove generated ID before saving + if 'id' in persona_data: + del persona_data['id'] + + # Save to database + persona_id = await Persona.create(persona_data, user_id) + + # Add database ID to the response + persona_data['_id'] = str(persona_id) + personas.append(persona_data) + persona_ids.append(str(persona_id)) - # Add database ID to the response - persona_data['_id'] = str(persona_id) - personas.append(persona_data) - persona_ids.append(str(persona_id)) - - return jsonify({ - "message": f"Successfully generated and saved {len(personas)} personas", - "personas": personas, - "persona_ids": persona_ids - }), 201 + return jsonify({ + "message": f"Successfully generated and saved {len(personas)} personas", + "personas": personas, + "persona_ids": persona_ids, + "task_id": task_id + }), 201 + except asyncio.CancelledError: + current_app.logger.info(f"Batch persona generation and save cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 except PersonaGenerationError as e: current_app.logger.error(f"AI Persona batch generation and save error: {str(e)}") return jsonify({"error": "Failed to generate and save personas", "message": str(e)}), 500 @@ -655,9 +692,9 @@ async def generate_summary_for_persona(): "message": f"Persona data is missing required fields: {', '.join(missing_fields)}" }), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Generate the summary @@ -717,9 +754,9 @@ async def enhance_audience_brief_endpoint(): if len(research_objective.strip()) < 10: return jsonify({"error": "Research objective too short", "message": "Research objective must be at least 10 characters long"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 try: # Generate enhancement suggestions @@ -775,9 +812,9 @@ async def batch_generate_summaries(): print(f"❌ Backend: Invalid persona_ids type: {type(persona_ids)}") return jsonify({"error": "Invalid persona IDs", "message": "persona_ids must be an array"}), 400 - temperature = data.get('temperature', 0.7) - if not (0 <= temperature <= 1): - temperature = 0.7 + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 llm_model = data.get('llm_model', 'gemini-2.5-pro') # Optional parameter with default @@ -786,114 +823,168 @@ async def batch_generate_summaries(): current_app.logger.info(f"Batch generating summaries for {len(persona_ids)} personas using model: {llm_model}") try: - # Fetch all persona data first - personas_data = [] - missing_personas = [] - - for persona_id in persona_ids: - try: - persona = await Persona.find_by_id(persona_id) - if persona: - personas_data.append(persona) - else: - missing_personas.append(persona_id) - except Exception as e: - current_app.logger.warning(f"Failed to fetch persona {persona_id}: {str(e)}") - missing_personas.append(persona_id) - - if not personas_data: - return jsonify({ - "error": "No valid personas found", - "message": "None of the provided persona IDs could be found" - }), 404 - - # Process personas in batches of 10 - batch_size = 10 - successful_summaries = [] - failed_summaries = [] - - async def process_persona_summary(persona_data): - """Helper function to process a single persona summary""" - try: - persona_name = persona_data.get('name', 'Unknown') - print(f"✅ Backend: Successfully generated summary for '{persona_name}' using model: {llm_model}") - - summary = await generate_persona_download_summary( - persona_data=persona_data, - temperature=temperature, - llm_model=llm_model + # Register current task for cancellation + async with CancellableTask("persona_summary_generation", user_id, {"count": len(persona_ids), "type": "batch_summaries"}) 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_summary_generation', + 'message': f'Started generating summaries for {len(persona_ids)} personas' + } ) - return { - 'success': True, - 'persona_id': persona_data.get('_id', persona_data.get('id')), - 'persona_name': persona_data.get('name', 'Unknown'), - 'summary': summary - } - except Exception as e: - return { - 'success': False, - 'persona_id': persona_data.get('_id', persona_data.get('id')), - 'persona_name': persona_data.get('name', 'Unknown'), - 'error': str(e) - } - - # Process in batches - for i in range(0, len(personas_data), batch_size): - batch = personas_data[i:i + batch_size] - current_app.logger.info(f"Processing batch {i//batch_size + 1}: {len(batch)} personas") - # Process this batch using asyncio - batch_tasks = [process_persona_summary(persona) for persona in batch] - batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) + # Fetch all persona data first + personas_data = [] + missing_personas = [] - # Collect results - for result in batch_results: - if isinstance(result, Exception): - failed_summaries.append({ + for persona_id in persona_ids: + # Check for cancellation before each persona fetch + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + try: + persona = await Persona.find_by_id(persona_id) + if persona: + personas_data.append(persona) + else: + missing_personas.append(persona_id) + except Exception as e: + current_app.logger.warning(f"Failed to fetch persona {persona_id}: {str(e)}") + missing_personas.append(persona_id) + + if not personas_data: + return jsonify({ + "error": "No valid personas found", + "message": "None of the provided persona IDs could be found" + }), 404 + + # Process personas in batches of 10 + batch_size = 10 + successful_summaries = [] + failed_summaries = [] + + async def process_persona_summary(persona_data): + """Helper function to process a single persona summary""" + try: + # Check for cancellation before processing each summary + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + persona_name = persona_data.get('name', 'Unknown') + print(f"✅ Backend: Successfully generated summary for '{persona_name}' using model: {llm_model}") + + summary = await generate_persona_download_summary( + persona_data=persona_data, + temperature=temperature, + llm_model=llm_model + ) + return { + 'success': True, + 'persona_id': persona_data.get('_id', persona_data.get('id')), + 'persona_name': persona_data.get('name', 'Unknown'), + 'summary': summary + } + except asyncio.CancelledError: + raise # Re-raise cancellation + except Exception as e: + return { 'success': False, - 'error': str(result), - 'persona_name': 'Unknown' - }) - current_app.logger.error(f"Failed to generate summary: {result}") - elif result['success']: - successful_summaries.append(result) - else: - failed_summaries.append(result) - current_app.logger.error(f"Failed to generate summary for persona {result['persona_name']}: {result['error']}") - - # Prepare response - total_requested = len(persona_ids) - total_found = len(personas_data) - total_successful = len(successful_summaries) - total_failed = len(failed_summaries) + len(missing_personas) - - response_data = { - "message": f"Processed {total_successful} of {total_requested} personas successfully", - "summary_stats": { - "total_requested": total_requested, - "total_found": total_found, - "total_successful": total_successful, - "total_failed": total_failed, - "missing_personas": len(missing_personas) - }, - "summaries": successful_summaries - } - - # Add error details if there were failures - if failed_summaries or missing_personas: - response_data["errors"] = { - "failed_summaries": failed_summaries, - "missing_personas": missing_personas + 'persona_id': persona_data.get('_id', persona_data.get('id')), + 'persona_name': persona_data.get('name', 'Unknown'), + 'error': str(e) + } + + # Process in batches + for i in range(0, len(personas_data), batch_size): + # Check for cancellation before each batch + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + batch = personas_data[i:i + batch_size] + current_app.logger.info(f"Processing batch {i//batch_size + 1}: {len(batch)} personas") + + # Process this batch using asyncio + batch_tasks = [process_persona_summary(persona) for persona in batch] + try: + batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True) + except asyncio.CancelledError: + raise # Re-raise cancellation + + # Collect results + for result in batch_results: + if isinstance(result, Exception): + if isinstance(result, asyncio.CancelledError): + raise result # Re-raise cancellation + failed_summaries.append({ + 'success': False, + 'error': str(result), + 'persona_name': 'Unknown' + }) + current_app.logger.error(f"Failed to generate summary: {result}") + elif result['success']: + successful_summaries.append(result) + else: + failed_summaries.append(result) + current_app.logger.error(f"Failed to generate summary for persona {result['persona_name']}: {result['error']}") + + # Prepare response + total_requested = len(persona_ids) + total_found = len(personas_data) + total_successful = len(successful_summaries) + total_failed = len(failed_summaries) + len(missing_personas) + + response_data = { + "message": f"Processed {total_successful} of {total_requested} personas successfully", + "summary_stats": { + "total_requested": total_requested, + "total_found": total_found, + "total_successful": total_successful, + "total_failed": total_failed, + "missing_personas": len(missing_personas) + }, + "summaries": successful_summaries, + "task_id": task_id } + + # Add error details if there were failures + if failed_summaries or missing_personas: + response_data["errors"] = { + "failed_summaries": failed_summaries, + "missing_personas": missing_personas + } + + # 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_summary_generation', + 'message': f'Successfully generated summaries for {total_successful} of {total_requested} personas', + 'summaries_created': total_successful, + 'errors': total_failed + } + ) + + # Determine appropriate status code + if total_successful == 0: + return jsonify(response_data), 500 # Complete failure + elif total_successful < total_requested: + return jsonify(response_data), 206 # Partial success + else: + return jsonify(response_data), 200 # Complete success - # Determine appropriate status code - if total_successful == 0: - return jsonify(response_data), 500 # Complete failure - elif total_successful < total_requested: - return jsonify(response_data), 206 # Partial success - else: - return jsonify(response_data), 200 # Complete success - + except asyncio.CancelledError: + current_app.logger.info(f"Batch summary generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Batch summary generation was cancelled by user"}), 499 except PersonaGenerationError as e: print(f"❌ Backend: Batch summary generation error: {str(e)}") current_app.logger.error(f"Batch summary generation error: {str(e)}") @@ -906,6 +997,257 @@ async def batch_generate_summaries(): return jsonify({"error": "Internal server error", "message": f"An unexpected error occurred: {str(e)}"}), 500 +@ai_personas_bp.route('/generate-personas-full', methods=['POST']) +@jwt_required() +async def generate_personas_full(): + """ + Unified endpoint for complete persona generation with cancellation support. + + Combines the two-stage generation process (basic profiles + completion) into a single + endpoint with proper task management and cancellation checkpoints. + + Request body: + { + "audience_brief": "A detailed description of the audience context...", + "research_objective": "Optional research objective...", + "count": 5, + "temperature": 0.8, + "customer_data_session_id": "optional_session_id", + "llm_model": "gemini-2.5-pro", + "target_folder_id": "optional_folder_id" + } + + Returns: + JSON object with task_id and generated personas (when complete) + """ + user_id = get_jwt_identity() + data = await request.get_json() or {} + + # Extract and validate parameters + audience_brief = data.get('audience_brief') + if not audience_brief or len(audience_brief.strip()) < 10: + return jsonify({"error": "Missing or invalid audience brief", "message": "Audience brief must be at least 10 characters"}), 400 + + research_objective = data.get('research_objective') + count = data.get('count', 5) + if count < 1 or count > 10: + return jsonify({"error": "Invalid count", "message": "Count must be between 1 and 10"}), 400 + + temperature = data.get('temperature', 1.0) + if not (0 <= temperature <= 1.5): + temperature = 1.0 + + customer_data_session_id = data.get('customer_data_session_id') + llm_model = data.get('llm_model', 'gemini-2.5-pro') + target_folder_id = data.get('target_folder_id') + + try: + # Register current task for cancellation with comprehensive metadata + async with CancellableTask("persona_full_generation", user_id, { + "count": count, + "type": "unified_generation", + "llm_model": llm_model, + "target_folder_id": target_folder_id + }) as task_id: + + current_app.logger.info(f"Starting unified persona generation for {count} personas using {llm_model}") + print(f"🔄 Backend: Unified generation started - Task ID: {task_id}") + + # Return task_id immediately so frontend can start cancellation 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_full_generation', + 'message': f'Started generating {count} personas' + } + ) + + # Stage 1: Generate basic profiles with cancellation check + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + print(f"🔄 Backend: Stage 1 - Generating {count} basic profiles...") + basic_profiles = await generate_basic_personas( + audience_brief=audience_brief, + research_objective=research_objective, + count=count, + temperature=temperature, # Use temperature from request + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + print(f"✅ Backend: Stage 1 complete - {len(basic_profiles)} basic profiles generated") + + # Stage 2: Complete personas in parallel with cancellation support + completed_personas = [] + failed_personas = [] + + print(f"🔄 Backend: Stage 2 - Completing {len(basic_profiles)} personas in parallel...") + + async def complete_single_persona(i: int, basic_profile: dict): + """Complete a single persona with cancellation checks.""" + try: + # Check for cancellation before starting + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + print(f"🔄 Backend: Completing persona {i+1}/{len(basic_profiles)}: {basic_profile.get('name', 'Unknown')}") + + # Complete the persona + persona_data = await generate_persona( + basic_persona=basic_profile, + temperature=temperature, + customer_data_session_id=customer_data_session_id, + llm_model=llm_model + ) + + # Check cancellation before summary generation + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Generate AI summary + try: + summary_data = await generate_persona_summary( + persona_data=persona_data, + temperature=temperature, + llm_model=llm_model + ) + + persona_data['aiSynthesizedBio'] = summary_data['aiSynthesizedBio'] + persona_data['qualitativeAttributes'] = summary_data['qualitativeAttributes'] + persona_data['topPersonalityTraits'] = summary_data['topPersonalityTraits'] + + except Exception as summary_error: + current_app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") + + # Add generation prompts + if audience_brief: + persona_data['audience_brief'] = audience_brief + if research_objective: + persona_data['research_objective'] = research_objective + + # Check cancellation before database save + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Remove generated ID before saving + if 'id' in persona_data: + del persona_data['id'] + + # Save to database + persona_id = await Persona.create(persona_data, user_id) + persona_data['_id'] = str(persona_id) + + print(f"✅ Backend: Persona {i+1}/{len(basic_profiles)} completed: {persona_data.get('name')}") + return {'success': True, 'persona': persona_data, 'index': i} + + except asyncio.CancelledError: + raise # Re-raise cancellation + except Exception as persona_error: + print(f"❌ Backend: Failed to complete persona {i+1}: {persona_error}") + return { + 'success': False, + 'index': i, + 'name': basic_profile.get('name', f'Persona {i+1}'), + 'error': str(persona_error) + } + + # Execute all persona completions in parallel + try: + completion_tasks = [ + complete_single_persona(i, profile) + for i, profile in enumerate(basic_profiles) + ] + + # Wait for all personas to complete or fail + results = await asyncio.gather(*completion_tasks, return_exceptions=True) + + # Process results + for result in results: + if isinstance(result, asyncio.CancelledError): + raise result # Re-raise cancellation + elif isinstance(result, Exception): + failed_personas.append({ + 'index': -1, + 'name': 'Unknown', + 'error': str(result) + }) + elif result['success']: + completed_personas.append(result['persona']) + else: + failed_personas.append({ + 'index': result['index'], + 'name': result['name'], + 'error': result['error'] + }) + + except asyncio.CancelledError: + raise # Re-raise cancellation + + print(f"✅ Backend: Stage 2 complete - {len(completed_personas)} personas completed, {len(failed_personas)} failed") + + # Check cancellation before folder assignment + if asyncio.current_task().cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + # Add personas to target folder if specified + if target_folder_id and completed_personas: + try: + from app.models.folder import Folder + persona_ids = [p['_id'] for p in completed_personas] + await Folder.add_personas_batch(target_folder_id, persona_ids) + print(f"✅ Backend: Added {len(persona_ids)} personas to folder {target_folder_id}") + except Exception as folder_error: + current_app.logger.warning(f"Failed to add personas to folder: {folder_error}") + + # Clean up customer data if provided + if customer_data_session_id: + try: + customer_data_service.cleanup_session(customer_data_session_id) + except Exception as cleanup_error: + current_app.logger.warning(f"Failed to cleanup customer data: {cleanup_error}") + + print(f"🎉 Backend: Unified generation complete - {len(completed_personas)} personas created") + + # 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_full_generation', + 'message': f'Successfully generated {len(completed_personas)} personas', + 'personas_created': len(completed_personas), + 'errors': len(failed_personas) + } + ) + + return jsonify({ + "message": f"Successfully generated and saved {len(completed_personas)} personas using {llm_model}", + "personas": completed_personas, + "persona_ids": [p['_id'] for p in completed_personas], + "task_id": task_id, + "errors": failed_personas if failed_personas else None, + "partial_success": len(failed_personas) > 0 and len(completed_personas) > 0 + }), 201 + + except asyncio.CancelledError: + current_app.logger.info(f"Unified persona generation cancelled for user {user_id}") + return jsonify({"error": "Generation cancelled", "message": "Persona generation was cancelled by user"}), 499 + except PersonaGenerationError as e: + current_app.logger.error(f"Unified persona generation error: {str(e)}") + return jsonify({"error": "Failed to generate personas", "message": str(e)}), 500 + except Exception as e: + current_app.logger.error(f"Unexpected error in unified persona generation: {str(e)}") + return jsonify({"error": "Internal server error", "message": "An unexpected error occurred"}), 500 + + @ai_personas_bp.route('/upload-customer-data', methods=['POST']) @jwt_required() async def upload_customer_data(): diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 67014c70..3c2df9df 100644 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -9,6 +9,7 @@ from app.auth.quart_jwt import jwt_required, get_jwt_identity from typing import Dict, List, Any import time import concurrent.futures +import asyncio from app.services.focus_group_response_service import ( generate_persona_response, @@ -20,6 +21,7 @@ from app.services.key_theme_service import ( KeyThemeService, KeyThemeServiceError ) +from app.services.task_manager import CancellableTask from app.services.ai_moderator_service import AIModeratorService from app.services.autonomous_conversation_controller import AutonomousConversationController from app.services.conversation_decision_service import ConversationDecisionService, ConversationDecisionError @@ -299,55 +301,99 @@ async def generate_key_themes(): if not focus_group: return jsonify({"error": "Focus group not found"}), 404 - # Get the LLM model for this focus group - llm_model = focus_group.get('llm_model') - - # Generate key themes + # Get user_id for task tracking (optional for development mode) + user_id = None try: - themes = await KeyThemeService.generate_key_themes( - focus_group_id=focus_group_id, - temperature=temperature, - llm_model=llm_model - ) + user_id = get_jwt_identity() + except: + pass # JWT is optional in development + + # Register current task for cancellation + async with CancellableTask("key_themes_generation", user_id, {"focus_group_id": focus_group_id}) as task_id: - # Log success - current_app.logger.info(f"Generated {len(themes)} key themes for focus group {focus_group_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}' + } + ) - # Save themes to database - theme_ids = await FocusGroup.add_generated_themes(focus_group_id, themes) + # Get the LLM model for this focus group + llm_model = focus_group.get('llm_model') - if not theme_ids: - current_app.logger.error("Failed to save themes to database") + # 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({ - "error": "Failed to save themes", - "message": "The themes were generated but could not be saved to the database" + "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 - # 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" - }) - - return jsonify({ - "message": "Key themes generated successfully", - "themes": formatted_themes, - "focus_group_id": focus_group_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 except Exception as e: current_app.logger.error(f"Unexpected error in key theme generation: {str(e)}") return jsonify({ diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 03af4e6e..d581fdd2 100644 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -4,10 +4,12 @@ from app.models.focus_group import FocusGroup from app.models.persona import Persona from app.services.focus_group_service import FocusGroupService from app.services.image_description_service import ImageDescriptionService, ImageDescriptionError +from app.services.task_manager import CancellableTask from bson import ObjectId import datetime import json import os +import asyncio import uuid import tempfile from werkzeug.utils import secure_filename @@ -806,53 +808,98 @@ async def generate_discussion_guide(focus_group_id=None): logger.info(f"Generating discussion guide for: '{focus_group_name}' (duration: {duration}min)") - # Add topic as a discussion topic if not already there - if discussion_topics and isinstance(discussion_topics, str): - # Convert to a specific topic if it's from the selection dropdown - topic_mapping = { - 'product-feedback': 'Product Feedback', - 'creative-testing': 'Creative Testing', - 'messaging-evaluation': 'Messaging Evaluation', - 'user-experience': 'User Experience', - 'market-research': 'Market Research' - } - formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) - else: - formatted_topic = 'General Discussion' + # Get user_id for task tracking (optional for development mode) + user_id = None + try: + user_id = get_jwt_identity() + except: + pass # JWT is optional in development - # Get the LLM model for this focus group if it exists - llm_model = None - if focus_group_id: - try: - focus_group = await FocusGroup.find_by_id(focus_group_id) - if focus_group: - llm_model = focus_group.get('llm_model') - logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}") - except Exception as e: - logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}") + # Register current task for cancellation + async with CancellableTask("discussion_guide_generation", user_id, {"focus_group_name": focus_group_name, "focus_group_id": focus_group_id}) as task_id: + + # Emit task_started event via WebSocket for immediate frontend tracking + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_started', + { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Started generating discussion guide for {focus_group_name}' + } + ) + + # Add topic as a discussion topic if not already there + if discussion_topics and isinstance(discussion_topics, str): + # Convert to a specific topic if it's from the selection dropdown + topic_mapping = { + 'product-feedback': 'Product Feedback', + 'creative-testing': 'Creative Testing', + 'messaging-evaluation': 'Messaging Evaluation', + 'user-experience': 'User Experience', + 'market-research': 'Market Research' + } + formatted_topic = topic_mapping.get(discussion_topics, discussion_topics) + else: + formatted_topic = 'General Discussion' + + # Get the LLM model for this focus group if it exists + llm_model = None + if focus_group_id: + try: + focus_group = await FocusGroup.find_by_id(focus_group_id) + if focus_group: + llm_model = focus_group.get('llm_model') + logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}") + except Exception as e: + logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}") + + # Use default model from request data if provided + if not llm_model: + llm_model = data.get('llm_model') + + # Generate the discussion guide + discussion_guide = await FocusGroupService.generate_discussion_guide( + focus_group_name=focus_group_name, + research_brief=research_brief, + discussion_topics=formatted_topic, + duration=duration, + temperature=0.7, + focus_group_id=focus_group_id, + llm_model=llm_model + ) + + # Emit completion event via WebSocket + if user_id: + await websocket_manager.emit_to_user( + user_id, + 'task_completed', + { + 'task_id': task_id, + 'task_type': 'discussion_guide_generation', + 'message': f'Successfully generated discussion guide for {focus_group_name}' + } + ) + + logger.info(f"Discussion guide successfully generated for '{focus_group_name}'") + return jsonify({ + "message": "Discussion guide generated successfully", + "discussionGuide": discussion_guide, + "success": True, + "task_id": task_id + }), 200 - # Use default model from request data if provided - if not llm_model: - llm_model = data.get('llm_model') - - # Generate the discussion guide - discussion_guide = await FocusGroupService.generate_discussion_guide( - focus_group_name=focus_group_name, - research_brief=research_brief, - discussion_topics=formatted_topic, - duration=duration, - temperature=0.7, - focus_group_id=focus_group_id, - llm_model=llm_model - ) - - logger.info(f"Discussion guide successfully generated for '{focus_group_name}'") + except asyncio.CancelledError: + logger.info(f"Discussion guide generation cancelled for focus group: {data.get('name', 'Unknown') if 'data' in locals() else 'Unknown'}") return jsonify({ - "message": "Discussion guide generated successfully", - "discussionGuide": discussion_guide, - "success": True - }), 200 - + "error": "Generation cancelled", + "details": "Discussion guide generation was cancelled by user", + "can_retry": True, + "error_type": "cancelled" + }), 499 except Exception as e: error_msg = str(e) logger.error(f"Discussion guide generation failed with error: {error_msg}") diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 8d70b67e..f529ad51 100644 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -3,8 +3,10 @@ from app.auth.quart_jwt import jwt_required, get_jwt_identity from app.models.persona import Persona from app.services.persona_export_service import PersonaExportService from app.services.persona_modification_service import PersonaModificationService, PersonaModificationError +from app.services.task_manager import CancellableTask from bson import ObjectId import datetime +import asyncio # Helper function to make MongoDB documents JSON serializable def make_serializable(obj): @@ -186,24 +188,67 @@ async def modify_persona_with_ai(persona_id): print(f"🤖 Backend: {mode_text.title()} persona {persona_id} with {llm_model}") print(f"📝 Modification prompt: {modification_prompt[:100]}...") - # Call the modification service - modified_persona_data = await PersonaModificationService.modify_persona( - persona_id=persona_id, - modification_prompt=modification_prompt, - llm_model=llm_model, - reasoning_effort=reasoning_effort, - verbosity=verbosity, - preview_only=preview_only - ) + # Get user_id for task tracking (optional for development mode) + user_id = None + try: + user_id = get_jwt_identity() + except: + pass # JWT is optional in development - success_message = "Persona preview generated successfully" if preview_only else "Persona modified successfully" + # 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 + modified_persona_data = await PersonaModificationService.modify_persona( + persona_id=persona_id, + modification_prompt=modification_prompt, + llm_model=llm_model, + reasoning_effort=reasoning_effort, + 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: + print(f"⏹️ Persona modification cancelled for persona {persona_id}") return jsonify({ - "success": True, - "message": success_message, - "persona": make_serializable(modified_persona_data), - "preview_only": preview_only - }), 200 - + "error": "Generation cancelled", + "message": "Persona modification was cancelled by user" + }), 499 except PersonaModificationError as e: print(f"❌ Persona modification error: {e}") return jsonify({"error": str(e)}), 400 diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py new file mode 100644 index 00000000..b6ef11e8 --- /dev/null +++ b/backend/app/routes/tasks.py @@ -0,0 +1,129 @@ +""" +Task management routes for handling cancellable operations. +""" + +from quart import Blueprint, jsonify, request +from app.services.task_manager import get_task_manager +from app.websocket_manager_async import get_async_websocket_manager +import logging + +logger = logging.getLogger(__name__) + +tasks_bp = Blueprint('tasks', __name__) + + +@tasks_bp.route('/', methods=['DELETE']) +async def cancel_task(task_id: str): + """ + Cancel a running task by its ID. + + Args: + task_id: The unique identifier of the task to cancel + + Returns: + JSON response indicating success or failure + """ + try: + task_manager = get_task_manager() + + # Get task info before cancellation for WebSocket notification + task_info = await task_manager.get_task_info(task_id) + + # Attempt to cancel the task + cancelled = await task_manager.cancel_task(task_id) + + if not cancelled: + return jsonify({ + 'error': 'Task not found or already completed', + 'task_id': task_id + }), 404 + + # Send WebSocket notification about cancellation + websocket_manager = get_async_websocket_manager() + if task_info and task_info.user_id: + await websocket_manager.emit_to_user( + task_info.user_id, + 'task_cancelled', + { + 'task_id': task_id, + 'task_type': task_info.task_type, + 'message': f'{task_info.task_type.replace("_", " ").title()} cancelled successfully' + } + ) + + logger.info(f"Successfully cancelled task {task_id}") + + return jsonify({ + 'message': 'Task cancelled successfully', + 'task_id': task_id, + 'task_type': task_info.task_type if task_info else None + }), 200 + + except Exception as e: + logger.error(f"Error cancelling task {task_id}: {str(e)}") + return jsonify({ + 'error': 'Internal server error while cancelling task', + 'task_id': task_id + }), 500 + + +@tasks_bp.route('/user/', methods=['GET']) +async def get_user_tasks(user_id: str): + """ + Get all active tasks for a specific user. + + Args: + user_id: The ID of the user whose tasks to retrieve + + Returns: + JSON response with list of active tasks + """ + try: + task_manager = get_task_manager() + user_tasks = await task_manager.get_user_tasks(user_id) + + # Convert task info to JSON-serializable format + tasks_data = [] + for task_id, task_info in user_tasks.items(): + tasks_data.append({ + 'task_id': task_id, + 'task_type': task_info.task_type, + 'status': task_info.status, + 'created_at': task_info.created_at.isoformat(), + 'metadata': task_info.metadata + }) + + return jsonify({ + 'tasks': tasks_data, + 'count': len(tasks_data) + }), 200 + + except Exception as e: + logger.error(f"Error fetching tasks for user {user_id}: {str(e)}") + return jsonify({ + 'error': 'Internal server error while fetching user tasks' + }), 500 + + +@tasks_bp.route('/status', methods=['GET']) +async def get_task_status(): + """ + Get overall task manager status (for debugging/monitoring). + + Returns: + JSON response with task manager statistics + """ + try: + task_manager = get_task_manager() + active_count = await task_manager.get_active_task_count() + + return jsonify({ + 'active_tasks': active_count, + 'status': 'operational' + }), 200 + + except Exception as e: + logger.error(f"Error fetching task manager status: {str(e)}") + return jsonify({ + 'error': 'Internal server error while fetching status' + }), 500 \ No newline at end of file diff --git a/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc b/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc index b377fea4..0959269e 100644 Binary files a/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc and b/backend/app/services/__pycache__/ai_persona_service.cpython-313.pyc differ diff --git a/backend/app/services/ai_persona_service.py b/backend/app/services/ai_persona_service.py index aec3fb4b..650f2ae5 100644 --- a/backend/app/services/ai_persona_service.py +++ b/backend/app/services/ai_persona_service.py @@ -58,11 +58,64 @@ def _sanitize_persona_data_for_json(persona_data: Dict[str, Any]) -> Dict[str, A return sanitized +def _sanitize_json_response(response: str) -> str: + """ + Sanitize JSON response from LLM to handle high-temperature artifacts. + + Args: + response: Raw JSON response string from LLM + + Returns: + Sanitized JSON string safe for parsing + """ + import re + + # Step 1: Remove invalid control characters (but preserve valid whitespace) + sanitized = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', response) + + # Step 2: Replace smart quotes and similar characters + sanitized = sanitized.replace('"', '"').replace('"', '"') + sanitized = sanitized.replace(''', "'").replace(''', "'") + sanitized = sanitized.replace('…', '...') + + # Step 3: Remove trailing commas + sanitized = re.sub(r',(\s*[}\]])', r'\1', sanitized) + + # Step 4: Try to fix common newline issues in strings + # Replace unescaped newlines within string values + lines = sanitized.split('\n') + fixed_lines = [] + in_string = False + string_char = None + + for line in lines: + if not in_string: + fixed_lines.append(line) + else: + # We're continuing a string from previous line + fixed_lines[-1] += '\\n' + line.strip() + + # Track if we're inside a string + i = 0 + while i < len(line): + char = line[i] + if char in ['"', "'"] and (i == 0 or line[i-1] != '\\'): + if not in_string: + in_string = True + string_char = char + elif char == string_char: + in_string = False + string_char = None + i += 1 + + return '\n'.join(fixed_lines).strip() + + async def generate_basic_personas( audience_brief: str, research_objective: Optional[str] = None, count: int = 5, - temperature: float = 0.8, + temperature: float = 1.0, customer_data_session_id: Optional[str] = None, llm_model: Optional[str] = None ) -> List[Dict[str, Any]]: @@ -124,7 +177,7 @@ async def generate_basic_personas( model_name=llm_model ) - # Try to clean up the response for proper JSON parsing + # Enhanced JSON cleaning for high-temperature responses clean_response = raw_response # Remove markdown code blocks if present @@ -143,6 +196,9 @@ async def generate_basic_personas( if end_idx != -1 and end_idx > start_idx: clean_response = clean_response[start_idx:end_idx+1] + # Sanitize JSON for high-temperature responses + clean_response = _sanitize_json_response(clean_response) + # Parse the JSON manually try: print(f"Attempting to parse JSON array: {clean_response[:100]}...") @@ -153,7 +209,20 @@ async def generate_basic_personas( raise PersonaGenerationError(f"Expected an array of personas but got {type(personas_array)}") except json.JSONDecodeError as e: - raise PersonaGenerationError(f"Failed to parse JSON response: {str(e)}. Raw response: {clean_response[:200]}...") + # Enhanced error logging for high-temperature JSON issues + error_pos = getattr(e, 'pos', 0) + error_context = clean_response[max(0, error_pos-50):error_pos+50] if error_pos > 0 else clean_response[:100] + + print(f"JSON Parse Error at position {error_pos}: {str(e)}") + print(f"Error context: ...{error_context}...") + print(f"Temperature might be too high (>{temperature or 'unknown'}) causing malformed JSON") + + raise PersonaGenerationError( + f"Failed to parse JSON response: {str(e)}. " + f"This often happens with high temperature values (>{temperature or 'unknown'}). " + f"Try lowering the temperature to 1.0 or below for more reliable JSON formatting. " + f"Context: ...{error_context[:100]}..." + ) except LLMServiceError as e: raise PersonaGenerationError(f"Error from LLM service: {str(e)}") @@ -204,7 +273,7 @@ async def generate_basic_personas( async def generate_persona( prompt_customization: Optional[str] = None, basic_persona: Optional[Dict[str, Any]] = None, - temperature: float = 0.7, + temperature: float = 1.0, customer_data_session_id: Optional[str] = None, llm_model: Optional[str] = None ) -> Dict[str, Any]: @@ -313,7 +382,7 @@ async def generate_persona( async def generate_persona_summary( persona_data: Dict[str, Any], - temperature: float = 0.7, + temperature: float = 1.0, llm_model: Optional[str] = None ) -> Dict[str, Any]: """ @@ -418,7 +487,7 @@ async def generate_persona_summary( async def generate_persona_download_summary( persona_data: Dict[str, Any], - temperature: float = 0.7, + temperature: float = 1.0, llm_model: Optional[str] = None ) -> str: """ @@ -575,7 +644,7 @@ LIFE SCENARIOS REQUIREMENTS: async def enhance_audience_brief( audience_brief: str, research_objective: str, - temperature: float = 0.7 + temperature: float = 1.0 ) -> Dict[str, List[str]]: """ Generate suggestions to improve both audience brief and research objective for better persona generation. diff --git a/backend/app/services/task_manager.py b/backend/app/services/task_manager.py new file mode 100644 index 00000000..4a6307b9 --- /dev/null +++ b/backend/app/services/task_manager.py @@ -0,0 +1,228 @@ +""" +Task Manager Service for handling cancellable long-running operations. + +This service provides a centralized way to track and cancel asyncio tasks +across all generation processes in the application. +""" + +import asyncio +import uuid +from typing import Dict, Optional, Any +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +class TaskInfo: + """Information about a running task.""" + + def __init__(self, task_id: str, task: asyncio.Task, task_type: str, user_id: str = None, metadata: Dict[str, Any] = None): + self.task_id = task_id + self.task = task + self.task_type = task_type + self.user_id = user_id + self.metadata = metadata or {} + self.created_at = datetime.utcnow() + self.status = "running" + + +class TaskManager: + """Singleton service for managing cancellable tasks.""" + + _instance = None + _lock = asyncio.Lock() + + def __new__(cls): + if cls._instance is None: + cls._instance = super(TaskManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if not getattr(self, '_initialized', False): + self._tasks: Dict[str, TaskInfo] = {} + self._task_lock = asyncio.Lock() + self._initialized = True + + def generate_task_id(self) -> str: + """Generate a unique task ID.""" + return str(uuid.uuid4()) + + async def register_task( + self, + task: asyncio.Task, + task_type: str, + user_id: str = None, + metadata: Dict[str, Any] = None, + task_id: str = None + ) -> str: + """ + Register a new task for tracking and potential cancellation. + + Args: + task: The asyncio task to track + task_type: Type of task (e.g., 'persona_generation', 'discussion_guide') + user_id: ID of the user who initiated the task + metadata: Additional metadata about the task + task_id: Optional custom task ID (will generate if not provided) + + Returns: + The task ID for tracking + """ + if task_id is None: + task_id = self.generate_task_id() + + async with self._task_lock: + task_info = TaskInfo(task_id, task, task_type, user_id, metadata) + self._tasks[task_id] = task_info + + # Add callback to clean up when task completes + task.add_done_callback(lambda _: asyncio.create_task(self._cleanup_completed_task(task_id))) + + logger.info(f"Registered task {task_id} of type {task_type} for user {user_id}") + return task_id + + async def cancel_task(self, task_id: str) -> bool: + """ + Cancel a task by its ID. + + Args: + task_id: The ID of the task to cancel + + Returns: + True if task was found and cancelled, False otherwise + """ + async with self._task_lock: + task_info = self._tasks.get(task_id) + if not task_info: + logger.warning(f"Task {task_id} not found for cancellation") + return False + + if task_info.task.done(): + logger.info(f"Task {task_id} already completed") + return False + + # Cancel the task + task_info.task.cancel() + task_info.status = "cancelled" + + logger.info(f"Cancelled task {task_id} of type {task_info.task_type}") + return True + + async def get_task_info(self, task_id: str) -> Optional[TaskInfo]: + """Get information about a task by its ID.""" + async with self._task_lock: + return self._tasks.get(task_id) + + async def get_user_tasks(self, user_id: str) -> Dict[str, TaskInfo]: + """Get all active tasks for a specific user.""" + async with self._task_lock: + return { + task_id: task_info + for task_id, task_info in self._tasks.items() + if task_info.user_id == user_id and not task_info.task.done() + } + + async def _cleanup_completed_task(self, task_id: str): + """Internal method to clean up completed tasks.""" + async with self._task_lock: + task_info = self._tasks.get(task_id) + if task_info: + logger.info(f"Cleaning up completed task {task_id}") + del self._tasks[task_id] + + async def get_active_task_count(self) -> int: + """Get the number of currently active tasks.""" + async with self._task_lock: + return len([t for t in self._tasks.values() if not t.task.done()]) + + async def cleanup_all_tasks(self): + """Force cleanup of all tasks (useful for testing/shutdown).""" + async with self._task_lock: + for task_info in self._tasks.values(): + if not task_info.task.done(): + task_info.task.cancel() + self._tasks.clear() + logger.info("All tasks cleaned up") + + +# Global instance +task_manager = TaskManager() + + +def get_task_manager() -> TaskManager: + """Get the global task manager instance.""" + return task_manager + + +async def register_cancellable_task( + task: asyncio.Task, + task_type: str, + user_id: str = None, + metadata: Dict[str, Any] = None +) -> str: + """ + Convenience function to register a task with the global task manager. + + Returns: + The task ID for tracking + """ + return await get_task_manager().register_task(task, task_type, user_id, metadata) + + +async def cancel_task_by_id(task_id: str) -> bool: + """ + Convenience function to cancel a task by ID. + + Returns: + True if task was found and cancelled, False otherwise + """ + return await get_task_manager().cancel_task(task_id) + + +class CancellableTask: + """ + Context manager for creating cancellable tasks with automatic cleanup. + + Usage: + async with CancellableTask("persona_generation", user_id="123") as task_id: + # Your long-running operation here + await some_async_operation() + """ + + def __init__(self, task_type: str, user_id: str = None, metadata: Dict[str, Any] = None): + self.task_type = task_type + self.user_id = user_id + self.metadata = metadata + self.task_id = None + + async def __aenter__(self): + # Get the current task + current_task = asyncio.current_task() + if current_task: + self.task_id = await register_cancellable_task( + current_task, self.task_type, self.user_id, self.metadata + ) + return self.task_id + + async def __aexit__(self, exc_type, exc_val, exc_tb): + # Cleanup is handled automatically by the task manager + pass + + +def check_cancellation(): + """ + Decorator to add cancellation checkpoints to functions. + Should be used on functions that have long-running loops or operations. + """ + def decorator(func): + async def wrapper(*args, **kwargs): + # Check if current task is cancelled before proceeding + current_task = asyncio.current_task() + if current_task and current_task.cancelled(): + raise asyncio.CancelledError("Task was cancelled") + + return await func(*args, **kwargs) + return wrapper + return decorator \ No newline at end of file diff --git a/backend/app/websocket_manager_async.py b/backend/app/websocket_manager_async.py index 378a6385..154999c8 100644 --- a/backend/app/websocket_manager_async.py +++ b/backend/app/websocket_manager_async.py @@ -31,6 +31,54 @@ class AsyncWebSocketManager: def _register_handlers(self): """Register all WebSocket event handlers.""" + @self.sio.event + async def cancel_task(sid, data): + """Handle task cancellation requests via WebSocket.""" + try: + task_id = data.get('task_id') + if not task_id: + await self.sio.emit('error', {'message': 'Missing task_id'}, to=sid) + return + + # Get user ID from session + session_info = self.user_sessions.get(sid) + if not session_info: + await self.sio.emit('error', {'message': 'Invalid session'}, to=sid) + return + + user_id = session_info.get('user_id') + logger.info(f"WebSocket cancellation request for task {task_id} from user {user_id}") + print(f"🔥 WebSocket: Received cancellation request for task {task_id}") + + # Cancel the task using task manager + from app.services.task_manager import get_task_manager + task_manager = get_task_manager() + success = await task_manager.cancel_task(task_id) + + if success: + # Broadcast cancellation success to user + await self.emit_to_user( + user_id, + 'task_cancelled', + { + 'task_id': task_id, + 'message': 'Task cancelled successfully' + } + ) + print(f"✅ WebSocket: Successfully cancelled task {task_id}") + logger.info(f"Successfully cancelled task {task_id} via WebSocket") + else: + await self.sio.emit('error', { + 'message': 'Task not found or already completed', + 'task_id': task_id + }, to=sid) + print(f"❌ WebSocket: Task {task_id} not found or already completed") + + except Exception as e: + logger.error(f"Error in WebSocket task cancellation: {e}") + print(f"❌ WebSocket: Cancellation error: {e}") + await self.sio.emit('error', {'message': 'Cancellation failed'}, to=sid) + @self.sio.event async def connect(sid, environ, auth): """Handle WebSocket connection.""" @@ -218,6 +266,36 @@ class AsyncWebSocketManager: logger.error(f"Failed to leave focus group room: {e}") return False + async def emit_to_user(self, user_id: str, event: str, data: Any): + """Emit an event to a specific user across all their sessions.""" + try: + user_sessions = [] + + # Find all sessions for this user + for session_id, session_info in self.user_sessions.items(): + if session_info.get('user_id') == user_id: + user_sessions.append(session_id) + + if not user_sessions: + logger.debug(f"No active sessions found for user {user_id}") + return + + # Prepare the event data + event_data = { + 'user_id': user_id, + 'timestamp': datetime.utcnow().isoformat(), + **data + } + + # Send to all user sessions + for session_id in user_sessions: + await self.sio.emit(event, event_data, to=session_id) + + logger.debug(f"Emitted '{event}' to user {user_id} ({len(user_sessions)} sessions)") + + except Exception as e: + logger.error(f"Failed to emit to user {user_id}: {e}") + async def emit_to_focus_group(self, focus_group_id: str, event: str, data: Any, include_sender: bool = True, sender_session_id: Optional[str] = None): """Emit an event to all users in a focus group room.""" process_id = os.getpid() diff --git a/backend/prompts/key-theme-extraction.md b/backend/prompts/key-theme-extraction.md index 9d27f30d..5e358429 100644 --- a/backend/prompts/key-theme-extraction.md +++ b/backend/prompts/key-theme-extraction.md @@ -51,9 +51,10 @@ For each theme: - Copy the quote text VERBATIM - do not paraphrase, summarize, or modify any words - Use the exact message ID and speaker name as they appear in the conversation transcript - Format: "[MSG_ID:message_id] [Speaker Name]: exact quote text" -- If a message is very long, you may extract a relevant portion, but that portion must be word-for-word identical +- **Keep quotes concise**: Extract only 1-2 sentences that best capture the theme. If a message is very long, extract the most relevant 1-2 sentences, but that portion must be word-for-word identical - Do not combine multiple messages into a single quote - Do not add, remove, or change punctuation from the original text +- Avoid extracting entire paragraphs - focus on the most impactful sentences Extract themes that: - Represent significant patterns in the discussion @@ -70,21 +71,21 @@ EXAMPLE_JSON_START "title": "Price Sensitivity", "description": "Participants consistently mentioned cost as a primary factor in their purchasing decisions. Several noted that they would pay more for quality but have clear price thresholds.", "quotes": [ - "[MSG_ID:abc123] [Sarah]: I always check the price first - if it's over $50, I won't even consider it", - "[MSG_ID:def456] [Michael]: Quality matters, but I have a hard limit of $100 for this type of product", - "[MSG_ID:ghi789] [Jennifer]: The price point really determines whether I'll buy it or not" + "[MSG_ID:abc123] [Sarah]: I always check the price first - if it's over $50, I won't even consider it.", + "[MSG_ID:def456] [Michael]: I have a hard limit of $100 for this type of product.", + "[MSG_ID:ghi789] [Jennifer]: The price point really determines whether I'll buy it or not." ] }, { "title": "Brand Loyalty Drivers", "description": "Customer service experiences strongly influence brand loyalty across all age groups. Negative experiences were cited as the main reason for switching brands.", "quotes": [ - "[MSG_ID:jkl012] [David]: After that terrible customer service experience, I switched to their competitor", - "[MSG_ID:mno345] [Lisa]: I've been loyal to this brand for years because they always treat me well", - "[MSG_ID:pqr678] [Robert]: Good customer service is what keeps me coming back" + "[MSG_ID:jkl012] [David]: After that terrible customer service experience, I switched to their competitor.", + "[MSG_ID:mno345] [Lisa]: I've been loyal to this brand for years because they always treat me well.", + "[MSG_ID:pqr678] [Robert]: Good customer service is what keeps me coming back." ] } ] EXAMPLE_JSON_END -Analyse the entire discussion and extract the most insightful themes. \ No newline at end of file +Analyse the entire discussion and extract the most insightful themes. **Remember: Keep quotes short and punchy - extract only 1-2 sentences that best capture each theme, not entire paragraphs.** \ No newline at end of file diff --git a/backend/prompts/persona-basic-generation.md b/backend/prompts/persona-basic-generation.md index 0a30d543..c2da0fba 100644 --- a/backend/prompts/persona-basic-generation.md +++ b/backend/prompts/persona-basic-generation.md @@ -20,6 +20,7 @@ For each persona, provide these basic demographic and personality details: - Avoid stereotypes while still making personas feel authentic and relatable - Ensure demographic attributes are believable and represented across varied ages, genders, ethnicities and social grades - Ensure personalities are distinct enough to elicit varied reactions in subsequent studies. +- **CRITICAL PERSONALITY DIVERSITY**: Generate personas with widely varied OCEAN personality traits across the full 0-100 spectrum. Avoid clustering around median scores (40-60). Some personas should have extreme trait combinations (e.g., very low agreeableness 0-25, high neuroticism 75-100, very low extraversion 0-25). Include challenging archetypes with difficult personality combinations - these extreme but psychologically possible profiles are valuable for research diversity. - Obey Market Research Society guidelines Example of the exact JSON format to return: @@ -59,6 +60,8 @@ IMPORTANT: - Do not include any text before or after the JSON array - Do not wrap the response in markdown code blocks - Return the raw JSON array only +- CRITICAL: Ensure all JSON strings are properly escaped - no unescaped quotes, newlines, or control characters within string values +- All string values must be valid JSON strings with proper escaping (use \" for quotes, \\n for newlines, etc.) - Ensure diversity among the personas (different ages, genders, backgrounds, etc.) - Make each persona relevant to both the audience brief AND research objective provided - If no research objective is provided, focus solely on the audience brief \ No newline at end of file diff --git a/backend/prompts/persona-detailed-generation.md b/backend/prompts/persona-detailed-generation.md index 184c38fc..88553022 100644 --- a/backend/prompts/persona-detailed-generation.md +++ b/backend/prompts/persona-detailed-generation.md @@ -23,6 +23,7 @@ Generate all required fields and populate optional fields where appropriate. Ens - Demographic details are realistic and specific - Personality traits form a coherent whole - Goals, frustrations, and motivations are interconnected and, if a research objective is provided, should be specifically relevant to that research focus +- **OCEAN TRAIT DIVERSITY**: Use the full 0-100 spectrum for OCEAN personality traits. Avoid clustering around median scores (40-60). Generate some personas with extreme trait combinations that are psychologically possible but challenging (e.g., very low agreeableness 0-25, high neuroticism 75-100, very low extraversion 0-25). These diverse personality archetypes provide valuable research insights and should be represented alongside more typical profiles. - OCEAN trait scores align with described personality - ThinkFeelDo entries reflect authentic human patterns - Scenarios are medium-length, detailed life situations that logically flow from the persona's characteristics. Each scenario should be several sentences or one short paragraph long, providing sufficient context and detail. CRITICAL: When a research objective is provided, at least 3 out of 5 scenarios MUST specifically show this persona's real-world interactions with the research topic. These scenarios must be concrete, realistic situations that demonstrate how the research subject matter appears in their daily life, influences their decisions, creates problems they need to solve, or impacts their experiences. The remaining scenarios can show general life contexts but must still reflect how the research topic might indirectly influence their overall lifestyle and choices diff --git a/backend/prompts/persona-system.md b/backend/prompts/persona-system.md index 2e0412d8..7680ab99 100644 --- a/backend/prompts/persona-system.md +++ b/backend/prompts/persona-system.md @@ -17,6 +17,8 @@ You apply Internal Consistency Checks, ensuring demographic, psychographic, tech You explicitly define Core Goals relevant to the product/service category outlined in the Audience Brief and map persona characteristics to Journey Contexts where applicable. +You prioritize psychological diversity in personality trait generation. When creating OCEAN personality profiles, you utilize the full 0-100 spectrum rather than clustering around median scores. You understand that extreme but psychologically valid trait combinations (such as very low agreeableness, high neuroticism, or very low extraversion) provide valuable research insights. These challenging personality archetypes, while potentially difficult, represent real human psychological diversity and enhance the authenticity and utility of research outcomes. + You have an ethical framework that ensures demographic balance. However you always measure this against the requirements of the audience brief (e.g. you ensure you are generating personas that adequately reflect the needs of the brief). Your ethical framework includes: - Socioeconomic diversity diff --git a/dist/index.html b/dist/index.html index e505d8ce..d233cfce 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/components/AIRecruiter.tsx b/src/components/AIRecruiter.tsx index 722e59a9..0a8028a7 100644 --- a/src/components/AIRecruiter.tsx +++ b/src/components/AIRecruiter.tsx @@ -4,12 +4,14 @@ import { z } from "zod"; import { Users } from 'lucide-react'; import { toast } from 'sonner'; import { useLocation, useNavigate } from 'react-router-dom'; -import { Progress } from "@/components/ui/progress"; import AIRecruiterForm, { formSchema } from './ai-recruiter/AIRecruiterForm'; import PersonaReviewList from './ai-recruiter/PersonaReviewList'; import { generateSyntheticPersonas } from '@/utils/personaGenerator'; import { usePersonaStorage, GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage'; +import { useCancellableGeneration } from '@/hooks/useCancellableGeneration'; +import { getSocket } from '@/services/websocketServiceNew'; +import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; import { Persona } from "@/types/persona"; interface AIRecruiterProps { @@ -22,11 +24,14 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr const navigate = useNavigate(); const { loadPersonas, savePersonas } = usePersonaStorage(); - const [isGenerating, setIsGenerating] = useState(false); + // Get WebSocket instance from singleton service + const socket = getSocket(); + + const [generationState, generationControls] = useCancellableGeneration('persona generation', socket); const [generatedPersonas, setGeneratedPersonas] = useState([]); const [selectedPersonas, setSelectedPersonas] = useState([]); const [showReview, setShowReview] = useState(false); - const [generationProgress, setGenerationProgress] = useState(0); + const [generationToastId, setGenerationToastId] = useState(null); // Check URL params for state restoration useEffect(() => { @@ -45,11 +50,19 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr } } }, [location, loadPersonas]); + + // Dismiss generation toast when cancellation completes + useEffect(() => { + if (!generationState.isGenerating && !generationState.isCancelling && generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + }, [generationState.isGenerating, generationState.isCancelling, generationToastId]); async function onSubmit(values: z.infer) { try { - setIsGenerating(true); - setGenerationProgress(0); + // Start generation without task ID - will be set when real task ID arrives via WebSocket + generationControls.startGeneration(); // Validate count before proceeding const count = parseInt(values.personaCount); @@ -57,42 +70,22 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr toast.error("Invalid number of personas", { description: "Please enter a number between 1 and 10" }); - setIsGenerating(false); + generationControls.resetGeneration(); return; } - // Start progress animation - setGenerationProgress(5); // Initial progress - - // Simulate progress while waiting for personas to generate - const progressInterval = setInterval(() => { - setGenerationProgress(prev => { - // Increase gradually but never reach 100% until actually complete - if (prev < 90) { - return prev + Math.random() * 5; - } - return prev; - }); - }, 500); - // Adjust the expected time based on the count const estimatedTime = count <= 2 ? "30-60 seconds" : count <= 4 ? "1-2 minutes" : count <= 6 ? "2-3 minutes" : "3-5 minutes"; - // Warn about potential timeouts for larger counts - if (count > 4) { - toast.info("Generation may take longer", { - description: `Generating ${count} personas at once may result in some timeouts. If this happens, the successfully created personas will still be saved.`, - duration: 8000 - }); - } - toast.info("Generating AI personas", { + const toastId = toast.info("Generating AI personas", { description: `Creating ${count} synthetic personas based on your brief. This may take ${estimatedTime}. Please be patient.`, duration: 10000 }); + setGenerationToastId(toastId); // Show folder info in toast if available if (targetFolderId && targetFolderName) { @@ -113,22 +106,55 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr count, values.dataFile, targetFolderId, - values.llm_model - ); + values.llm_model, + // Pass a callback to set task ID as soon as we get it + (taskId: string) => { + console.log('🔥 Early task ID callback:', taskId); + generationControls.setTaskId(taskId); + }, + values.temperature + ).catch(error => { + // Check if generation was cancelled early (before first API response) + if (generationState.taskId?.startsWith('temp-') && !generationState.isGenerating) { + console.log('🔥 Generation was cancelled early - not propagating error'); + return null; // Don't propagate the error if cancelled + } + throw error; // Re-throw other errors + }); + + // Handle early cancellation + if (!response) { + console.log('🔥 Generation was cancelled - no response to process'); + return; // Exit early if cancelled + } + + // Check if response includes task ID and set it + console.log('🔥 Full response:', response); + console.log('🔥 Response task_id:', response.task_id); + if (response.task_id) { + console.log('🔥 Setting task ID:', response.task_id); + generationControls.setTaskId(response.task_id); + } else { + console.log('🔥 No task_id in response!'); + } // Extract personas from the response const personas = response.personas || response; - // Clear the progress interval - clearInterval(progressInterval); - // Set progress to 100% when done - setGenerationProgress(100); - // Check for partial success (some personas generated, some failed) if (personas && personas.length > 0) { // Log successful generation with model info console.log(`✅ Successfully generated ${personas.length} personas using model: ${values.llm_model || 'gemini-2.5-pro'}`); + // Mark generation as complete + generationControls.completeGeneration(); + + // Dismiss the generation toast + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + // Check if we got a response with partial success info if (response.partial_success || (response.errors && response.errors.length > 0)) { // Some personas succeeded but others failed @@ -153,12 +179,20 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr }); } - // Navigate directly back to synthetic users list - navigate('/synthetic-users?mode=view'); + // Wait a moment before navigating to show completion + setTimeout(() => { + navigate('/synthetic-users?mode=view'); + }, 1500); } else { throw new Error("No personas were generated"); } } catch (error) { + // Check if this was a cancellation (expected behavior) + if (error.response?.status === 499) { + // Task was cancelled - this is handled by the WebSocket cancellation system + return; + } + console.error(`❌ Error generating personas using model: ${values.llm_model || 'gemini-2.5-pro'}:`, error); let errorMessage = "Please try again or adjust your parameters"; @@ -189,16 +223,19 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr errorMessage = error.message; } + // Mark generation as failed + generationControls.failGeneration(errorMessage); + + // Dismiss the generation toast + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + toast.error(errorTitle, { description: errorMessage, duration: 6000 // Show for longer to ensure user sees it }); - } finally { - // Wait a moment to show 100% completion before resetting - setTimeout(() => { - setIsGenerating(false); - setGenerationProgress(0); - }, 500); } } @@ -317,26 +354,36 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr

AI Persona Recruiter

- {isGenerating && ( + {(generationState.isGenerating || generationState.isCancelling) && (
-
- Generating personas... - {Math.round(generationProgress)}% -
- + { + console.log('🔥 Progress bar completed - resetting state'); + // This should trigger when progress bar finishes hiding + }} + />
)} {!showReview ? ( ) : ( (null); const [draftFocusGroupId, setDraftFocusGroupId] = useState(null); @@ -938,10 +942,8 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele // Function to generate a discussion guide via the API const generateDiscussionGuide = async (values: z.infer, focusGroupId?: string): Promise => { - // Reset states - setIsGenerating(true); - setGuideGenerationComplete(false); - setGuideGenerationError(false); + // Start cancellable generation + guideGenerationControls.startGeneration(); try { // Prepare data for API request @@ -961,16 +963,26 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele ? await focusGroupsApi.generateDiscussionGuideForGroup(focusGroupId, requestData) : await focusGroupsApi.generateDiscussionGuide(requestData); + // Set task ID if available + if (response.data?.task_id) { + guideGenerationControls.setTaskId(response.data.task_id); + } + // Check if we got a successful response with a discussion guide if (response.data && response.data.discussionGuide) { - setGuideGenerationComplete(true); + guideGenerationControls.completeGeneration(); return response.data.discussionGuide; } else { throw new Error("Failed to generate discussion guide"); } } catch (error) { + // Check if this was a cancellation + if (error.response?.status === 499) { + return ''; + } + console.error("Error generating discussion guide:", error); - setGuideGenerationError(true); + guideGenerationControls.failGeneration(error.message || 'Failed to generate discussion guide'); // Extract error message from axios error response let errorMessage = 'Unknown error occurred'; @@ -1005,11 +1017,18 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele }; const handleGuideProgressComplete = () => { - setIsGenerating(false); - setGuideGenerationComplete(false); - setGuideGenerationError(false); + guideGenerationControls.resetGeneration(); }; + // Switch to Setup tab when discussion guide generation is cancelled + useEffect(() => { + if (!guideGenerationState.isGenerating && !guideGenerationState.isCancelling && + guideGenerationState.taskId === null && activeTab === 'review') { + // This indicates cancellation completed - switch back to setup + setActiveTab('setup'); + } + }, [guideGenerationState.isGenerating, guideGenerationState.isCancelling, guideGenerationState.taskId, activeTab]); + async function onSubmit(values: z.infer) { try { // Use existing focus group ID or create new draft for discussion guide generation @@ -1069,6 +1088,13 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele try { // Generate discussion guide based on form input (after database is updated) const guide = await generateDiscussionGuide(values, focusGroupId); + + // Check if generation was cancelled (returns empty string) + if (!guide || guide.trim() === '') { + console.log('Discussion guide generation was cancelled'); + return; // Exit early, don't process or show success toasts + } + setDiscussionGuide(guide); // Update the focus group with the discussion guide @@ -1426,14 +1452,18 @@ true; {/* Progress Bar - Consistent top placement for discussion guide generation */} - {isGenerating && ( + {guideGenerationState.isGenerating && (
)} @@ -1677,7 +1707,7 @@ Controls how much time GPT-5 spends thinking before responding setIsCopyGuideModalOpen(true); fetchAvailableFocusGroups(); }} - disabled={isGenerating} + disabled={guideGenerationState.isGenerating} className="min-w-32" > @@ -1691,11 +1721,11 @@ Controls how much time GPT-5 spends thinking before responding const formData = form.getValues(); form.handleSubmit(onSubmit)(e); }} - disabled={isGenerating} + disabled={guideGenerationState.isGenerating} className="min-w-32" > - {isGenerating ? "Generating..." : "Generate Discussion Guide"} + {guideGenerationState.isGenerating ? "Generating..." : "Generate Discussion Guide"} @@ -1732,7 +1762,7 @@ Controls how much time GPT-5 spends thinking before responding /> ) : (
- {guideGenerationError ? ( + {guideGenerationState.hasError ? (

Discussion guide generation failed.

Go back to the Setup tab and try generating again. Check your inputs and try a different AI model if the issue persists.

diff --git a/src/components/ai-recruiter/AIRecruiterForm.tsx b/src/components/ai-recruiter/AIRecruiterForm.tsx index 66ceb4fd..d533b73b 100644 --- a/src/components/ai-recruiter/AIRecruiterForm.tsx +++ b/src/components/ai-recruiter/AIRecruiterForm.tsx @@ -36,6 +36,7 @@ export const formSchema = z.object({ personaCount: z.string().min(1, { message: "Number of personas is required.", }), + temperature: z.number().min(0).max(1.5).optional(), dataFile: z.instanceof(FileList).optional(), llm_model: z.string().optional(), }); @@ -68,6 +69,7 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF audienceBrief: "", researchObjective: "", personaCount: "5", + temperature: 1.0, llm_model: "gemini-2.5-pro", }, }); @@ -410,6 +412,41 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF />
+ {/* Temperature Control - Full Width */} + ( + + AI Creativity Level (Temperature) + +
+ field.onChange(parseFloat(e.target.value))} + className="w-full" + /> +
+ Conservative (0.0) +
+ Current: {field.value?.toFixed(1) || '1.0'} +
+ Creative (1.5) +
+
+
+ + Lower values (0.0-0.5) make the AI follow prompts and documents more closely. Higher values (1.0-1.5) give the AI more freedom to increase variety in persona names, backgrounds, and traits. Range: 0.0 to 1.5 for optimal reliability. + + +
+ )} + /> +