made long actions cancellable (like persona generation, etc.), increased variety of persona generation with prompt changes and temperature variable, reduced length of key theme quotes, bug fixes

This commit is contained in:
michael 2025-09-10 16:24:05 -05:00
parent 8288cb9f5e
commit e29d2a0bb9
36 changed files with 2193 additions and 638 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
backend/.DS_Store vendored

Binary file not shown.

View file

@ -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'])

View file

@ -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()

View file

@ -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():

View file

@ -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({

View file

@ -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}")

View file

@ -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

129
backend/app/routes/tasks.py Normal file
View file

@ -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('/<task_id>', 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/<user_id>', 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

View file

@ -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.

View file

@ -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

View file

@ -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()

View file

@ -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.
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.**

View file

@ -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

View file

@ -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

View file

@ -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

4
dist/index.html vendored
View file

@ -7,8 +7,8 @@
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<meta property="og:image" content="/og-image.png" />
<script type="module" crossorigin src="/semblance/assets/index-DeCgXQlM.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-D95t4u2x.css">
<script type="module" crossorigin src="/semblance/assets/index-BwNvaEWm.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-CvzgaSoN.css">
</head>
<body>

View file

@ -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<Persona[]>([]);
const [selectedPersonas, setSelectedPersonas] = useState<string[]>([]);
const [showReview, setShowReview] = useState(false);
const [generationProgress, setGenerationProgress] = useState(0);
const [generationToastId, setGenerationToastId] = useState<string | null>(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<typeof formSchema>) {
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
<h2 className="font-sf text-xl font-semibold">AI Persona Recruiter</h2>
</div>
{isGenerating && (
{(generationState.isGenerating || generationState.isCancelling) && (
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Generating personas...</span>
<span className="text-sm text-muted-foreground">{Math.round(generationProgress)}%</span>
</div>
<Progress value={generationProgress} className="h-2" />
<GenerationProgressBar
isActive={generationState.isGenerating}
isComplete={generationState.isComplete}
hasError={generationState.hasError}
isCancelling={generationState.isCancelling}
label="Generating AI personas..."
onCancel={generationControls.cancelGeneration}
taskId={generationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
onComplete={() => {
console.log('🔥 Progress bar completed - resetting state');
// This should trigger when progress bar finishes hiding
}}
/>
</div>
)}
{!showReview ? (
<AIRecruiterForm
onSubmit={onSubmit}
isGenerating={isGenerating}
isGenerating={generationState.isGenerating}
/>
) : (
<PersonaReviewList
generatedPersonas={generatedPersonas}
selectedPersonas={selectedPersonas}
isGenerating={isGenerating}
isGenerating={generationState.isGenerating}
onPersonaSelection={handlePersonaSelection}
onRefinePersonas={handleRefinePersonas}
onApprovePersonas={handleApprovePersonas}

View file

@ -29,6 +29,8 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { personasApi, focusGroupsApi, foldersApi } from '@/lib/api';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer';
import AssetUploader from '@/components/AssetUploader';
@ -145,9 +147,11 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
const location = useLocation();
const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation();
const [activeTab, setActiveTab] = useState('setup');
const [isGenerating, setIsGenerating] = useState(false);
const [guideGenerationComplete, setGuideGenerationComplete] = useState(false);
const [guideGenerationError, setGuideGenerationError] = useState(false);
// Cancellable generation for discussion guide
const socket = getSocket();
const [guideGenerationState, guideGenerationControls] = useCancellableGeneration('discussion guide generation', socket);
const [discussionGuide, setDiscussionGuide] = useState<string | any | null>(null);
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(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<typeof formSchema>, focusGroupId?: string): Promise<string> => {
// 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<typeof formSchema>) {
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;
</div>
{/* Progress Bar - Consistent top placement for discussion guide generation */}
{isGenerating && (
{guideGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={isGenerating}
isComplete={guideGenerationComplete}
hasError={guideGenerationError}
isActive={guideGenerationState.isGenerating}
isComplete={guideGenerationState.isComplete}
hasError={guideGenerationState.hasError}
isCancelling={guideGenerationState.isCancelling}
label="Generating discussion guide"
onComplete={handleGuideProgressComplete}
onCancel={guideGenerationControls.cancelGeneration}
taskId={guideGenerationState.taskId}
showCancel={true}
/>
</div>
)}
@ -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"
>
<Copy className="mr-2 h-4 w-4" />
@ -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"
>
<MessageSquare className="mr-2 h-4 w-4" />
{isGenerating ? "Generating..." : "Generate Discussion Guide"}
{guideGenerationState.isGenerating ? "Generating..." : "Generate Discussion Guide"}
</Button>
</div>
</TabsContent>
@ -1732,7 +1762,7 @@ Controls how much time GPT-5 spends thinking before responding
/>
) : (
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
{guideGenerationError ? (
{guideGenerationState.hasError ? (
<div>
<p className="mb-2">Discussion guide generation failed.</p>
<p className="text-sm">Go back to the <strong>Setup</strong> tab and try generating again. Check your inputs and try a different AI model if the issue persists.</p>

View file

@ -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
/>
</div>
{/* Temperature Control - Full Width */}
<FormField
control={form.control}
name="temperature"
render={({ field }) => (
<FormItem>
<FormLabel>AI Creativity Level (Temperature)</FormLabel>
<FormControl>
<div className="space-y-3">
<Input
type="range"
min="0"
max="1.5"
step="0.1"
value={field.value || 1.0}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
className="w-full"
/>
<div className="flex justify-between items-center text-sm text-muted-foreground">
<span>Conservative (0.0)</span>
<div className="text-center">
<span className="font-medium text-foreground">Current: {field.value?.toFixed(1) || '1.0'}</span>
</div>
<span>Creative (1.5)</span>
</div>
</div>
</FormControl>
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col items-end">
<Button
type="button"

View file

@ -31,6 +31,9 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
import { Persona } from '@/types/persona';
const modificationFormSchema = z.object({
@ -59,7 +62,9 @@ export default function PersonaModificationModal({
onClose,
onPersonaPreview
}: PersonaModificationModalProps) {
const [isProcessing, setIsProcessing] = useState(false);
// Cancellable generation for persona modification
const socket = getSocket();
const [modificationState, modificationControls] = useCancellableGeneration('persona modification', socket);
const form = useForm<ModificationFormData>({
resolver: zodResolver(modificationFormSchema),
@ -72,13 +77,14 @@ export default function PersonaModificationModal({
});
const handleClose = () => {
if (isProcessing) return; // Prevent closing while processing
if (modificationState.isGenerating) return; // Prevent closing while processing
form.reset();
modificationControls.resetGeneration();
onClose();
};
const onSubmit = async (values: ModificationFormData) => {
setIsProcessing(true);
modificationControls.startGeneration();
try {
toastService.info("Generating persona preview...", {
@ -96,7 +102,14 @@ export default function PersonaModificationModal({
preview_only: true
});
// Set task ID if available
if (response.data?.task_id) {
modificationControls.setTaskId(response.data.task_id);
}
if (response.data && response.data.persona) {
modificationControls.completeGeneration();
toastService.success("Preview generated successfully!", {
description: `Ready to review proposed changes to ${persona.name}`
});
@ -108,8 +121,15 @@ export default function PersonaModificationModal({
}
} catch (error: any) {
// Check if this was a cancellation
if (error.response?.status === 499) {
return;
}
console.error("Error generating persona preview:", error);
modificationControls.failGeneration(error.message || 'Failed to generate preview');
if (error.response) {
const errorMessage = error.response.data?.error || "Server error occurred";
toastService.error("Failed to generate preview", {
@ -124,8 +144,6 @@ export default function PersonaModificationModal({
description: error.message || "An unexpected error occurred"
});
}
} finally {
setIsProcessing(false);
}
};
@ -143,6 +161,23 @@ export default function PersonaModificationModal({
</DialogDescription>
</DialogHeader>
{/* Progress Bar for persona modification */}
{modificationState.isGenerating && (
<div className="mb-4">
<GenerationProgressBar
isActive={modificationState.isGenerating}
isComplete={modificationState.isComplete}
hasError={modificationState.hasError}
isCancelling={modificationState.isCancelling}
label="Modifying persona with AI..."
onCancel={modificationControls.cancelGeneration}
taskId={modificationState.taskId}
showCancel={true}
className="w-full"
/>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Modification Prompt */}
@ -157,7 +192,7 @@ export default function PersonaModificationModal({
{...field}
placeholder="E.g., 'Make this person more tech-savvy and interested in sustainable products' or 'Increase their income level and add marketing experience'"
className="min-h-[120px]"
disabled={isProcessing}
disabled={modificationState.isGenerating}
/>
</FormControl>
<FormDescription>
@ -178,7 +213,7 @@ export default function PersonaModificationModal({
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
disabled={modificationState.isGenerating}
>
<FormControl>
<SelectTrigger>
@ -212,7 +247,7 @@ export default function PersonaModificationModal({
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
disabled={modificationState.isGenerating}
>
<FormControl>
<SelectTrigger>
@ -244,7 +279,7 @@ export default function PersonaModificationModal({
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isProcessing}
disabled={modificationState.isGenerating}
>
<FormControl>
<SelectTrigger>
@ -272,16 +307,16 @@ export default function PersonaModificationModal({
type="button"
variant="outline"
onClick={handleClose}
disabled={isProcessing}
disabled={modificationState.isGenerating}
>
Cancel
</Button>
<Button
type="submit"
disabled={isProcessing}
disabled={modificationState.isGenerating}
className="flex items-center gap-2"
>
{isProcessing ? (
{modificationState.isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating Preview...

View file

@ -1,13 +1,19 @@
import React, { useState, useEffect, useRef } from 'react';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
interface GenerationProgressBarProps {
isActive: boolean; // Whether generation is in progress
isComplete: boolean; // Whether generation completed successfully
hasError: boolean; // Whether there was an error
isCancelling: boolean; // Whether cancellation is in progress
label?: string; // Optional label text (e.g., "Generating Discussion Guide...")
onComplete?: () => void; // Callback when progress bar finishes and hides
onCancel?: () => void; // Callback when user clicks cancel
showCancel?: boolean; // Whether to show the cancel button
taskId?: string; // Task ID for cancellation
className?: string; // Additional CSS classes
}
@ -17,8 +23,12 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
isActive,
isComplete,
hasError,
isCancelling,
label,
onComplete,
onCancel,
showCancel = true,
taskId,
className
}) => {
const [progress, setProgress] = useState(0);
@ -146,8 +156,19 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
handleError();
}
// Reset when not active
if (!isActive && isVisible) {
// Handle cancellation start
if (isCancelling && (phase === 'progressing' || phase === 'waiting')) {
clearAllTimers();
// Keep current progress and stop - show cancelling state
}
// Handle cancellation completion (isCancelling becomes false)
if (!isCancelling && !isActive && isVisible) {
resetProgressBar();
}
// Reset when not active (normal completion)
if (!isActive && !isCancelling && isVisible) {
resetProgressBar();
}
@ -157,7 +178,7 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
clearAllTimers();
}
};
}, [isActive, isComplete, hasError, phase, isVisible]);
}, [isActive, isComplete, hasError, isCancelling, phase, isVisible]);
// Cleanup on unmount
useEffect(() => {
@ -170,14 +191,40 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
return null;
}
const handleCancelClick = () => {
if (onCancel && !isCancelling) {
onCancel();
}
};
const getDisplayLabel = () => {
if (isCancelling) return `${label} - cancelling...`;
if (phase === 'waiting') return `${label} - finalizing...`;
return label;
};
return (
<div className={cn("w-full space-y-2", className)}>
{label && (
<div className="flex justify-between items-center text-sm text-muted-foreground">
<span>
{phase === 'waiting' ? `${label} - finalizing...` : label}
{getDisplayLabel()}
</span>
<span>{Math.round(progress)}%</span>
<div className="flex items-center gap-2">
<span>{Math.round(progress)}%</span>
{showCancel && (isActive || isCancelling) && !isComplete && !hasError && taskId && !taskId.startsWith('temp-') && (
<Button
variant="ghost"
size="sm"
onClick={handleCancelClick}
disabled={isCancelling}
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Cancel generation"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
<Progress
@ -185,6 +232,7 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
className={cn(
"w-full transition-all duration-200",
hasError && "opacity-75",
isCancelling && "opacity-60",
phase === 'completed' && "bg-green-100"
)}
/>
@ -193,7 +241,12 @@ const GenerationProgressBar: React.FC<GenerationProgressBarProps> = ({
Generation failed. Please try again.
</div>
)}
{phase === 'completed' && !hasError && (
{isCancelling && (
<div className="text-sm text-orange-600">
Cancelling generation...
</div>
)}
{phase === 'completed' && !hasError && !isCancelling && (
<div className="text-sm text-green-600">
Generation completed successfully!
</div>

View file

@ -0,0 +1,278 @@
/**
* Custom hook for managing cancellable generation processes.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { cancelTaskWithFeedback } from '@/lib/taskCancellation';
import { toast } from 'sonner';
interface CancellableGenerationState {
isGenerating: boolean;
isCancelling: boolean;
hasError: boolean;
isComplete: boolean;
taskId: string | null;
error: string | null;
}
interface CancellableGenerationControls {
startGeneration: (taskId?: string) => void;
completeGeneration: () => void;
failGeneration: (error: string) => void;
cancelGeneration: () => Promise<boolean>;
resetGeneration: () => void;
setTaskId: (taskId: string) => void;
}
/**
* Hook for managing cancellable generation state and actions
*/
export function useCancellableGeneration(
taskDescription: string = 'generation',
socket?: any // WebSocket instance for real-time cancellation
): [CancellableGenerationState, CancellableGenerationControls] {
const [state, setState] = useState<CancellableGenerationState>({
isGenerating: false,
isCancelling: false,
hasError: false,
isComplete: false,
taskId: null,
error: null,
});
// Use ref to always have current state in event handlers
const stateRef = useRef(state);
stateRef.current = state;
// Set up WebSocket event listeners using window events (GPT-5 pattern)
// Note: Set up listeners even without taskId to catch task_started events
useEffect(() => {
const handleTaskCancelled = (event: CustomEvent) => {
const data = event.detail;
const currentState = stateRef.current;
if (data.task_id === currentState.taskId) {
setState({
isGenerating: false,
isCancelling: false,
isComplete: false,
hasError: false,
taskId: null,
error: null,
});
toast.success('Generation cancelled successfully');
}
};
const handleTaskStarted = (event: CustomEvent) => {
const data = event.detail;
const currentState = stateRef.current;
// Set real task ID when we receive task_started (only if we don't have one yet)
if (data.task_id && (!currentState.taskId || currentState.taskId.startsWith('temp-'))) {
setState(prev => ({
...prev,
taskId: data.task_id
}));
}
};
const handleTaskCompleted = (event: CustomEvent) => {
const data = event.detail;
if (data.task_id === state.taskId) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
isComplete: true,
hasError: false,
error: null,
}));
}
};
const handleTaskFailed = (event: CustomEvent) => {
const data = event.detail;
if (data.task_id === state.taskId) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
hasError: true,
error: data.message
}));
toast.error('Generation failed: ' + (data.message || 'Unknown error'));
}
};
// Register window event listeners (GPT-5 pattern)
window.addEventListener('ws:task_cancelled', handleTaskCancelled as EventListener);
window.addEventListener('ws:task_started', handleTaskStarted as EventListener);
window.addEventListener('ws:task_completed', handleTaskCompleted as EventListener);
window.addEventListener('ws:task_failed', handleTaskFailed as EventListener);
// Cleanup listeners
return () => {
window.removeEventListener('ws:task_cancelled', handleTaskCancelled as EventListener);
window.removeEventListener('ws:task_started', handleTaskStarted as EventListener);
window.removeEventListener('ws:task_completed', handleTaskCompleted as EventListener);
window.removeEventListener('ws:task_failed', handleTaskFailed as EventListener);
};
}, []); // Set up listeners once, not dependent on taskId
const startGeneration = useCallback((taskId?: string) => {
setState(prev => ({
...prev,
isGenerating: true,
isCancelling: false,
hasError: false,
isComplete: false,
taskId: taskId || null,
error: null,
}));
}, []);
const completeGeneration = useCallback(() => {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
isComplete: true,
hasError: false,
error: null,
}));
}, []);
const failGeneration = useCallback((error: string) => {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
hasError: true,
isComplete: false,
error,
}));
}, []);
const cancelGeneration = useCallback(async (): Promise<boolean> => {
if (!state.taskId || state.isCancelling) {
return false;
}
// Only proceed if we have a real task ID (not temp)
if (!state.taskId || state.taskId.startsWith('temp-')) {
toast.info('Waiting for generation to start...');
return false;
}
// Try WebSocket cancellation first (instant), fallback to HTTP
if (socket && socket.connected) {
setState(prev => ({ ...prev, isCancelling: true }));
try {
// Send WebSocket cancellation request
socket.emit('cancel_task', { task_id: state.taskId });
// Don't wait for response - WebSocket will handle the callback
// The task_cancelled event will reset the state
return true;
} catch (wsError) {
}
} else {
}
setState(prev => ({
...prev,
isCancelling: true,
}));
const success = await cancelTaskWithFeedback(state.taskId, taskDescription);
if (success) {
setState(prev => ({
...prev,
isGenerating: false,
isCancelling: false,
isComplete: false,
hasError: false,
taskId: null,
error: null,
}));
} else {
setState(prev => ({
...prev,
isCancelling: false,
}));
}
return success;
}, [state.taskId, state.isCancelling, taskDescription]);
const resetGeneration = useCallback(() => {
setState({
isGenerating: false,
isCancelling: false,
hasError: false,
isComplete: false,
taskId: null,
error: null,
});
}, []);
const setTaskId = useCallback((taskId: string) => {
setState(prev => ({
...prev,
taskId,
}));
}, []);
const controls: CancellableGenerationControls = {
startGeneration,
completeGeneration,
failGeneration,
cancelGeneration,
resetGeneration,
setTaskId,
};
return [state, controls];
}
/**
* Simplified hook for basic cancellation functionality
*/
export function useTaskCancellation() {
const [taskId, setTaskId] = useState<string | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
const cancel = useCallback(async (description: string = 'task'): Promise<boolean> => {
if (!taskId || isCancelling) {
return false;
}
setIsCancelling(true);
const success = await cancelTaskWithFeedback(taskId, description);
setIsCancelling(false);
if (success) {
setTaskId(null);
}
return success;
}, [taskId, isCancelling]);
const reset = useCallback(() => {
setTaskId(null);
setIsCancelling(false);
}, []);
return {
taskId,
setTaskId,
isCancelling,
cancel,
reset,
};
}

View file

@ -283,11 +283,13 @@ export const aiPersonasApi = {
count: number = 5,
temperature: number = 0.7,
customerDataSessionId?: string,
llmModel?: string
llmModel?: string,
onTaskIdReceived?: (taskId: string) => void
) => {
try {
// Log the API call with model information
console.log(`📡 API call to generate-basic-profiles with model: ${llmModel || 'gemini-2.5-pro'}`);
console.log('🔥 onTaskIdReceived callback provided:', !!onTaskIdReceived);
// First stage: Generate basic profiles
const basicProfilesResponse = await api.post('/ai-personas/generate-basic-profiles', {
@ -301,7 +303,19 @@ export const aiPersonasApi = {
timeout: 600000 // 10 minutes for basic profile generation
});
console.log('🔥 First stage response:', basicProfilesResponse.data);
const basicProfiles = basicProfilesResponse.data.profiles;
const taskId = basicProfilesResponse.data.task_id; // Extract task_id from first call
console.log('🔥 Extracted taskId:', taskId);
// Call the callback immediately with the task ID
if (taskId && onTaskIdReceived) {
console.log('🔥 Calling onTaskIdReceived with taskId:', taskId);
onTaskIdReceived(taskId);
} else {
console.log('🔥 Not calling callback - taskId:', taskId, 'callback:', !!onTaskIdReceived);
}
const personas = [];
const personaIds = [];
const errors = [];
@ -353,6 +367,7 @@ export const aiPersonasApi = {
message: `Generated and saved ${personas.length} personas${errors.length > 0 ? ` (${errors.length} failed)` : ''}`,
personas,
persona_ids: personaIds,
task_id: taskId,
errors: errors.length > 0 ? errors : undefined,
partial_success: errors.length > 0 && personas.length > 0
}
@ -407,7 +422,32 @@ export const aiPersonasApi = {
// Clean up customer data files for a session
cleanupCustomerData: (sessionId: string) =>
api.delete(`/ai-personas/cleanup-customer-data/${sessionId}`)
api.delete(`/ai-personas/cleanup-customer-data/${sessionId}`),
// Unified persona generation endpoint with cancellation support
generatePersonasFull: async (
audienceBrief: string,
researchObjective?: string,
count: number = 5,
temperature: number = 0.8,
customerDataSessionId?: string,
llmModel?: string,
targetFolderId?: string
) => {
console.log(`📡 API call to generate-personas-full with model: ${llmModel || 'gemini-2.5-pro'}`);
return api.post('/ai-personas/generate-personas-full', {
audience_brief: audienceBrief,
research_objective: researchObjective,
count,
temperature,
customer_data_session_id: customerDataSessionId,
llm_model: llmModel || 'gemini-2.5-pro',
target_folder_id: targetFolderId
}, {
timeout: 1200000 // 20 minutes for complete generation
});
}
};
// Focus Groups endpoints

View file

@ -31,6 +31,8 @@ import QuickNoteModal from '@/components/focus-group-session/QuickNoteModal';
import { FocusGroup, Message, Theme, Note, QuoteData, ModeEvent } from '@/components/focus-group-session/types';
import { Persona } from '@/types/persona';
import api, { focusGroupsApi, personasApi, focusGroupAiApi } from '@/lib/api';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
// GPT-5 FIX: Use new singleton WebSocket service
import { initSocket, joinFocusGroup, leaveFocusGroup } from '@/services/websocketServiceNew';
@ -80,10 +82,9 @@ const FocusGroupSession = () => {
// Participant filtering state
const [selectedParticipantIds, setSelectedParticipantIds] = useState<string[]>([]);
// Theme generation progress state
const [isThemeGenerating, setIsThemeGenerating] = useState(false);
const [themeGenerationComplete, setThemeGenerationComplete] = useState(false);
const [themeGenerationError, setThemeGenerationError] = useState(false);
// Cancellable generation for key themes
const socket = getSocket();
const [themeGenerationState, themeGenerationControls] = useCancellableGeneration('key themes generation', socket);
// WebSocket status bar visibility
const [isStatusBarVisible, setIsStatusBarVisible] = useState(true);
@ -1677,9 +1678,7 @@ const FocusGroupSession = () => {
if (!id) return;
// Reset states
setIsThemeGenerating(true);
setThemeGenerationComplete(false);
setThemeGenerationError(false);
themeGenerationControls.startGeneration();
toastService.info("Analyzing discussion for key themes...", {
description: "This may take a moment as we process the entire conversation."
@ -1719,9 +1718,7 @@ const FocusGroupSession = () => {
};
const handleThemeProgressComplete = () => {
setIsThemeGenerating(false);
setThemeGenerationComplete(false);
setThemeGenerationError(false);
themeGenerationControls.resetGeneration();
};
const handleOpenNoteModal = () => {
@ -1967,14 +1964,18 @@ const FocusGroupSession = () => {
</div>
{/* Progress Bar - Consistent top placement for theme generation */}
{isThemeGenerating && (
{themeGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={isThemeGenerating}
isComplete={themeGenerationComplete}
hasError={themeGenerationError}
isActive={themeGenerationState.isGenerating}
isComplete={themeGenerationState.isComplete}
hasError={themeGenerationState.hasError}
isCancelling={themeGenerationState.isCancelling}
label="Analyzing discussion for key themes"
onComplete={handleThemeProgressComplete}
onCancel={themeGenerationControls.cancelGeneration}
taskId={themeGenerationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
/>
</div>

View file

@ -39,6 +39,8 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Persona } from '@/types/persona';
import { usePersonaStorage } from '@/hooks/usePersonaStorage';
import { useCancellableGeneration } from '@/hooks/useCancellableGeneration';
import { getSocket } from '@/services/websocketServiceNew';
import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
@ -82,6 +84,10 @@ const SyntheticUsers = () => {
const [mode, setMode] = useState<'view' | 'create'>('view');
const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai');
// WebSocket and cancellable generation for summary generation
const socket = getSocket();
const [summaryGenerationState, summaryGenerationControls] = useCancellableGeneration('persona summary generation', socket);
const [searchTerm, setSearchTerm] = useState('');
const [selectedUser, setSelectedUser] = useState<Persona | null>(null);
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
@ -134,19 +140,13 @@ const SyntheticUsers = () => {
ethnicity: [],
folderStatus: [],
});
// Progress monitoring state for persona summary generation
const [isSummaryGenerating, setIsSummaryGenerating] = useState(false);
const [summaryGenerationComplete, setSummaryGenerationComplete] = useState(false);
const [summaryGenerationError, setSummaryGenerationError] = useState(false);
// LLM selection for download
const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false);
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gemini-2.5-pro');
// Handle summary generation progress completion
const handleSummaryProgressComplete = () => {
setIsSummaryGenerating(false);
setSummaryGenerationComplete(false);
setSummaryGenerationError(false);
summaryGenerationControls.resetGeneration();
};
// Handle navigation to persona details from synthetic users list
@ -949,10 +949,8 @@ const SyntheticUsers = () => {
// Close modal
setDownloadLlmModalOpen(false);
// Reset progress states and start generation
setIsSummaryGenerating(true);
setSummaryGenerationComplete(false);
setSummaryGenerationError(false);
// Start cancellable generation
summaryGenerationControls.startGeneration();
setIsLoading(true);
try {
@ -963,7 +961,12 @@ const SyntheticUsers = () => {
// Call the new API endpoint for batch summary generation
const response = await aiPersonasApi.batchGenerateSummaries(personaIds, 0.7, selectedDownloadLlmModel);
const { summaries, summary_stats, errors } = response.data;
const { summaries, summary_stats, errors, task_id } = response.data;
// Set task ID for cancellation
if (task_id) {
summaryGenerationControls.setTaskId(task_id);
}
// Generate markdown content from LLM-processed summaries
const currentDate = new Date().toISOString().split('T')[0];
@ -1026,7 +1029,7 @@ const SyntheticUsers = () => {
document.body.removeChild(element);
// Mark generation as complete
setSummaryGenerationComplete(true);
summaryGenerationControls.completeGeneration();
// Show success toast with details including model information
const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 2.5 Pro';
@ -1041,21 +1044,15 @@ const SyntheticUsers = () => {
}
} catch (error) {
console.error("Error generating persona summaries:", error);
// Log detailed error information
if (error.response) {
console.error("Error response data:", error.response.data);
console.error("Error response status:", error.response.status);
console.error("Error response headers:", error.response.headers);
} else if (error.request) {
console.error("Error request:", error.request);
} else {
console.error("Error message:", error.message);
// Check if this was a cancellation (expected behavior)
if (error.response?.status === 499) {
return;
}
console.error("Error generating persona summaries:", error);
// Mark generation as failed
setSummaryGenerationError(true);
summaryGenerationControls.failGeneration(error.message || 'Failed to generate summaries');
// Fall back to basic summary if API fails
toastService.error("AI summary generation failed, creating basic summary", {
@ -1102,11 +1099,11 @@ const SyntheticUsers = () => {
<Button
variant="outline"
onClick={downloadPersonaSummary}
disabled={isSummaryGenerating}
disabled={summaryGenerationState.isGenerating}
className="flex items-center gap-2 hover-transition"
>
<Download className="h-4 w-4" />
{isSummaryGenerating ? 'Generating Summary...' : 'Download Persona Summary'}
{summaryGenerationState.isGenerating ? 'Generating Summary...' : 'Download Persona Summary'}
</Button>
)}
<Button
@ -1121,14 +1118,18 @@ const SyntheticUsers = () => {
</div>
{/* Progress Bar - Consistent top placement for all long-running operations */}
{mode === 'view' && filteredPersonas.length > 0 && isSummaryGenerating && (
{mode === 'view' && filteredPersonas.length > 0 && summaryGenerationState.isGenerating && (
<div className="mb-6">
<GenerationProgressBar
isActive={isSummaryGenerating}
isComplete={summaryGenerationComplete}
hasError={summaryGenerationError}
isActive={summaryGenerationState.isGenerating}
isComplete={summaryGenerationState.isComplete}
hasError={summaryGenerationState.hasError}
isCancelling={summaryGenerationState.isCancelling}
label="Generating comprehensive persona summaries"
onComplete={handleSummaryProgressComplete}
onCancel={summaryGenerationControls.cancelGeneration}
taskId={summaryGenerationState.taskId}
showCancel={true}
className="max-w-4xl mx-auto"
/>
</div>

View file

@ -108,6 +108,26 @@ export function initSocket(getToken: () => string): Socket {
// Handle connected events if needed
break;
case 'task_started':
console.log('🔧 [GPT-5] *** ROUTING task_started from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_started", { detail: payload }));
break;
case 'task_cancelled':
console.log('🔧 [GPT-5] *** ROUTING task_cancelled from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_cancelled", { detail: payload }));
break;
case 'task_completed':
console.log('🔧 [GPT-5] *** ROUTING task_completed from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_completed", { detail: payload }));
break;
case 'task_failed':
console.log('🔧 [GPT-5] *** ROUTING task_failed from onAny ***');
window.dispatchEvent(new CustomEvent("ws:task_failed", { detail: payload }));
break;
case 'error':
console.error('🔧 [GPT-5] *** ROUTING error from onAny ***', payload);
break;

93
src/types/cancellable.ts Normal file
View file

@ -0,0 +1,93 @@
/**
* Type definitions for cancellable operations and task management.
*/
export interface CancellableResponse<T = any> {
task_id: string;
[key: string]: any;
}
export interface TaskCancellationRequest {
task_id: string;
}
export interface TaskCancellationResponse {
success: boolean;
message?: string;
error?: string;
task_id: string;
task_type?: string;
}
export interface WebSocketTaskEvent {
type: 'task_cancelled' | 'task_completed' | 'task_failed';
task_id: string;
task_type: string;
message: string;
data?: any;
}
export interface CancellableGenerationHookState {
isGenerating: boolean;
isCancelling: boolean;
hasError: boolean;
isComplete: boolean;
taskId: string | null;
error: string | null;
}
export interface GenerationProgressProps {
isActive: boolean;
isComplete: boolean;
hasError: boolean;
isCancelling: boolean;
label?: string;
onComplete?: () => void;
onCancel?: () => void;
showCancel?: boolean;
taskId?: string;
className?: string;
}
// Common response interfaces for generation endpoints
export interface PersonaGenerationResponse extends CancellableResponse {
message: string;
personas?: any[];
persona?: any;
profiles?: any[];
}
export interface DiscussionGuideResponse extends CancellableResponse {
message: string;
discussionGuide: any;
success: boolean;
}
export interface KeyThemesResponse extends CancellableResponse {
message: string;
themes: any[];
focus_group_id: string;
}
export interface PersonaModificationResponse extends CancellableResponse {
success: boolean;
message: string;
persona: any;
preview_only: boolean;
}
export interface SummaryGenerationResponse extends CancellableResponse {
message: string;
summaries: any[];
summary_stats: {
total_requested: number;
total_found: number;
total_successful: number;
total_failed: number;
missing_personas: number;
};
errors?: {
failed_summaries: any[];
missing_personas: string[];
};
}

View file

@ -21,8 +21,10 @@ export async function generateSyntheticPersonas(
count: number,
file?: FileList,
targetFolderId?: string | null,
llmModel?: string
): Promise<Persona[]> {
llmModel?: string,
onTaskIdReceived?: (taskId: string) => void,
temperature?: number
): Promise<{personas: Persona[], task_id?: string, partial_success?: boolean, errors?: any[]}> {
// Debug logging for folder and model
console.log(`generateSyntheticPersonas called with targetFolderId: ${targetFolderId || 'none'}`);
console.log(`🔄 generateSyntheticPersonas using model: ${llmModel || 'gemini-2.5-pro'}`);
@ -50,116 +52,45 @@ export async function generateSyntheticPersonas(
}
}
// Use the batchGenerateWithStages helper that implements the two-stage process
const response = await aiPersonasApi.batchGenerateWithStages(
brief, // Pass the full audience brief for context
researchObjective, // Pass the research objective for focused goals and scenarios
count, // Number of personas to generate
0.8, // Temperature - slightly higher for more creativity
customerDataSessionId, // Pass customer data session ID if available
llmModel // Pass the LLM model selection
// Use the new unified generation endpoint
console.log('🔥 Calling generatePersonasFull...');
console.log(`🌡️ Using temperature: ${temperature || 1.0}`);
const response = await aiPersonasApi.generatePersonasFull(
brief, // Audience brief
researchObjective, // Research objective
count, // Number of personas to generate
temperature || 1.0, // Temperature (default to 1.0 if not specified)
customerDataSessionId, // Customer data session ID
llmModel, // LLM model
targetFolderId // Target folder ID
);
// Call the task ID callback immediately since unified endpoint returns task_id right away
if (response.data.task_id && onTaskIdReceived) {
console.log('🔥 Unified endpoint - calling onTaskIdReceived with:', response.data.task_id);
onTaskIdReceived(response.data.task_id);
}
if (response.data) {
// Handle partial success (some personas succeeded, some failed)
const hasPartialSuccess = response.data.partial_success === true;
const hasPersonas = response.data.personas && response.data.personas.length > 0;
const hasErrors = response.data.errors && response.data.errors.length > 0;
// Include task_id in response if available
const result = {
personas: response.data.personas,
task_id: response.data.task_id,
partial_success: hasPartialSuccess,
errors: response.data.errors
};
if (hasPersonas) {
console.log(`Generated ${response.data.personas.length} personas with two-stage process${hasErrors ? ` (${response.data.errors.length} failed)` : ''}`);
console.log(`Generated ${response.data.personas.length} personas with unified endpoint${hasErrors ? ` (${response.data.errors.length} failed)` : ''}`);
// If a target folder ID is provided, add the personas to that folder using the new folder system
if (targetFolderId) {
const personas = response.data.personas as Persona[];
// Get the persona IDs
const personaIds = personas.map(persona => persona._id || persona.id).filter(Boolean);
console.log(`Adding ${personaIds.length} newly generated personas to folder: ${targetFolderId}`);
// Add personas to folder using the new folder API
try {
// Add all personas to the folder in batch
await foldersApi.addPersonasBatch(targetFolderId, personaIds);
console.log(`Added ${personaIds.length} newly generated personas to folder: ${targetFolderId}`);
} catch (error) {
console.error("Error adding personas to folder:", error);
// Continue anyway since the personas were created successfully
}
// Clean up customer data files if session was created
if (customerDataSessionId) {
try {
await aiPersonasApi.cleanupCustomerData(customerDataSessionId);
console.log(`Cleaned up customer data for session: ${customerDataSessionId}`);
} catch (cleanupError) {
console.warn('Failed to cleanup customer data:', cleanupError);
// Don't throw here, just log the warning
}
}
// Return the personas and include any error information
if (hasPartialSuccess || hasErrors) {
// Need to ensure our response structure matches what the component expects
return {
...response.data, // Keep the original structure including personas
length: personas.length
};
}
// Return in the expected format
return {
...response.data,
personas: personas
};
}
// Clean up customer data files if session was created
if (customerDataSessionId) {
try {
await aiPersonasApi.cleanupCustomerData(customerDataSessionId);
console.log(`Cleaned up customer data for session: ${customerDataSessionId}`);
} catch (cleanupError) {
console.warn('Failed to cleanup customer data:', cleanupError);
// Don't throw here, just log the warning
}
}
// Return the personas including any error information
if (hasPartialSuccess || hasErrors) {
return {
...response.data.personas,
length: response.data.personas.length,
partial_success: hasPartialSuccess,
errors: response.data.errors
};
}
// Clean up customer data files if session was created
if (customerDataSessionId) {
try {
await aiPersonasApi.cleanupCustomerData(customerDataSessionId);
console.log(`Cleaned up customer data for session: ${customerDataSessionId}`);
} catch (cleanupError) {
console.warn('Failed to cleanup customer data:', cleanupError);
// Don't throw here, just log the warning
}
}
return response.data.personas as Persona[];
// Unified endpoint handles folder assignment and cleanup internally
return result;
} else if (hasErrors) {
// Clean up customer data files if session was created
if (customerDataSessionId) {
try {
await aiPersonasApi.cleanupCustomerData(customerDataSessionId);
console.log(`Cleaned up customer data for session: ${customerDataSessionId}`);
} catch (cleanupError) {
console.warn('Failed to cleanup customer data:', cleanupError);
}
}
// If we have errors but no personas, throw an error
throw new Error(`Failed to generate personas: ${response.data.errors.length} generation attempts failed.`);
} else {
@ -169,17 +100,12 @@ export async function generateSyntheticPersonas(
throw new Error("Invalid response format from API");
}
} catch (error) {
// Clean up customer data files if session was created
if (customerDataSessionId) {
try {
await aiPersonasApi.cleanupCustomerData(customerDataSessionId);
console.log(`Cleaned up customer data for session: ${customerDataSessionId}`);
} catch (cleanupError) {
console.warn('Failed to cleanup customer data:', cleanupError);
}
}
// Note: Cleanup is now handled by the unified backend endpoint
console.error("Error generating AI personas:", error);
// Don't log 499 errors as they are successful cancellations
if (error.response?.status !== 499) {
console.error("Error generating AI personas:", error);
}
throw error;
}
}