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:
parent
8288cb9f5e
commit
e29d2a0bb9
36 changed files with 2193 additions and 638 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
|
|
@ -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'])
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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()
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
129
backend/app/routes/tasks.py
Normal 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
|
||||
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
228
backend/app/services/task_manager.py
Normal file
228
backend/app/services/task_manager.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
278
src/hooks/useCancellableGeneration.ts
Normal file
278
src/hooks/useCancellableGeneration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
93
src/types/cancellable.ts
Normal 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[];
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue