From aa4090888d76f9146d3a10e35f47a478bd6bfc9f Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 14:00:07 +0000 Subject: [PATCH] Fix persona generation 504: async flow + remove debug logging - Backend: /generate-personas-full now returns task_id immediately (202) and runs generation as a background asyncio task, delivering results via WebSocket task_completed event (bypasses GCP LB 30s timeout) - Frontend: AIRecruiter listens for ws:task_completed to process personas instead of awaiting the long HTTP response - Remove 53 debug console.log calls from websocketServiceNew.ts including session_id exposure and a self-test emit that was firing fake events - Remove debug logs from WebSocketContextNew, AIRecruiter, personaGenerator Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/ai_personas.py | 342 ++++++++++++--------------- src/components/AIRecruiter.tsx | 219 +++++++---------- src/contexts/WebSocketContextNew.tsx | 12 +- src/lib/api.ts | 2 +- src/services/websocketServiceNew.ts | 215 ++--------------- src/utils/personaGenerator.ts | 126 ++++------ 6 files changed, 301 insertions(+), 615 deletions(-) diff --git a/backend/app/routes/ai_personas.py b/backend/app/routes/ai_personas.py index 5d6d92aa..322c964c 100755 --- a/backend/app/routes/ai_personas.py +++ b/backend/app/routes/ai_personas.py @@ -1016,108 +1016,48 @@ 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-3-pro-preview", - "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-3-pro-preview') - 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}") +async def _run_persona_generation_bg( + app, + task_id: str, + user_id: str, + audience_brief: str, + research_objective, + count: int, + temperature: float, + customer_data_session_id, + llm_model: str, + target_folder_id +): + """Background coroutine: generates personas and notifies via WebSocket.""" + from app.websocket_manager_async import get_async_websocket_manager + websocket_manager = get_async_websocket_manager() + + async with app.app_context(): + try: + # Stage 1 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 + temperature=temperature, 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 + + # Stage 2 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") - + raise asyncio.CancelledError() + 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, @@ -1127,146 +1067,168 @@ async def generate_personas_full(): research_objective=research_objective ) - # Check cancellation before summary generation if asyncio.current_task().cancelled(): - raise asyncio.CancelledError("Task was cancelled") - - # Generate AI summary + raise asyncio.CancelledError() + 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 + app.logger.warning(f"Failed to generate summary for persona: {str(summary_error)}") + 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 + raise asyncio.CancelledError() + 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 + raise 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 - + return {'success': False, 'index': i, 'name': basic_profile.get('name', f'Persona {i+1}'), 'error': str(persona_error)} + + results = await asyncio.gather( + *[complete_single_persona(i, p) for i, p in enumerate(basic_profiles)], + return_exceptions=True + ) + + for result in results: + if isinstance(result, asyncio.CancelledError): + raise result + 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']}) + 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 + + # Folder assignment 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}") + await Folder.add_personas_batch(target_folder_id, [p['_id'] for p in completed_personas]) except Exception as folder_error: - current_app.logger.warning(f"Failed to add personas to folder: {folder_error}") - - # Clean up customer data if provided + app.logger.warning(f"Failed to add personas to folder: {folder_error}") + + # Customer data cleanup 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}") - + except Exception: + pass + 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 + + 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': completed_personas, + 'errors': failed_personas if failed_personas else None, + 'partial_success': len(failed_personas) > 0 and len(completed_personas) > 0 + }) + + except asyncio.CancelledError: + app.logger.info(f"Persona generation task {task_id} cancelled") + await websocket_manager.emit_to_user(user_id, 'task_cancelled', { + 'task_id': task_id, + 'task_type': 'persona_full_generation', + 'message': 'Generation cancelled' + }) + except Exception as e: + app.logger.error(f"Persona generation task {task_id} failed: {str(e)}") + await websocket_manager.emit_to_user(user_id, 'task_failed', { + 'task_id': task_id, + 'task_type': 'persona_full_generation', + 'message': str(e) + }) + + +@ai_personas_bp.route('/generate-personas-full', methods=['POST']) +@jwt_required() +async def generate_personas_full(): + """ + Async persona generation: returns task_id immediately (202), + delivers results via WebSocket task_completed event. + """ + user_id = get_jwt_identity() + data = await request.get_json() or {} + + 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-3-pro-preview') + target_folder_id = data.get('target_folder_id') + + try: + from app.services.task_manager import get_task_manager + from app.websocket_manager_async import get_async_websocket_manager + + task_manager = get_task_manager() + task_id = task_manager.generate_task_id() + app = current_app._get_current_object() + + bg_task = asyncio.create_task( + _run_persona_generation_bg( + app, task_id, user_id, + audience_brief, research_objective, + count, temperature, customer_data_session_id, + llm_model, target_folder_id + ) + ) + await task_manager.register_task(bg_task, 'persona_full_generation', user_id, { + 'count': count, + 'llm_model': llm_model, + 'target_folder_id': target_folder_id + }, task_id=task_id) + + current_app.logger.info(f"Starting unified persona generation for {count} personas using {llm_model}") + + websocket_manager = get_async_websocket_manager() + 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' + }) + + return jsonify({ + 'task_id': task_id, + 'message': f'Generation started for {count} personas' + }), 202 + 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 + current_app.logger.error(f"Failed to start persona generation: {str(e)}") + return jsonify({"error": "Internal server error", "message": "Failed to start generation"}), 500 @ai_personas_bp.route('/upload-customer-data', methods=['POST']) diff --git a/src/components/AIRecruiter.tsx b/src/components/AIRecruiter.tsx index 39ffd0db..2a63212f 100755 --- a/src/components/AIRecruiter.tsx +++ b/src/components/AIRecruiter.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { z } from "zod"; import { Users } from 'lucide-react'; import { toast } from 'sonner'; @@ -34,6 +34,8 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr const [generationToastId, setGenerationToastId] = useState(null); const [isProgressModalOpen, setIsProgressModalOpen] = useState(false); const [progressModalDescription, setProgressModalDescription] = useState(''); + // Ref to track current task_id in WebSocket event handlers (avoids stale closure) + const taskIdRef = useRef(null); // Check URL params for state restoration useEffect(() => { @@ -61,186 +63,127 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr } }, [generationState.isGenerating, generationState.isCancelling, generationToastId]); + // Handle WebSocket task completion — personas arrive here + useEffect(() => { + const handleTaskCompleted = (event: CustomEvent) => { + const data = event.detail; + if (data.task_id !== taskIdRef.current) return; + + generationControls.completeGeneration(); + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + + const personas: Persona[] = data.personas || []; + const errors = data.errors || []; + + if (personas.length > 0) { + if (data.partial_success && errors.length > 0) { + toast.success("Some personas generated successfully", { + description: `${personas.length} created, ${errors.length} failed.`, + duration: 8000 + }); + } else { + toast.success("Personas generated and saved successfully", { + description: `${personas.length} personas saved${targetFolderName ? ` to "${targetFolderName}"` : ''}.` + }); + } + setTimeout(() => navigate('/synthetic-users?mode=view'), 1500); + } else { + generationControls.failGeneration("No personas were generated"); + toast.error("Failed to generate personas", { description: "No personas were returned." }); + } + }; + + const handleTaskFailed = (event: CustomEvent) => { + const data = event.detail; + if (data.task_id !== taskIdRef.current) return; + + const errorMessage = data.message || "Please try again or adjust your parameters"; + generationControls.failGeneration(errorMessage); + if (generationToastId) { + toast.dismiss(generationToastId); + setGenerationToastId(null); + } + toast.error("Failed to generate personas", { description: errorMessage, duration: 6000 }); + }; + + window.addEventListener('ws:task_completed', handleTaskCompleted as EventListener); + window.addEventListener('ws:task_failed', handleTaskFailed as EventListener); + return () => { + window.removeEventListener('ws:task_completed', handleTaskCompleted as EventListener); + window.removeEventListener('ws:task_failed', handleTaskFailed as EventListener); + }; + }, [generationToastId, targetFolderName]); + async function onSubmit(values: z.infer) { try { - // 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); if (isNaN(count) || count < 1 || count > 10) { - toast.error("Invalid number of personas", { - description: "Please enter a number between 1 and 10" - }); + toast.error("Invalid number of personas", { description: "Please enter a number between 1 and 10" }); generationControls.resetGeneration(); return; } - // 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"; + count <= 4 ? "1-2 minutes" : + count <= 6 ? "2-3 minutes" : "3-5 minutes"; - // Open progress modal with description setProgressModalDescription(`Creating ${count} synthetic persona${count !== 1 ? 's' : ''} based on your criteria. Estimated time: ${estimatedTime}.`); setIsProgressModalOpen(true); const toastId = toast.info("Generating AI personas", { - description: `Creating ${count} synthetic personas based on your brief. This may take ${estimatedTime}. Please be patient.`, + description: `Creating ${count} synthetic personas. This may take ${estimatedTime}.`, duration: 10000 }); setGenerationToastId(toastId); - - // Show folder info in toast if available + if (targetFolderId && targetFolderName) { - console.log(`Target folder for new personas: ID=${targetFolderId}, Name=${targetFolderName}`); - toast.info(`Creating personas in "${targetFolderName}" folder`, { - duration: 3000 - }); - } else { - console.log("No target folder specified for new personas"); + toast.info(`Creating personas in "${targetFolderName}" folder`, { duration: 3000 }); } - - // Log which model is being used for generation - console.log(`🤖 Starting persona generation with model: ${values.llm_model || 'gemini-2.5-pro'}`); - - const response = await generateSyntheticPersonas( - values.audienceBrief, + + // Quick call — returns task_id immediately (202), generation runs in background + const { task_id } = await generateSyntheticPersonas( + values.audienceBrief, values.researchObjective, count, values.dataFile, targetFolderId, 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); + taskIdRef.current = 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; - - // 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 - toast.success("Some personas generated successfully", { - description: `${personas.length} synthetic personas were created using ${values.llm_model || 'Gemini 3 Pro'}. ${response.errors?.length || 0} failed due to timeout or other errors.`, - duration: 8000 - }); - - // Show details about the failures - if (response.errors && response.errors.length > 0) { - setTimeout(() => { - toast.error("Some personas failed to generate", { - description: `${response.errors.length} personas timed out. The server took too long to generate them. The successfully generated personas have been saved${targetFolderId ? ` in the selected folder` : ''}.`, - duration: 10000 - }); - }, 1000); - } - } else { - // All personas succeeded - toast.success("Personas generated and saved successfully", { - description: `${personas.length} synthetic personas have been created using ${values.llm_model || 'Gemini 3 Pro'} and saved ${targetFolderId ? `to the "${targetFolderName}" folder` : 'to the database'}.` - }); - } - - // Wait a moment before navigating to show completion - setTimeout(() => { - navigate('/synthetic-users?mode=view'); - }, 1500); - } else { - throw new Error("No personas were generated"); - } + ); + + taskIdRef.current = task_id; + generationControls.setTaskId(task_id); + } 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); - + if (error.response?.status === 499) return; + let errorMessage = "Please try again or adjust your parameters"; - let errorTitle = "Failed to generate personas"; - - // Check for specific error types - if (error.code === "ECONNABORTED" || error.message?.includes("timeout") || error.response?.status === 504) { - errorTitle = "Generation timeout"; - errorMessage = "AI persona generation timed out. This often happens when generating multiple complex personas. Try generating fewer personas (2-3) or try again later."; - } else if (error.response?.status === 500) { - errorTitle = "Server error"; - - // Try to extract the error message from the response if available - if (error.response?.data?.message) { - errorMessage = error.response.data.message; - } else if (error.response?.data?.error) { - errorMessage = error.response.data.error; - } else { - errorMessage = "The server encountered an error processing your request. Please try again later."; - } + let errorTitle = "Failed to start generation"; + + if (error.response?.status === 400) { + errorMessage = error.response.data?.message || errorMessage; } else if (error.response?.status === 401) { errorTitle = "Authentication required"; errorMessage = "Please log in to generate personas."; - } else if (error.message?.includes("504 Deadline Exceeded")) { - errorTitle = "Generation timeout"; - errorMessage = "The AI model took too long to generate personas. Try generating fewer personas or simplify your brief."; } else if (error instanceof Error) { 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 - }); + toast.error(errorTitle, { description: errorMessage, duration: 6000 }); } } diff --git a/src/contexts/WebSocketContextNew.tsx b/src/contexts/WebSocketContextNew.tsx index 5f2fadb5..b1dd26d9 100755 --- a/src/contexts/WebSocketContextNew.tsx +++ b/src/contexts/WebSocketContextNew.tsx @@ -24,25 +24,17 @@ export function WebSocketProvider({ children }: WebSocketProviderProps) { // GPT-5 FIX: Get token from localStorage directly as fallback const getAccessToken = () => { - const authToken = token || localStorage.getItem('auth_token'); - console.log('🔧 [GPT-5 Context] Getting token:', authToken ? 'Found' : 'Missing'); - return authToken || ''; + return token || localStorage.getItem('auth_token') || ''; }; useEffect(() => { if (!socketInitialized) { - console.log('🔧 [GPT-5 Context] Initializing singleton socket'); initSocket(getAccessToken); socketInitialized = true; } - // Only connect if we have a valid token - const currentToken = getAccessToken(); - if (currentToken) { - console.log('🔧 [GPT-5 Context] Connecting socket with token'); + if (getAccessToken()) { connectSocket(); - } else { - console.log('🔧 [GPT-5 Context] No token available, skipping WebSocket connection'); } return () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 0e589045..2fa38a48 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -453,7 +453,7 @@ export const aiPersonasApi = { llm_model: llmModel || 'gemini-2.5-pro', target_folder_id: targetFolderId }, { - timeout: 180000 // 3 minutes for complete generation + timeout: 10000 // 10 seconds — endpoint returns immediately with task_id }); } }; diff --git a/src/services/websocketServiceNew.ts b/src/services/websocketServiceNew.ts index 9b991981..d974c965 100755 --- a/src/services/websocketServiceNew.ts +++ b/src/services/websocketServiceNew.ts @@ -1,10 +1,9 @@ /** - * GPT-5 Singleton WebSocket Service - * Fixes listener binding issues by ensuring stable socket instance and re-binding on every connect + * Singleton WebSocket Service */ import { io, Socket } from "socket.io-client"; -const BASE_URL = import.meta.env.DEV +const BASE_URL = import.meta.env.DEV ? "http://localhost:5137" : (import.meta.env.VITE_WEBSOCKET_URL || window.location.origin); @@ -14,16 +13,12 @@ let socket: Socket | null = null; let currentRoom: string | null = null; let coreListenersBound = false; -// GPT-5 FIX: Pass token getter function to ensure fresh tokens on reconnect export function initSocket(getToken: () => string): Socket { if (socket) { - // Keep token fresh for future reconnects socket.io.opts.auth = { token: getToken() }; return socket; } - console.log('🔧 [GPT-5] Creating singleton socket:', BASE_URL, SOCKET_PATH); - socket = io(BASE_URL, { path: SOCKET_PATH, transports: ["websocket"], @@ -32,125 +27,77 @@ export function initSocket(getToken: () => string): Socket { timeout: 60000, pingInterval: 45000, pingTimeout: 120000, - // Using auth callback guarantees latest token on every (re)connect auth: (cb) => cb({ token: getToken() }), }); - // Always refresh token before reconnect attempts socket.io.on("reconnect_attempt", () => { - console.log('🔧 [GPT-5] Reconnect attempt - refreshing token'); socket!.io.opts.auth = { token: getToken() }; }); - // GPT-5 CRITICAL: Bind listeners on EVERY connect (initial + reconnects) - const onConnected = () => { - console.log('🔧 [GPT-5] Socket connected, rebinding listeners and rejoining room'); - bindCoreListeners(); // idempotent - if (currentRoom) rejoinRoom(); // keep room after reconnect - }; - socket.on("connect", onConnected); + socket.on("connect", () => { + bindCoreListeners(); + if (currentRoom) rejoinRoom(); + }); - // GPT-5 FIX: Route all events through onAny since specific listeners are broken socket.onAny((event, ...args) => { - console.log(`🔧 [GPT-5 onAny] ${event}:`, args); - - // Route to specific handlers manually since socket.on() doesn't work - const payload = args[0]; // First argument is always the payload - + const payload = args[0]; + switch (event) { case 'joined_focus_group': - console.log('🔧 [GPT-5] *** ROUTING joined_focus_group from onAny ***'); window.dispatchEvent(new CustomEvent("ws:joined_focus_group", { detail: payload })); break; - case 'left_focus_group': - console.log('🔧 [GPT-5] *** ROUTING left_focus_group from onAny ***'); window.dispatchEvent(new CustomEvent("ws:left_focus_group", { detail: payload })); break; - case 'message_update': - console.log('🔧 [GPT-5] *** ROUTING message_update from onAny ***'); - try { - window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload })); - console.log('🔧 [GPT-5] DISPATCHED window event ws:message_update SUCCESS (via onAny)'); - } catch (error) { - console.error('🔧 [GPT-5] ERROR dispatching window event (via onAny):', error); - } + window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload })); break; - case 'ai_status_update': - console.log('🔧 [GPT-5] *** ROUTING ai_status_update from onAny ***'); window.dispatchEvent(new CustomEvent("ws:ai_status_update", { detail: payload })); break; - case 'moderator_status_update': - console.log('🔧 [GPT-5] *** ROUTING moderator_status_update from onAny ***'); window.dispatchEvent(new CustomEvent("ws:moderator_status_update", { detail: payload })); break; - case 'theme_update': - console.log('🔧 [GPT-5] *** ROUTING theme_update from onAny ***'); window.dispatchEvent(new CustomEvent("ws:theme_update", { detail: payload })); break; - case 'focus_group_update': - console.log('🔧 [GPT-5] *** ROUTING focus_group_update from onAny ***'); window.dispatchEvent(new CustomEvent("ws:focus_group_update", { detail: payload })); break; - case 'mode_event_update': - console.log('🔧 [GPT-5] *** ROUTING mode_event_update from onAny ***'); window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload })); break; - - case 'connected': - console.log('🔧 [GPT-5] *** ROUTING connected from onAny ***'); - // 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 'bulk_export_progress': - console.log('🔧 [GPT-5] *** ROUTING bulk_export_progress from onAny ***'); window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload })); break; - case 'error': - console.error('🔧 [GPT-5] *** ROUTING error from onAny ***', payload); + console.error('[WebSocket] Error:', payload); break; - case 'auth_error': - console.error('🔧 [GPT-5] *** ROUTING auth_error from onAny ***', payload); + console.error('[WebSocket] Auth error'); window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); break; } }); - // Connection debugging socket.on("connect_error", (error) => { - console.error('🔧 [GPT-5] Connect error:', error); + console.error('[WebSocket] Connect error:', error.message); }); socket.on("disconnect", (reason) => { - console.log('🔧 [GPT-5] Disconnected:', reason); + if (import.meta.env.DEV) console.log('[WebSocket] Disconnected:', reason); }); return socket; @@ -158,179 +105,65 @@ export function initSocket(getToken: () => string): Socket { export function connectSocket() { if (socket && !socket.connected) { - console.log('🔧 [GPT-5] Connecting socket'); socket.connect(); } } export function disconnectSocket() { if (socket) { - console.log('🔧 [GPT-5] Disconnecting socket'); socket.disconnect(); currentRoom = null; } } export function joinFocusGroup(focus_group_id: string, ack?: (resp: any) => void) { - console.log('🔧 [GPT-5] Joining focus group:', focus_group_id); currentRoom = focus_group_id; - + if (!socket?.connected) { - console.log('🔧 [GPT-5] Socket not connected, will auto-rejoin on connect'); - // Force connection and try again connectSocket(); setTimeout(() => { if (socket?.connected) { - console.log('🔧 [GPT-5] Retrying join after connection established'); - socket.emit("join_focus_group", { focus_group_id }, (resp: any) => { - console.log('🔧 [GPT-5] join_focus_group RETRY ACK:', resp); - ack?.(resp); - }); - } else { - console.log('🔧 [GPT-5] Still not connected, will rejoin on next connect event'); + socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp)); } }, 1000); return; } - - socket.emit("join_focus_group", { focus_group_id }, (resp: any) => { - console.log('🔧 [GPT-5] join_focus_group ACK:', resp); - ack?.(resp); - }); + + socket.emit("join_focus_group", { focus_group_id }, (resp: any) => ack?.(resp)); } export function leaveFocusGroup(focus_group_id: string) { - console.log('🔧 [GPT-5] Leaving focus group:', focus_group_id); if (currentRoom === focus_group_id) { currentRoom = null; } - if (socket?.connected) { socket.emit("leave_focus_group", { focus_group_id }); } } -// GPT-5 FIX: Auto-rejoin room on reconnect function rejoinRoom() { if (!socket?.connected || !currentRoom) return; - - console.log('🔧 [GPT-5] Auto-rejoining room after reconnect:', currentRoom); socket.emit("join_focus_group", { focus_group_id: currentRoom }); } -// GPT-5 CRITICAL: Ensure we never lose handlers across reconnects or AI mode toggles function bindCoreListeners() { - if (!socket) { - console.log('🔧 [GPT-5] bindCoreListeners called but socket is null!'); - return; - } + if (!socket) return; - // GPT-5 FIX: Always rebind listeners on reconnect - don't let flag prevent rebinding - if (coreListenersBound) { - console.log('🔧 [GPT-5] Listeners already bound, but rebinding anyway for safety'); - } + // Already bound — listeners are re-added via onAny, no need to duplicate + if (coreListenersBound) return; - console.log('🔧 [GPT-5] bindCoreListeners called - socket exists, binding listeners'); - - // IMPORTANT: Use stable function references so off() later removes exactly these - const onJoined = (payload: any) => { - console.log('🔧 [GPT-5] joined_focus_group:', payload); - window.dispatchEvent(new CustomEvent("ws:joined_focus_group", { detail: payload })); - }; - - const onLeft = (payload: any) => { - console.log('🔧 [GPT-5] left_focus_group:', payload); - window.dispatchEvent(new CustomEvent("ws:left_focus_group", { detail: payload })); - }; - - const onMsg = (payload: any) => { - console.log('🔧 [GPT-5] *** MESSAGE_UPDATE LISTENER FIRED! ***'); - console.log('🔧 [GPT-5] message_update payload:', payload); - console.log('🔧 [GPT-5] DISPATCHING window event ws:message_update'); - try { - window.dispatchEvent(new CustomEvent("ws:message_update", { detail: payload })); - console.log('🔧 [GPT-5] DISPATCHED window event ws:message_update SUCCESS'); - } catch (error) { - console.error('🔧 [GPT-5] ERROR dispatching window event:', error); - } - }; - - const onAI = (payload: any) => { - console.log('🔧 [GPT-5] ai_status_update:', payload); - console.log('🔧 [GPT-5] DISPATCHING window event ws:ai_status_update'); - window.dispatchEvent(new CustomEvent("ws:ai_status_update", { detail: payload })); - console.log('🔧 [GPT-5] DISPATCHED window event ws:ai_status_update'); - }; - - const onMod = (payload: any) => { - console.log('🔧 [GPT-5] moderator_status_update:', payload); - window.dispatchEvent(new CustomEvent("ws:moderator_status_update", { detail: payload })); - }; - - const onTheme = (payload: any) => { - console.log('🔧 [GPT-5] theme_update:', payload); - window.dispatchEvent(new CustomEvent("ws:theme_update", { detail: payload })); - }; - - const onFG = (payload: any) => { - console.log('🔧 [GPT-5] focus_group_update:', payload); - window.dispatchEvent(new CustomEvent("ws:focus_group_update", { detail: payload })); - }; - - const onModeEvent = (payload: any) => { - console.log('🔧 [GPT-5] mode_event_update:', payload); - window.dispatchEvent(new CustomEvent("ws:mode_event_update", { detail: payload })); - }; - - const onAuthError = (payload: any) => { - console.error('🔧 [GPT-5] auth_error:', payload); + socket.on("auth_error", (payload: any) => { + console.error('[WebSocket] Auth error'); window.dispatchEvent(new CustomEvent("ws:auth_error", { detail: payload })); - }; - - const onBulkExportProgress = (payload: any) => { - console.log('🔧 [GPT-5] bulk_export_progress:', payload); - window.dispatchEvent(new CustomEvent("ws:bulk_export_progress", { detail: payload })); - }; - - // Bind all listeners - console.log('🔧 [GPT-5] BINDING specific listeners to socket'); - socket.on("joined_focus_group", onJoined); - socket.on("left_focus_group", onLeft); - socket.on("message_update", onMsg); - socket.on("ai_status_update", onAI); - socket.on("moderator_status_update", onMod); - socket.on("theme_update", onTheme); - socket.on("focus_group_update", onFG); - socket.on("mode_event_update", onModeEvent); - socket.on("auth_error", onAuthError); - socket.on("bulk_export_progress", onBulkExportProgress); - console.log('🔧 [GPT-5] BOUND specific listeners to socket'); - - // GPT-5 DEBUG: Verify listeners are actually attached - console.log('🔧 [GPT-5] Socket listeners after binding:', socket.listeners('message_update').length); - console.log('🔧 [GPT-5] Socket hasListeners message_update:', socket.hasListeners('message_update')); - - // GPT-5 TEST: Emit a test event to ourselves to verify binding - setTimeout(() => { - if (socket?.connected) { - console.log('🔧 [GPT-5] SELF-TEST: Emitting test event'); - (socket as any).emit('message_update', { test: 'self-emit-test' }); - } - }, 1000); - - // Handle connection events - socket.on("connected", (payload: any) => { - console.log('🔧 [GPT-5] connected:', payload); }); socket.on("error", (error: any) => { - console.error('🔧 [GPT-5] socket error:', error); + console.error('[WebSocket] Socket error:', error?.message || error); }); coreListenersBound = true; } -// Utility to get current socket (for debugging) export function getSocket() { return socket; } @@ -341,4 +174,4 @@ export function getSocketId() { export function isConnected() { return socket?.connected ?? false; -} \ No newline at end of file +} diff --git a/src/utils/personaGenerator.ts b/src/utils/personaGenerator.ts index 0a86c3fe..4467bfa8 100755 --- a/src/utils/personaGenerator.ts +++ b/src/utils/personaGenerator.ts @@ -15,99 +15,55 @@ import { aiPersonasApi, personasApi, foldersApi } from "@/lib/api"; * @param llmModel Optional LLM model to use for generation * @returns Array of generated personas */ +/** + * Start async persona generation. Returns task_id immediately (202). + * Results are delivered via WebSocket task_completed event. + */ export async function generateSyntheticPersonas( - brief: string, + brief: string, researchObjective?: string, - count: number, + count: number = 5, file?: FileList, targetFolderId?: string | null, 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-3-pro-preview'}`); - - try { - // We'll use the two-stage approach - console.log(`Generating ${count} synthetic personas using two-stage approach...`); - - // First, check if the brief is too short to be useful - if (brief.trim().length < 10) { - throw new Error("Audience brief is too short. Please provide more context for better persona generation."); - } - - // Upload customer data if files provided - let customerDataSessionId: string | undefined; - if (file && file.length > 0) { - console.log(`Uploading ${file.length} customer data files...`); - try { - const uploadResponse = await aiPersonasApi.uploadCustomerData(file); - customerDataSessionId = uploadResponse.data.session_id; - console.log(`Customer data uploaded with session ID: ${customerDataSessionId}`); - } catch (error) { - console.error('Failed to upload customer data:', error); - throw new Error("Failed to upload customer data files. Please try again."); - } - } - - // 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 unified endpoint${hasErrors ? ` (${response.data.errors.length} failed)` : ''}`); - - // Unified endpoint handles folder assignment and cleanup internally - return result; - } else if (hasErrors) { - // 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 { - throw new Error("No personas returned from API"); - } - } else { - throw new Error("Invalid response format from API"); - } - } catch (error) { - // Note: Cleanup is now handled by the unified backend endpoint - - // Don't log 499 errors as they are successful cancellations - if (error.response?.status !== 499) { - console.error("Error generating AI personas:", error); - } - throw error; +): Promise<{ task_id: string }> { + if (brief.trim().length < 10) { + throw new Error("Audience brief is too short. Please provide more context for better persona generation."); } + + let customerDataSessionId: string | undefined; + if (file && file.length > 0) { + try { + const uploadResponse = await aiPersonasApi.uploadCustomerData(file); + customerDataSessionId = uploadResponse.data.session_id; + } catch (error) { + console.error('Failed to upload customer data:', error); + throw new Error("Failed to upload customer data files. Please try again."); + } + } + + const response = await aiPersonasApi.generatePersonasFull( + brief, + researchObjective, + count, + temperature || 1.0, + customerDataSessionId, + llmModel, + targetFolderId + ); + + const task_id = response.data.task_id; + if (!task_id) { + throw new Error("No task_id returned from server"); + } + + if (onTaskIdReceived) { + onTaskIdReceived(task_id); + } + + return { task_id }; } /**