diff --git a/backend/.env.example b/backend/.env.example index f3efd999..1983057f 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,5 @@ # MongoDB Configuration -MONGO_URI=mongodb://localhost:27017/semblance_db +MONGO_URI=mongodb://localhost:27017/cohorta_db # MongoDB auth (uncomment if your MongoDB requires authentication) # MONGO_USER=admin @@ -11,10 +11,17 @@ DEBUG=0 SECRET_KEY=REPLACE_WITH_RANDOM_SECRET JWT_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET -# AI API Keys -OPENAI_API_KEY=REPLACE_WITH_KEY -GEMINI_API_KEY=REPLACE_WITH_KEY +# Azure AI Foundry — base URL ending at /v1/ (do NOT include the operation path) +# Example: https://.services.ai.azure.com/api/projects//openai/v1/ +AZURE_AI_ENDPOINT=REPLACE_WITH_AZURE_ENDPOINT +AZURE_AI_API_KEY=REPLACE_WITH_AZURE_KEY +# Optional: override deployed model names (defaults below match the Foundry deployments) +AZURE_AI_MODEL_MAIN=gpt-5.4 +AZURE_AI_MODEL_MINI=gpt-5.4-mini -# Microsoft Azure (optional, for MS login) -# MSAL_TENANT_ID=your-tenant-id -# MSAL_CLIENT_ID=your-client-id +# CORS — comma-separated allowed origins +CORS_ALLOWED_ORIGINS=https://cohorta.ai-impress.com + +# Stripe (get from dashboard.stripe.com — use test keys locally) +STRIPE_SECRET_KEY=REPLACE_WITH_STRIPE_SECRET_KEY +STRIPE_WEBHOOK_SECRET=REPLACE_WITH_STRIPE_WEBHOOK_SECRET diff --git a/backend/app/__init__.py b/backend/app/__init__.py index dc2fbdd3..5a800b73 100755 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -94,7 +94,7 @@ def create_app(): # Initialize extensions — restrict CORS to known origins _allowed_origins = os.environ.get( 'CORS_ALLOWED_ORIGINS', - 'https://optical-dev.oliver.solutions' + 'https://cohorta.ai-impress.com' ) _origins = [o.strip() for o in _allowed_origins.split(',')] app = cors(app, allow_origin=_origins, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) @@ -149,6 +149,7 @@ def create_app(): from app.routes.tasks import tasks_bp from app.routes.admin import admin_bp from app.routes.usage import usage_bp + from app.routes.billing import billing_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(personas_bp, url_prefix='/api/personas') @@ -159,6 +160,7 @@ def create_app(): app.register_blueprint(tasks_bp, url_prefix='/api/tasks') app.register_blueprint(admin_bp, url_prefix='/api/admin') app.register_blueprint(usage_bp, url_prefix='/api/usage') + app.register_blueprint(billing_bp, url_prefix='/api/billing') @app.before_serving async def start_task_sweeper(): diff --git a/backend/app/db.py b/backend/app/db.py index 0bb9a4d5..c4f6a677 100755 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -32,13 +32,13 @@ async def get_db(): # Build URI: prefer MONGO_URI, fall back to host+port with optional credentials if not mongo_uri: if mongo_user and mongo_pass: - mongo_uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/semblance_db?authSource=admin" + mongo_uri = f"mongodb://{mongo_user}:{mongo_pass}@{mongo_host}:{mongo_port}/cohorta_db?authSource=admin" else: mongo_uri = f"mongodb://{mongo_host}:{mongo_port}" try: motor_client = AsyncIOMotorClient(mongo_uri, serverSelectionTimeoutMS=5000) - database = motor_client.semblance_db + database = motor_client.cohorta_db await database.command('ping') logging.info("Successfully connected to MongoDB") except Exception as e: diff --git a/backend/app/models/focus_group.py b/backend/app/models/focus_group.py index 14bd83ca..0ac828a7 100755 --- a/backend/app/models/focus_group.py +++ b/backend/app/models/focus_group.py @@ -52,7 +52,7 @@ class FocusGroup: # Set default LLM model if not provided if "llm_model" not in focus_group_data: - focus_group_data["llm_model"] = "gemini-3.1-pro-preview" + focus_group_data["llm_model"] = "gpt-5.4" # Set default GPT-5 parameters if not provided if "reasoning_effort" not in focus_group_data: diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c472f2c4..ada4705b 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -21,6 +21,8 @@ from app.utils import admin_required, make_serializable from app.models.user import User from app.models.usage_event import UsageEvent from app.models.model_pricing import ModelPricing +from app.models.credit_transaction import CreditTransaction +from app.models.app_settings import get_settings, update_settings from app.db import get_db logger = logging.getLogger(__name__) @@ -480,3 +482,164 @@ async def list_focus_groups(): result.sort(key=lambda x: x['cost_total'], reverse=True) total = await db.focus_groups.count_documents({}) return jsonify({'focus_groups': make_serializable(result), 'total': total}), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# App Settings (credit pricing config) +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/settings', methods=['GET']) +@jwt_required() +@admin_required +async def get_app_settings(): + settings = await get_settings() + return jsonify(make_serializable(settings)), 200 + + +@admin_bp.route('/settings', methods=['PUT']) +@jwt_required() +@admin_required +async def put_app_settings(): + data = await request.get_json() + allowed = {'persona_cost', 'run_cost', 'trial_grant', 'credit_packs'} + fields = {k: v for k, v in data.items() if k in allowed} + if not fields: + return jsonify({'message': 'No valid fields to update'}), 400 + updated = await update_settings(fields) + return jsonify(make_serializable(updated)), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Manual credit management +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/users//credits', methods=['POST']) +@jwt_required() +@admin_required +async def adjust_user_credits(user_id): + """Grant or deduct credits. amount>0 = grant, amount<0 = deduct.""" + admin_id = get_jwt_identity() + data = await request.get_json() + amount = data.get('amount') + reason = data.get('reason', 'Admin adjustment') + + if amount is None or not isinstance(amount, int) or amount == 0: + return jsonify({'message': 'amount must be a non-zero integer'}), 400 + + user = await User.find_by_id(user_id) + if not user: + return jsonify({'message': 'User not found'}), 404 + + if amount > 0: + new_balance = await User.grant_credits(user_id, amount) + tx_type = 'admin_grant' + else: + # Allow negative adjustment only if user has enough + abs_amount = abs(amount) + new_balance = await User.deduct_credits(user_id, abs_amount) + if new_balance is None: + return jsonify({'message': 'Insufficient credits to deduct'}), 400 + tx_type = 'debit' + + await CreditTransaction.record( + user_id=user_id, + tx_type=tx_type, + amount=amount, + balance_after=new_balance, + description=reason, + ref={'admin_id': admin_id}, + ) + return jsonify({'credits_balance': new_balance, 'adjustment': amount}), 200 + + +# ───────────────────────────────────────────────────────────────────────────── +# Analytics +# ───────────────────────────────────────────────────────────────────────────── + +@admin_bp.route('/analytics', methods=['GET']) +@jwt_required() +@admin_required +async def get_analytics(): + """Aggregate key business metrics.""" + from_str = request.args.get('from') + to_str = request.args.get('to') + period_filter = _period_match(from_str, to_str) + + db = await get_db() + + # User counts + total_users = await db.users.count_documents({}) + new_users = await db.users.count_documents( + {'created_at': period_filter} if period_filter else {} + ) + + # Focus group runs in period + fg_match = {'type': 'debit', 'description': {'$regex': 'Focus group'}} + if period_filter: + fg_match['ts'] = period_filter + run_count_agg = await db.credit_transactions.count_documents(fg_match) + + # Persona creations in period + persona_match = {'type': 'debit', 'description': {'$regex': 'persona'}} + if period_filter: + persona_match['ts'] = period_filter + persona_count = await db.credit_transactions.count_documents(persona_match) + + # Revenue: credits purchased (credit transactions of type 'purchase') + rev_match: dict = {'type': 'purchase'} + if period_filter: + rev_match['ts'] = period_filter + rev_agg = await db.credit_transactions.aggregate([ + {'$match': rev_match}, + {'$group': {'_id': None, 'total_credits': {'$sum': '$amount'}, 'count': {'$sum': 1}}}, + ]).to_list(1) + revenue_credits = rev_agg[0]['total_credits'] if rev_agg else 0 + purchase_count = rev_agg[0]['count'] if rev_agg else 0 + + # Cost (USD) from usage_events + usage_match: dict = {} + if period_filter: + usage_match['ts'] = period_filter + cost_agg = await db.usage_events.aggregate([ + {'$match': usage_match}, + {'$group': {'_id': None, 'total_cost': {'$sum': '$cost_usd.total'}}}, + ]).to_list(1) + total_cost_usd = cost_agg[0]['total_cost'] if cost_agg else 0 + + # Per-model breakdown + model_agg = await db.usage_events.aggregate([ + {'$match': usage_match}, + {'$group': { + '_id': '$model', + 'cost': {'$sum': '$cost_usd.total'}, + 'calls': {'$sum': 1}, + }}, + ]).to_list(20) + + # Daily credits purchased (last 30 days) + daily_pipeline = [ + {'$match': {'type': 'purchase'}}, + {'$group': { + '_id': {'$dateToString': {'format': '%Y-%m-%d', 'date': '$ts'}}, + 'credits': {'$sum': '$amount'}, + 'count': {'$sum': 1}, + }}, + {'$sort': {'_id': 1}}, + {'$limit': 30}, + ] + daily_purchases = await db.credit_transactions.aggregate(daily_pipeline).to_list(30) + + return jsonify({ + 'users': {'total': total_users, 'new_in_period': new_users}, + 'activity': { + 'focus_group_runs': run_count_agg, + 'personas_created': persona_count, + }, + 'revenue': { + 'credits_sold': revenue_credits, + 'purchase_count': purchase_count, + 'cost_usd': round(total_cost_usd, 4), + }, + 'model_breakdown': make_serializable(model_agg), + 'daily_purchases': make_serializable(daily_purchases), + }), 200 diff --git a/backend/app/routes/ai_personas.py b/backend/app/routes/ai_personas.py index d8ad039f..4912f37e 100755 --- a/backend/app/routes/ai_personas.py +++ b/backend/app/routes/ai_personas.py @@ -20,6 +20,9 @@ from app.services.ai_persona_service import ( 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 +from app.models.user import User +from app.models.credit_transaction import CreditTransaction +from app.models.app_settings import get_settings from app.utils.rate_limiter import rate_limit, ip_key from app.utils import active_required, with_user_context @@ -77,7 +80,7 @@ async def generate_basic_profiles(): temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter - llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default + llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default try: # Register current task for cancellation @@ -217,7 +220,7 @@ async def complete_and_save_persona(): temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') # Optional parameter - llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default + llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default # Get persona name for logging persona_name = basic_profile.get('name', 'Unknown') @@ -856,7 +859,7 @@ async def batch_generate_summaries(): if not (0 <= temperature <= 1.5): temperature = 1.0 - llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') # Optional parameter with default + llm_model = data.get('llm_model', 'gpt-5.4') # Optional parameter with default # Log the request with model information print(f"🔄 Backend: Received batch-generate-summaries request for {len(persona_ids)} personas with model: {llm_model}") @@ -1215,9 +1218,35 @@ async def generate_personas_full(): temperature = 1.0 customer_data_session_id = data.get('customer_data_session_id') - llm_model = data.get('llm_model', 'gemini-3.1-pro-preview') + llm_model = data.get('llm_model', 'gpt-5.4') target_folder_id = data.get('target_folder_id') + # Pre-flight credit check: N × persona_cost + settings = await get_settings() + persona_cost = settings.get("persona_cost", 2) + total_cost = count * persona_cost + user_data = await User.find_by_id(user_id) + balance = (user_data or {}).get("credits_balance", 0) + if balance < total_cost: + return jsonify({ + "error": "Insufficient credits", + "message": f"You need {total_cost} credits to generate {count} persona(s). Current balance: {balance}.", + "credits_required": total_cost, + "credits_balance": balance, + }), 402 + + # Deduct credits atomically + new_balance = await User.deduct_credits(user_id, total_cost) + if new_balance is None: + return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402 + await CreditTransaction.record( + user_id=user_id, + tx_type="debit", + amount=-total_cost, + balance_after=new_balance, + description=f"Generated {count} persona(s)", + ) + try: from app.services.task_manager import get_task_manager from app.websocket_manager_async import get_async_websocket_manager diff --git a/backend/app/routes/focus_group_ai.py b/backend/app/routes/focus_group_ai.py index 2fbe8f6b..de19dec6 100755 --- a/backend/app/routes/focus_group_ai.py +++ b/backend/app/routes/focus_group_ai.py @@ -30,6 +30,9 @@ from app.services.ai_runner_service import get_ai_runner from app.services.image_description_service import ImageDescriptionService, ImageDescriptionError from app.models.focus_group import FocusGroup from app.models.persona import Persona +from app.models.user import User +from app.models.credit_transaction import CreditTransaction +from app.models.app_settings import get_settings from app.utils.rate_limiter import rate_limit from app.utils import active_required, with_user_context @@ -188,8 +191,8 @@ Be genuine and specific in your feedback, drawing on your personal experiences a conversation_context=multimodal_context['conversation_context'], temperature=temperature, model_name=llm_model, - reasoning_effort=reasoning_effort if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None, - verbosity=verbosity if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None + reasoning_effort=reasoning_effort, + verbosity=verbosity ) else: response_text = await generate_persona_response( @@ -757,7 +760,32 @@ async def start_autonomous_conversation(focus_group_id): data = (await request.get_json()) or {} initial_prompt = data.get('initial_prompt') current_app.logger.info(f"Request data: {data}") - + + # Pre-flight credit check + run_user_id = get_jwt_identity() + settings = await get_settings() + run_cost = settings.get("run_cost", 40) + user_data = await User.find_by_id(run_user_id) + balance = (user_data or {}).get("credits_balance", 0) + if balance < run_cost: + return jsonify({ + "error": "Insufficient credits", + "message": f"You need {run_cost} credits to run a focus group session. Current balance: {balance}.", + "credits_required": run_cost, + "credits_balance": balance, + }), 402 + new_balance = await User.deduct_credits(run_user_id, run_cost) + if new_balance is None: + return jsonify({"error": "Insufficient credits", "message": "Credit balance changed. Please try again."}), 402 + await CreditTransaction.record( + user_id=run_user_id, + tx_type="debit", + amount=-run_cost, + balance_after=new_balance, + description=f"Focus group session run", + ref={"focus_group_id": focus_group_id}, + ) + # Create autonomous conversation controller current_app.logger.info("Creating AutonomousConversationController...") controller = AutonomousConversationController(focus_group_id, current_app.logger) diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index 0b01b0b1..223ebf47 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -898,7 +898,7 @@ def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None --- -*Exported from Semblance Synthetic Society*""" +*Exported from Cohorta*""" # Handle structured format if isinstance(discussion_guide, dict): @@ -965,7 +965,7 @@ def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None markdown += "---\n\n" - markdown += "*Exported from Semblance Synthetic Society*" + markdown += "*Exported from Cohorta*" return markdown # Fallback for unknown format @@ -982,7 +982,7 @@ Raw content: --- -*Exported from Semblance Synthetic Society*""" +*Exported from Cohorta*""" def format_discussion_item_markdown(item, index, item_type): """Format a discussion item (question or activity) as markdown.""" diff --git a/backend/app/routes/personas.py b/backend/app/routes/personas.py index 278a0dbc..b32bc2e3 100755 --- a/backend/app/routes/personas.py +++ b/backend/app/routes/personas.py @@ -163,7 +163,7 @@ async def modify_persona_with_ai(persona_id): Request body should include: - modification_prompt: Natural language description of desired changes - - llm_model: Model to use (defaults to 'gemini-3.1-pro-preview') + - llm_model: Model to use (defaults to 'gpt-5.4') - reasoning_effort: For GPT-5 (minimal, low, medium, high) - verbosity: For GPT-5 (low, medium, high) - preview_only: If true, returns modified data without saving to database (defaults to false) @@ -177,7 +177,7 @@ async def modify_persona_with_ai(persona_id): if not modification_prompt: return jsonify({"error": "modification_prompt is required"}), 400 - llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview') + llm_model = request_data.get('llm_model', 'gpt-5.4') reasoning_effort = request_data.get('reasoning_effort', 'medium') verbosity = request_data.get('verbosity', 'medium') preview_only = request_data.get('preview_only', False) @@ -250,7 +250,7 @@ async def export_persona_profile(persona_id): Returns 202 immediately; result delivered via WebSocket task_completed event. Request body can optionally include: - - llm_model: Model to use (defaults to 'gemini-3.1-pro-preview') + - llm_model: Model to use (defaults to 'gpt-5.4') - temperature: Temperature for generation (defaults to 0.3) """ try: @@ -259,7 +259,7 @@ async def export_persona_profile(persona_id): return jsonify({"error": "Persona not found"}), 404 request_data = await request.get_json() or {} - llm_model = request_data.get('llm_model', 'gemini-3.1-pro-preview') + llm_model = request_data.get('llm_model', 'gpt-5.4') temperature = request_data.get('temperature', 0.3) user_id = get_jwt_identity() diff --git a/backend/app/services/ai_persona_service.py b/backend/app/services/ai_persona_service.py index d61cb0be..b60d84a5 100755 --- a/backend/app/services/ai_persona_service.py +++ b/backend/app/services/ai_persona_service.py @@ -1,7 +1,5 @@ """ -AI Persona Generation Service using Google's Gemini model. -This service handles the integration with the Gemini API to generate -synthetic persona data based on a predefined prompt. +AI Persona Generation Service — uses Azure AI Foundry (gpt-5.4 / gpt-5.4-mini) via llm_service. """ import os @@ -220,7 +218,7 @@ async def _generate_basic_personas_attempt( # Log the LLM API call with attempt number attempt_text = f" (attempt {attempt})" if attempt > 1 else "" - print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for basic persona generation{attempt_text}") + print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for basic persona generation{attempt_text}") raw_response = await LLMService.generate_content( prompt=final_prompt, @@ -519,7 +517,7 @@ async def generate_persona( # Log the LLM API call persona_name = basic_persona.get('name', 'Unknown') if basic_persona else 'New Persona' - print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for detailed persona generation of '{persona_name}'") + print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for detailed persona generation of '{persona_name}'") persona_data = await LLMService.generate_structured_response( prompt=final_prompt, @@ -604,7 +602,7 @@ async def generate_persona_summary( # Log the LLM API call persona_name = persona_data.get('name', 'Unknown') - print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for summary generation of '{persona_name}'") + print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for summary generation of '{persona_name}'") raw_response = await LLMService.generate_content( prompt=final_prompt, @@ -709,7 +707,7 @@ async def generate_persona_download_summary( # Log the LLM API call persona_name = persona_data.get('name', 'Unknown') - print(f"🤖 Backend: Making LLM API call to {llm_model or 'gemini-3-pro-preview'} for download summary of '{persona_name}'") + print(f"🤖 Backend: Making LLM API call to {llm_model or 'gpt-5.4'} for download summary of '{persona_name}'") # Generate the markdown content directly markdown_response = await LLMService.generate_content( diff --git a/backend/app/services/autonomous_conversation_controller.py b/backend/app/services/autonomous_conversation_controller.py index e3cf9ba4..fb8c09ca 100755 --- a/backend/app/services/autonomous_conversation_controller.py +++ b/backend/app/services/autonomous_conversation_controller.py @@ -691,7 +691,7 @@ class AutonomousConversationController: llm_model = focus_group.get('llm_model') reasoning_effort = focus_group.get('reasoning_effort', 'medium') verbosity = focus_group.get('verbosity', 'medium') - self.logger.info(f"🤖 Autonomous conversation using model: {llm_model or 'default (gemini-3-pro-preview)'} for focus group {self.focus_group_id}") + self.logger.info(f"🤖 Autonomous conversation using model: {llm_model or 'default (gpt-5.4)'} for focus group {self.focus_group_id}") # Get recent messages messages = await FocusGroup.get_messages(self.focus_group_id) diff --git a/backend/app/services/customer_data_service.py b/backend/app/services/customer_data_service.py index 198e7d7b..c8d4380e 100755 --- a/backend/app/services/customer_data_service.py +++ b/backend/app/services/customer_data_service.py @@ -191,5 +191,13 @@ class CustomerDataService: return True # Nothing to clean up -# Global service instance -customer_data_service = CustomerDataService() \ No newline at end of file +class _UnavailableService: + """Stub returned when llama-cloud-services is not installed.""" + def __getattr__(self, name): + raise CustomerDataServiceError("llama-cloud-services package not installed. File parsing unavailable.") + + +if LlamaParse: + customer_data_service = CustomerDataService() +else: + customer_data_service = _UnavailableService() \ No newline at end of file diff --git a/backend/app/services/focus_group_service.py b/backend/app/services/focus_group_service.py index 36f3eef5..e4fcc2c3 100755 --- a/backend/app/services/focus_group_service.py +++ b/backend/app/services/focus_group_service.py @@ -224,7 +224,7 @@ class FocusGroupService: 'content': question.get('content', 'No content')[:100] + '...' }) - logger.info(f"=== CREATIVE REVIEW VALIDATION RESULTS (Model: {llm_model or 'gemini-3-pro-preview'}) ===") + logger.info(f"=== CREATIVE REVIEW VALIDATION RESULTS (Model: {llm_model or 'gpt-5.4'}) ===") logger.info(f"Found {creative_review_count} creative_review activities for {len(uploaded_assets)} uploaded assets") if creative_review_activities: @@ -236,7 +236,7 @@ class FocusGroupService: # If no creative review activities were generated, retry with enhanced prompt if creative_review_count == 0: logger.warning(f"❌ WARNING: No creative_review activities generated despite {len(uploaded_assets)} uploaded assets!") - logger.warning(f"❌ This suggests {llm_model or 'gemini-3-pro-preview'} is not following the creative asset instructions") + logger.warning(f"❌ This suggests {llm_model or 'gpt-5.4'} is not following the creative asset instructions") # For GPT models, if this was already the enhanced prompt, we have a serious issue if llm_model and llm_model.startswith('gpt') and attempt < max_retries: diff --git a/backend/app/services/key_theme_service.py b/backend/app/services/key_theme_service.py index e9515880..a5af989f 100755 --- a/backend/app/services/key_theme_service.py +++ b/backend/app/services/key_theme_service.py @@ -107,7 +107,7 @@ class KeyThemeService: """ logger = logging.getLogger(__name__) logger.info(f"Beginning theme extraction from {len(messages)} messages") - logger.info(f"Theme extraction using LLM model: {llm_model or 'default (gemini-3-pro-preview)'}") + logger.info(f"Theme extraction using LLM model: {llm_model or 'default (gpt-5.4)'}") try: # Load and prepare the prompt for the LLM @@ -137,7 +137,7 @@ class KeyThemeService: for attempt in range(max_retries): attempt_num = attempt + 1 - logger.info(f"Attempt {attempt_num}/{max_retries}: Calling LLM ({llm_model or 'gemini-3-pro-preview'}) for theme generation") + logger.info(f"Attempt {attempt_num}/{max_retries}: Calling LLM ({llm_model or 'gpt-5.4'}) for theme generation") try: themes = await LLMService.generate_structured_array( @@ -147,7 +147,7 @@ class KeyThemeService: model_name=llm_model ) - logger.info(f"Attempt {attempt_num}/{max_retries}: LLM ({llm_model or 'gemini-3-pro-preview'}) call successful, received {len(themes)} themes") + logger.info(f"Attempt {attempt_num}/{max_retries}: LLM ({llm_model or 'gpt-5.4'}) call successful, received {len(themes)} themes") # Validate the response structure validated_themes = [] @@ -177,7 +177,7 @@ class KeyThemeService: validated_themes.append(validated_theme) - logger.info(f"Theme generation completed successfully with {len(validated_themes)} validated themes using {llm_model or 'gemini-3-pro-preview'}") + logger.info(f"Theme generation completed successfully with {len(validated_themes)} validated themes using {llm_model or 'gpt-5.4'}") return validated_themes except LLMServiceError as e: diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index 01014486..fc787570 100755 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -1,8 +1,9 @@ """ -LLM Service for Synthetic Society -This service provides a centralized interface for interacting with language models -through the Google Generative AI API. It supports various prompting functions for -different application features. +LLM Service for Cohorta — Azure AI Foundry (Responses API) + +All model calls route through a single Azure AI Foundry project endpoint. + gpt-5.4 — complex tasks: persona responses, moderator, detailed generation + gpt-5.4-mini — cheap tasks: summaries, key themes, conversation decisions, basic gen """ import os @@ -12,221 +13,156 @@ import logging import base64 import traceback import time -from google import genai -from google.genai import errors as genai_errors +import io from openai import AsyncOpenAI -import httpx from typing import Dict, Any, Optional, Union, List from PIL import Image -import io -# Set up API keys — must be set in environment, no hardcoded fallbacks + def _require_env(key: str) -> str: value = os.environ.get(key) if not value: - raise RuntimeError(f"Required environment variable '{key}' is not set. Set it in backend/.env before starting the server.") + raise RuntimeError( + f"Required environment variable '{key}' is not set. " + "Set it in backend/.env before starting the server." + ) return value -GEMINI_API_KEY = _require_env('GEMINI_API_KEY') -OPENAI_API_KEY = _require_env('OPENAI_API_KEY') + +AZURE_AI_ENDPOINT = _require_env('AZURE_AI_ENDPOINT') +AZURE_AI_API_KEY = _require_env('AZURE_AI_API_KEY') +AZURE_MODEL_MAIN = os.environ.get('AZURE_AI_MODEL_MAIN', 'gpt-5.4') +AZURE_MODEL_MINI = os.environ.get('AZURE_AI_MODEL_MINI', 'gpt-5.4-mini') + +# Features automatically routed to the cheaper mini model +MINI_FEATURES = frozenset({ + 'summary', + 'key_themes', + 'conversation_decision', + 'persona_basic', + 'discussion_guide', + 'audience_brief', +}) + +DEFAULT_MODEL = AZURE_MODEL_MAIN + +SUPPORTED_MODELS = { + AZURE_MODEL_MAIN: 'azure', + AZURE_MODEL_MINI: 'azure', +} + +# Legacy model IDs stored in the database — all map to the Azure main model +MODEL_ALIASES = { + 'gemini-3.1-pro-preview': AZURE_MODEL_MAIN, + 'gemini-3-pro-preview': AZURE_MODEL_MAIN, + 'gpt-5.4-2026-03-05': AZURE_MODEL_MAIN, + 'gpt-5': AZURE_MODEL_MAIN, + 'gpt-5.2': AZURE_MODEL_MAIN, + 'gpt-4.1': AZURE_MODEL_MAIN, +} -def get_gemini_client(): - """Create a fresh Gemini client for each call. +def get_azure_client() -> AsyncOpenAI: + """Create a fresh Azure AI Foundry client for each call. - Creating a new client per call avoids event loop mismatch issues that occur - when caching clients in ASGI environments where requests may come on different - event loops. The overhead is minimal compared to the LLM API call. + Creating a new client per call avoids event-loop mismatch issues in ASGI + environments where requests may arrive on different event loops. The + overhead is negligible compared to the LLM API call itself. - Force httpx transport to avoid aiohttp AssertionError (connector is None) - that occurs when aiohttp is installed in the environment via other packages. + The base URL must end with /v1/ so the SDK correctly appends operation + paths (e.g. 'responses' → .../v1/responses). """ - from google.genai import types as genai_types - return genai.Client( - api_key=GEMINI_API_KEY, - http_options=genai_types.HttpOptions( - httpx_async_client=httpx.AsyncClient(timeout=600.0) - ) + base_url = AZURE_AI_ENDPOINT.rstrip('/') + '/' + return AsyncOpenAI( + base_url=base_url, + api_key=AZURE_AI_API_KEY, + timeout=600.0, ) -def get_openai_client(): - """Create a fresh OpenAI client for each call. - - Creating a new client per call avoids event loop mismatch issues that occur - when caching clients in ASGI environments where requests may come on different - event loops. The overhead is minimal compared to the LLM API call. - """ - return AsyncOpenAI(api_key=OPENAI_API_KEY, timeout=600.0) - -# The default model we're using -DEFAULT_MODEL = "gemini-3.1-pro-preview" - -# Supported models -SUPPORTED_MODELS = { - 'gemini-3.1-pro-preview': 'gemini', - 'gpt-5.4-2026-03-05': 'openai', -} - -# Aliases for renamed/legacy model IDs stored in the database -MODEL_ALIASES = { - 'gpt-5': 'gpt-5.4-2026-03-05', - 'gpt-5.2': 'gpt-5.4-2026-03-05', - 'gemini-3-pro-preview': 'gemini-3.1-pro-preview', - 'gpt-4.1': 'gemini-3.1-pro-preview', -} - class LLMServiceError(Exception): - """Exception raised for errors in LLM operations.""" + """Raised for errors in LLM operations.""" pass + class LLMService: - """Centralized service for LLM operations.""" - + """Centralized service for LLM operations via Azure AI Foundry.""" + @staticmethod def _extract_responses_api_content(response) -> str: - """ - Extract text content from OpenAI Responses API response. - - Args: - response: The response object from OpenAI Responses API - - Returns: - The extracted text content - """ + """Extract text from an Azure / OpenAI Responses API response.""" result = "" - - # Try to extract from output structure if hasattr(response, 'output') and response.output: for item in response.output: if hasattr(item, 'content') and item.content is not None: for content in item.content: if hasattr(content, 'text'): result += content.text - - # Fallback to output_text if available if not result and hasattr(response, 'output_text'): result = response.output_text - - # Additional fallback - try direct text access if not result and hasattr(response, 'text'): result = response.text - return result.strip() - + @staticmethod def _resolve_model(model_name: Optional[str] = None) -> str: - """ - Resolve a model name, applying aliases for legacy/renamed models. + """Resolve a model name, applying feature-based mini routing. - Args: - model_name: Optional model name to use. Defaults to the default model. - - Returns: - The resolved model name + Resolution order: + 1. If model_name is one of the directly supported models, use it — + but still override to mini when the current feature is a mini feature. + 2. If model_name is a legacy alias, resolve it, then apply mini routing. + 3. If model_name is None or unknown, auto-route by feature context. """ - actual_model = model_name or DEFAULT_MODEL - return MODEL_ALIASES.get(actual_model, actual_model) + # Determine base model from the explicit argument + if model_name: + resolved = MODEL_ALIASES.get(model_name, model_name) + base = resolved if resolved in SUPPORTED_MODELS else DEFAULT_MODEL + else: + base = DEFAULT_MODEL + + # Feature override: mini features always get the cheaper model + try: + from app.services.llm_usage_context import current_context + ctx = current_context() + if ctx.feature in MINI_FEATURES: + return AZURE_MODEL_MINI + except Exception: + pass + + return base @staticmethod def _get_model_provider(model_name: Optional[str] = None) -> str: - """ - Get the provider for a given model name. - - Args: - model_name: Optional model name to use. Defaults to the default model. - - Returns: - The provider name ('gemini' or 'openai') - """ - actual_model = LLMService._resolve_model(model_name) - return SUPPORTED_MODELS.get(actual_model, 'gemini') - - - @staticmethod - def _extract_text_from_new_genai_response(response) -> str: - """ - Extract text from a new Google GenAI SDK response. - - Args: - response: The response object from the new Google GenAI SDK - - Returns: - The extracted text content - - Raises: - LLMServiceError: If no text content can be extracted - """ - try: - # New SDK has a simpler text attribute - if hasattr(response, 'text') and response.text: - return response.text.strip() - - # If that doesn't work, check for candidates structure - if hasattr(response, 'candidates') and response.candidates: - for candidate in response.candidates: - if hasattr(candidate, 'content') and candidate.content: - if hasattr(candidate.content, 'parts') and candidate.content.parts: - text_parts = [] - for part in candidate.content.parts: - if hasattr(part, 'text') and part.text: - text_parts.append(part.text) - if text_parts: - return ''.join(text_parts).strip() - - # If no text found, check if the response object has direct text content - if hasattr(response, 'content') and response.content: - return str(response.content).strip() - - raise LLMServiceError("Unable to extract text from new GenAI SDK response") - - except Exception as e: - if isinstance(e, LLMServiceError): - raise - raise LLMServiceError(f"Error extracting text from new GenAI SDK response: {str(e)}") + """Return the provider for the resolved model (always 'azure').""" + return 'azure' @staticmethod def _extract_usage_metadata(response, provider: str) -> dict: - """Extract token counts from a provider response. All fields default to 0.""" + """Extract token counts from a Responses API response. All fields default to 0.""" _log = logging.getLogger(__name__) - if provider == 'gemini': - um = getattr(response, 'usage_metadata', None) - if um is None: - _log.warning("Gemini response missing usage_metadata — token counts will be 0, cost recorded as $0") - return {'prompt': 0, 'completion': 0, 'cached': 0, 'reasoning': 0} - # thoughts_token_count (thinking models) is already included in candidates_token_count. - # Capture it separately so the stored event can show the split. - thoughts = getattr(um, 'thoughts_token_count', 0) or 0 + usage = getattr(response, 'usage', None) + if usage is None: + _log.warning("Azure response missing usage — token counts will be 0") + return {'prompt': 0, 'completion': 0, 'cached': 0, 'reasoning': 0} + if hasattr(usage, 'input_tokens'): + # Responses API shape + input_details = getattr(usage, 'input_tokens_details', None) + output_details = getattr(usage, 'output_tokens_details', None) return { - 'prompt': getattr(um, 'prompt_token_count', 0) or 0, - 'completion': getattr(um, 'candidates_token_count', 0) or 0, - 'cached': getattr(um, 'cached_content_token_count', 0) or 0, - 'reasoning': thoughts, + 'prompt': getattr(usage, 'input_tokens', 0) or 0, + 'completion': getattr(usage, 'output_tokens', 0) or 0, + 'cached': getattr(input_details, 'cached_tokens', 0) or 0 if input_details else 0, + 'reasoning': getattr(output_details, 'reasoning_tokens', 0) or 0 if output_details else 0, } - elif provider == 'openai': - usage = getattr(response, 'usage', None) - if usage is None: - _log.warning("OpenAI response missing usage — token counts will be 0, cost recorded as $0") - return {'prompt': 0, 'completion': 0, 'cached': 0, 'reasoning': 0} - # Responses API (gpt-5.4-2026-03-05) - if hasattr(usage, 'input_tokens'): - input_details = getattr(usage, 'input_tokens_details', None) - output_details = getattr(usage, 'output_tokens_details', None) - return { - 'prompt': getattr(usage, 'input_tokens', 0) or 0, - 'completion': getattr(usage, 'output_tokens', 0) or 0, - 'cached': getattr(input_details, 'cached_tokens', 0) or 0 if input_details else 0, - 'reasoning': getattr(output_details, 'reasoning_tokens', 0) or 0 if output_details else 0, - } - # Chat Completions API - prompt_details = getattr(usage, 'prompt_tokens_details', None) - return { - 'prompt': getattr(usage, 'prompt_tokens', 0) or 0, - 'completion': getattr(usage, 'completion_tokens', 0) or 0, - 'cached': getattr(prompt_details, 'cached_tokens', 0) or 0 if prompt_details else 0, - 'reasoning': 0, - } - return {'prompt': 0, 'completion': 0, 'cached': 0, 'reasoning': 0} + # Chat Completions API shape (fallback) + prompt_details = getattr(usage, 'prompt_tokens_details', None) + return { + 'prompt': getattr(usage, 'prompt_tokens', 0) or 0, + 'completion': getattr(usage, 'completion_tokens', 0) or 0, + 'cached': getattr(prompt_details, 'cached_tokens', 0) or 0 if prompt_details else 0, + 'reasoning': 0, + } @staticmethod async def _record_usage(response, provider: str, model: str, start_time: float, retry_count: int) -> None: @@ -266,7 +202,6 @@ class LLMService: task_id=ctx.task_id, ) - # Notify focus group room of cost delta (non-fatal) try: if ctx.focus_group_id: from app.models.focus_group import emit_websocket_event @@ -284,6 +219,24 @@ class LLMService: except Exception: logging.getLogger(__name__).warning("_record_usage failed (non-fatal)", exc_info=True) + @staticmethod + def _build_responses_kwargs( + actual_model: str, + input_content, + reasoning_effort: Optional[str] = None, + verbosity: Optional[str] = None, + ) -> dict: + """Build the kwargs dict for a Responses API call.""" + return { + "model": actual_model, + "input": input_content, + "reasoning": {"effort": reasoning_effort or "low"}, + "text": { + "format": {"type": "text"}, + "verbosity": verbosity or "medium", + }, + } + @staticmethod async def generate_content( prompt: str, @@ -294,241 +247,92 @@ class LLMService: reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None ) -> str: - """ - Generate content using the LLM with retry mechanism for transient errors. - + """Generate text content via the Azure AI Foundry Responses API. + Args: - prompt: The prompt to send to the model - temperature: Controls randomness (0.0 = deterministic, 1.0 = creative) - max_tokens: Maximum number of tokens to generate - model_name: Optional model name to use - system_prompt: Optional system prompt to define the role of the AI - reasoning_effort: GPT-5.2 only - Controls thinking time (minimal/low/medium/high) - verbosity: GPT-5.2 only - Controls response length (low/medium/high) - - Returns: - The generated text response - - Raises: - LLMServiceError: If there's an issue with the generation + prompt: The user prompt. + temperature: Ignored for Responses API (kept for interface compatibility). + max_tokens: Ignored for Responses API (kept for interface compatibility). + model_name: Override model. If None, auto-routes by feature context. + system_prompt: Optional system instruction prepended to the prompt. + reasoning_effort: Responses API reasoning effort (low/medium/high). + verbosity: Responses API verbosity (low/medium/high). """ logger = logging.getLogger(__name__) max_retries = 3 last_error = None - # Quota pre-flight — raises QuotaExceededError if over limit try: - from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.models.quota import check_quota, QuotaExceededError as _QEE from app.services.llm_usage_context import current_context as _ctx _c = _ctx() await check_quota(_c.user_id, _c.focus_group_id) except Exception as _qe: - from app.models.quota import QuotaExceededError as _QEE - if isinstance(_qe, _QEE): + from app.models.quota import QuotaExceededError as _QEE2 + if isinstance(_qe, _QEE2): raise - pass # Non-fatal: DB failures must not block LLM calls + pass actual_model = LLMService._resolve_model(model_name) - provider = LLMService._get_model_provider(model_name) _start_time = time.monotonic() + if system_prompt: + input_content = f"System: {system_prompt}\n\nUser: {prompt}" + else: + input_content = prompt + + kwargs = LLMService._build_responses_kwargs(actual_model, input_content, reasoning_effort, verbosity) + for attempt in range(max_retries): attempt_num = attempt + 1 - logger.debug(f"LLM content generation attempt {attempt_num}/{max_retries} using {provider} provider") - + logger.debug(f"LLM generate_content attempt {attempt_num}/{max_retries} model={actual_model}") try: - if provider == 'openai': - if actual_model == 'gpt-5.4-2026-03-05': - # Use OpenAI Responses API for gpt-5.4-2026-03-05 - input_content = prompt - if system_prompt: - input_content = f"System: {system_prompt}\n\nUser: {prompt}" - - kwargs = { - "model": actual_model, - "input": input_content, - } - - # Add reasoning configuration - reasoning_config = {} - if reasoning_effort: - reasoning_config["effort"] = reasoning_effort - else: - reasoning_config["effort"] = "low" # Default - kwargs["reasoning"] = reasoning_config - - # Add text configuration with verbosity - text_config = { - "format": {"type": "text"} - } - if verbosity: - text_config["verbosity"] = verbosity - else: - text_config["verbosity"] = "medium" # Default - kwargs["text"] = text_config - - # Note: GPT-5 Responses API does not support max_tokens parameter - - response = await get_openai_client().responses.create(**kwargs) - result = LLMService._extract_responses_api_content(response) - - else: - # Use Chat Completions API for non-GPT-5 models - messages = [] - if system_prompt: - messages.append({"role": "system", "content": system_prompt}) - messages.append({"role": "user", "content": prompt}) - - kwargs = { - "model": actual_model, - "messages": messages, - "temperature": temperature, - } - - if max_tokens: - kwargs["max_tokens"] = max_tokens - - response = await get_openai_client().chat.completions.create(**kwargs) - result = response.choices[0].message.content.strip() - - else: - # New Google GenAI SDK - async call - config = genai.types.GenerateContentConfig( - temperature=temperature, - ) - - if max_tokens: - config.max_output_tokens = max_tokens - - # Prepare the prompt - combine system prompt with user prompt if needed - if system_prompt: - combined_prompt = f"System: {system_prompt}\n\nUser: {prompt}" - else: - combined_prompt = prompt - - # Make async call to new GenAI SDK - response = await get_gemini_client().aio.models.generate_content( - model=actual_model, - contents=combined_prompt, - config=config - ) - - # Extract text from new SDK response - result = LLMService._extract_text_from_new_genai_response(response) - + response = await get_azure_client().responses.create(**kwargs) + result = LLMService._extract_responses_api_content(response) if attempt > 0: - logger.info(f"LLM content generation succeeded on attempt {attempt_num}/{max_retries}") - await LLMService._record_usage(response, provider, actual_model, _start_time, attempt) + logger.info(f"LLM generate_content succeeded on attempt {attempt_num}/{max_retries}") + await LLMService._record_usage(response, 'azure', actual_model, _start_time, attempt) return result - - except genai_errors.APIError as e: - # Google GenAI SDK specific error handling - last_error = e - error_code = getattr(e, 'code', 'unknown') - error_message = getattr(e, 'message', str(e)) or str(e) or repr(e) - - logger.warning(f"LLM attempt {attempt_num}/{max_retries} failed: [Google API {error_code}] {error_message}") - - # Retryable: 429 rate limit, 500+ server errors - is_retryable = ( - error_code == 429 or - (isinstance(error_code, int) and error_code >= 500) - ) - - if is_retryable: - if attempt < max_retries - 1: - wait_time = 2 ** attempt - logger.info(f"Retryable Google API error. Waiting {wait_time}s before retry {attempt_num + 1}/{max_retries}") - await asyncio.sleep(wait_time) - continue - else: - logger.error(f"Retryable Google API error [{error_code}] but max retries ({max_retries}) reached") - else: - # 400, 403, 404, etc. - non-retryable - logger.error(f"Non-retryable Google API error [{error_code}]: {error_message}") - break - except Exception as e: - # Fallback for OpenAI and other non-Google errors last_error = e - # Debug: capture full exception details - exc_type = type(e).__name__ - exc_module = type(e).__module__ - exc_str = str(e) - exc_repr = repr(e) - exc_args = getattr(e, 'args', ()) - exc_dict = getattr(e, '__dict__', {}) - exc_tb = traceback.format_exc() - - logger.warning(f"LLM attempt {attempt_num}/{max_retries} failed - Type: {exc_module}.{exc_type}, str: '{exc_str}', repr: {exc_repr}, args: {exc_args}, dict: {exc_dict}") - logger.warning(f"Full traceback:\n{exc_tb}") - - error_message = exc_str.lower() if exc_str else exc_repr.lower() - - # Check if this is a retryable error (API internal errors, rate limiting, etc.) - if ("500" in error_message or - "internal error" in error_message or - "internal server error" in error_message or - "service unavailable" in error_message or - "timeout" in error_message or - "rate" in error_message): - - if attempt < max_retries - 1: - # Wait before retrying (exponential backoff) - wait_time = 2 ** attempt # 1s, 2s, 4s - logger.info(f"Retryable error detected. Waiting {wait_time} seconds before retry {attempt_num + 1}/{max_retries}") - await asyncio.sleep(wait_time) - continue - else: - logger.error(f"Retryable error detected but max retries ({max_retries}) reached") + error_message = str(e).lower() + logger.warning( + f"LLM attempt {attempt_num}/{max_retries} failed — {type(e).__name__}: {e}\n" + f"{traceback.format_exc()}" + ) + is_retryable = any(kw in error_message for kw in ( + "500", "internal error", "internal server error", + "service unavailable", "timeout", "rate", + )) + if is_retryable and attempt < max_retries - 1: + wait = 2 ** attempt + logger.info(f"Retryable error — waiting {wait}s before retry {attempt_num + 1}") + await asyncio.sleep(wait) + continue else: - logger.error(f"Non-retryable error detected: {str(e)}") + if not is_retryable: + logger.error(f"Non-retryable error: {e}") break - # If we've exhausted all retries or hit a non-retryable error, raise the last error - error_detail = "" - if isinstance(last_error, genai_errors.APIError): - error_code = getattr(last_error, 'code', 'unknown') - error_msg = getattr(last_error, 'message', str(last_error)) or str(last_error) or repr(last_error) - error_detail = f"[Google API {error_code}] {error_msg}" - else: - # Use repr if str is empty - error_detail = str(last_error) or repr(last_error) or f"{type(last_error).__module__}.{type(last_error).__name__}: {getattr(last_error, 'args', ())}" - logger.error(f"LLM content generation failed after {max_retries} attempts. Final error: {error_detail}") + error_detail = str(last_error) or repr(last_error) + logger.error(f"generate_content failed after {max_retries} attempts: {error_detail}") raise LLMServiceError(f"Error generating content: {error_detail}") - + @staticmethod def parse_json_response(response_text: str) -> Union[Dict[str, Any], List[Any]]: - """ - Parse a JSON response from the LLM. - - Args: - response_text: The text response from the LLM - - Returns: - A dictionary or list parsed from the JSON response - - Raises: - LLMServiceError: If there's an issue parsing the JSON - """ + """Parse a JSON response from the LLM, stripping markdown code fences.""" try: - # Handle common formatting issues in the response - clean_response = response_text - - # Remove markdown code blocks if present - if clean_response.startswith("```json"): - clean_response = clean_response.strip("```json").strip("```").strip() - elif clean_response.startswith("```"): - clean_response = clean_response.strip("```").strip() - - # Parse the JSON - return json.loads(clean_response) - + clean = response_text + if clean.startswith("```json"): + clean = clean.strip("```json").strip("```").strip() + elif clean.startswith("```"): + clean = clean.strip("```").strip() + return json.loads(clean) except json.JSONDecodeError as e: - error_msg = f"Failed to parse JSON response: {str(e)}. Raw response: {response_text[:200]}..." - logging.getLogger(__name__).error(error_msg) - raise LLMServiceError(error_msg) - + msg = f"Failed to parse JSON response: {e}. Raw: {response_text[:200]}..." + logging.getLogger(__name__).error(msg) + raise LLMServiceError(msg) + @staticmethod async def generate_structured_response( prompt: str, @@ -539,36 +343,14 @@ class LLMService: reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None ) -> Dict[str, Any]: - """ - Generate a structured JSON response using the LLM. - - Args: - prompt: The prompt to send to the model - temperature: Controls randomness in generation - max_tokens: Maximum tokens to generate - model_name: Optional model name to use - system_prompt: Optional system prompt to define the role of the AI - reasoning_effort: GPT-5.2 only - Controls thinking time (minimal/low/medium/high) - verbosity: GPT-5.2 only - Controls response length (low/medium/high) - - Returns: - A dictionary parsed from the JSON response - - Raises: - LLMServiceError: If there's an issue with generation or parsing - """ - response_text = await LLMService.generate_content( - prompt=prompt, - temperature=temperature, - max_tokens=max_tokens, - model_name=model_name, - system_prompt=system_prompt, - reasoning_effort=reasoning_effort, - verbosity=verbosity + """Generate and parse a structured JSON dict response.""" + text = await LLMService.generate_content( + prompt=prompt, temperature=temperature, max_tokens=max_tokens, + model_name=model_name, system_prompt=system_prompt, + reasoning_effort=reasoning_effort, verbosity=verbosity, ) - - return LLMService.parse_json_response(response_text) - + return LLMService.parse_json_response(text) + @staticmethod async def generate_structured_array( prompt: str, @@ -579,42 +361,17 @@ class LLMService: reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None ) -> List[Dict[str, Any]]: - """ - Generate a structured JSON array response using the LLM. - - Args: - prompt: The prompt to send to the model - temperature: Controls randomness in generation - max_tokens: Maximum tokens to generate - model_name: Optional model name to use - system_prompt: Optional system prompt to define the role of the AI - reasoning_effort: GPT-5.2 only - Controls thinking time (minimal/low/medium/high) - verbosity: GPT-5.2 only - Controls response length (low/medium/high) - - Returns: - A list of dictionaries parsed from the JSON array response - - Raises: - LLMServiceError: If there's an issue with generation or parsing - """ - response_text = await LLMService.generate_content( - prompt=prompt, - temperature=temperature, - max_tokens=max_tokens, - model_name=model_name, - system_prompt=system_prompt, - reasoning_effort=reasoning_effort, - verbosity=verbosity + """Generate and parse a structured JSON array response.""" + text = await LLMService.generate_content( + prompt=prompt, temperature=temperature, max_tokens=max_tokens, + model_name=model_name, system_prompt=system_prompt, + reasoning_effort=reasoning_effort, verbosity=verbosity, ) - - result = LLMService.parse_json_response(response_text) - - # Ensure the result is a list + result = LLMService.parse_json_response(text) if not isinstance(result, list): - raise LLMServiceError(f"Expected a JSON array but received {type(result)}") - + raise LLMServiceError(f"Expected JSON array but received {type(result)}") return result - + @staticmethod async def generate_multimodal_content( prompt: str, @@ -623,205 +380,72 @@ class LLMService: max_tokens: Optional[int] = None, model_name: Optional[str] = None ) -> str: - """ - Generate content using both text and image inputs. - - Args: - prompt: The text prompt to send to the model - image_paths: List of paths to image files to include - temperature: Controls randomness in generation - max_tokens: Maximum tokens to generate - model_name: Optional model name to use - - Returns: - The generated text response - - Raises: - LLMServiceError: If there's an issue with generation or image processing - """ + """Generate content from text + images via the Azure Responses API.""" logger = logging.getLogger(__name__) max_retries = 3 last_error = None - # Quota pre-flight — raises QuotaExceededError if over limit try: - from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.models.quota import check_quota, QuotaExceededError as _QEE from app.services.llm_usage_context import current_context as _ctx _c = _ctx() await check_quota(_c.user_id, _c.focus_group_id) except Exception as _qe: - from app.models.quota import QuotaExceededError as _QEE - if isinstance(_qe, _QEE): + from app.models.quota import QuotaExceededError as _QEE2 + if isinstance(_qe, _QEE2): raise - pass # Non-fatal: DB failures must not block LLM calls + pass actual_model = LLMService._resolve_model(model_name) - provider = LLMService._get_model_provider(model_name) - - logger.info(f"Generating multimodal content with {len(image_paths)} image(s) using {provider} provider") + logger.info(f"generate_multimodal_content: {len(image_paths)} image(s), model={actual_model}") _start_time = time.monotonic() + # Build the multimodal input list + content_items = [{"type": "input_text", "text": prompt}] + for image_path in image_paths: + if not os.path.exists(image_path): + raise LLMServiceError(f"Image file not found: {image_path}") + ext = image_path.lower().split('.')[-1] + mime_type = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', + 'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/jpeg') + with open(image_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode('utf-8') + content_items.append({ + "type": "input_image", + "image_url": f"data:{mime_type};base64,{b64}", + }) + logger.debug(f"Loaded image: {image_path}") + + input_content = [{"role": "user", "content": content_items}] + kwargs = LLMService._build_responses_kwargs(actual_model, input_content) + for attempt in range(max_retries): attempt_num = attempt + 1 - logger.debug(f"Multimodal content generation attempt {attempt_num}/{max_retries}") - + logger.debug(f"generate_multimodal_content attempt {attempt_num}/{max_retries}") try: - if provider == 'openai': - # OpenAI multimodal API call - import base64 - - # Prepare image content for OpenAI API - image_content = [] - for image_path in image_paths: - if not os.path.exists(image_path): - raise LLMServiceError(f"Image file not found: {image_path}") - - # Encode image to base64 - with open(image_path, "rb") as image_file: - base64_image = base64.b64encode(image_file.read()).decode('utf-8') - - # Get image format - image_format = image_path.lower().split('.')[-1] - if image_format == 'jpg': - image_format = 'jpeg' - - image_content.append({ - "type": "image_url", - "image_url": { - "url": f"data:image/{image_format};base64,{base64_image}" - } - }) - logger.debug(f"Successfully loaded image for OpenAI: {image_path}") - - if actual_model == 'gpt-5.4-2026-03-05': - # Use Responses API for gpt-5.4-2026-03-05 multimodal - # Note: GPT-5 Responses API supports multimodal input - input_content = [{"role": "user", "content": [{"type": "input_text", "text": prompt}]}] - # Add images to the content array - for img_content in image_content: - input_content[0]["content"].append({ - "type": "input_image", - "image_url": img_content["image_url"]["url"] - }) - - kwargs = { - "model": actual_model, - "input": input_content, - "reasoning": {"effort": "low"}, # Default reasoning for multimodal - "text": { - "verbosity": "medium", # Default verbosity for multimodal - "format": {"type": "text"} - } - } - - # Note: GPT-5 Responses API does not support max_tokens parameter - - response = await get_openai_client().responses.create(**kwargs) - result = LLMService._extract_responses_api_content(response) - - else: - # Use Chat Completions API for non-GPT-5 models - content = [{"type": "text", "text": prompt}] - content.extend(image_content) - - kwargs = { - "model": actual_model, - "messages": [{"role": "user", "content": content}], - "temperature": temperature, - } - - if max_tokens: - kwargs["max_tokens"] = max_tokens - - response = await get_openai_client().chat.completions.create(**kwargs) - result = response.choices[0].message.content.strip() - - else: - # New Google GenAI SDK - multimodal async call - config = genai.types.GenerateContentConfig( - temperature=temperature, - ) - - if max_tokens: - config.max_output_tokens = max_tokens - - # Prepare multimodal content for new SDK - content_parts = [] - - # Add text prompt - content_parts.append(genai.types.Part.from_text(prompt)) - - # Add images - for image_path in image_paths: - try: - if not os.path.exists(image_path): - raise LLMServiceError(f"Image file not found: {image_path}") - - # Read image data for new SDK - with open(image_path, 'rb') as img_file: - image_data = img_file.read() - - # Determine MIME type from file extension - ext = os.path.splitext(image_path)[1].lower() - mime_type = { - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.png': 'image/png', - '.gif': 'image/gif', - '.webp': 'image/webp' - }.get(ext, 'image/jpeg') # Default to JPEG - - content_parts.append(genai.types.Part.from_bytes(image_data, mime_type=mime_type)) - logger.debug(f"Successfully loaded image for new GenAI SDK: {image_path}") - - except Exception as e: - raise LLMServiceError(f"Failed to load image {image_path}: {str(e)}") - - # Make async call to new GenAI SDK with multimodal content - response = await get_gemini_client().aio.models.generate_content( - model=actual_model, - contents=content_parts, - config=config - ) - - # Extract text from new SDK response - result = LLMService._extract_text_from_new_genai_response(response) - + response = await get_azure_client().responses.create(**kwargs) + result = LLMService._extract_responses_api_content(response) if attempt > 0: - logger.info(f"Multimodal content generation succeeded on attempt {attempt_num}/{max_retries}") - await LLMService._record_usage(response, provider, actual_model, _start_time, attempt) + logger.info(f"generate_multimodal_content succeeded on attempt {attempt_num}/{max_retries}") + await LLMService._record_usage(response, 'azure', actual_model, _start_time, attempt) return result - except Exception as e: last_error = e error_message = str(e).lower() - - logger.warning(f"Multimodal attempt {attempt_num}/{max_retries} failed: {str(e)}") - - # Check if this is a retryable error - if ("500" in error_message or - "internal error" in error_message or - "internal server error" in error_message or - "service unavailable" in error_message or - "timeout" in error_message or - "rate" in error_message): - - if attempt < max_retries - 1: - # Wait before retrying (exponential backoff) - wait_time = 2 ** attempt # 1s, 2s, 4s - logger.info(f"Retryable error detected. Waiting {wait_time} seconds before retry {attempt_num + 1}/{max_retries}") - await asyncio.sleep(wait_time) - continue - else: - logger.error(f"Retryable error detected but max retries ({max_retries}) reached") + logger.warning(f"Multimodal attempt {attempt_num}/{max_retries} failed: {e}") + is_retryable = any(kw in error_message for kw in ( + "500", "internal error", "service unavailable", "timeout", "rate", + )) + if is_retryable and attempt < max_retries - 1: + wait = 2 ** attempt + await asyncio.sleep(wait) + continue else: - logger.error(f"Non-retryable error detected: {str(e)}") break - - # If we've exhausted all retries or hit a non-retryable error, raise the last error - logger.error(f"Multimodal content generation failed after {max_retries} attempts. Final error: {str(last_error)}") - raise LLMServiceError(f"Error generating multimodal content: {str(last_error)}") - + + logger.error(f"generate_multimodal_content failed after {max_retries} attempts: {last_error}") + raise LLMServiceError(f"Error generating multimodal content: {last_error}") + @staticmethod async def generate_contextual_response( prompt: str, @@ -832,266 +456,104 @@ class LLMService: reasoning_effort: Optional[str] = None, verbosity: Optional[str] = None ) -> str: - """ - Generate content using conversation context that may include both text and images in sequence. - - Args: - prompt: The main prompt for the LLM - conversation_context: List of context items (text and image) in chronological order - temperature: Controls randomness in generation - max_tokens: Maximum tokens to generate - model_name: Optional model name to use - reasoning_effort: GPT-5.2 only - Controls thinking time (minimal/low/medium/high) - verbosity: GPT-5.2 only - Controls response length (low/medium/high) + """Generate content using conversation context that may include text and images. - Returns: - The generated text response - - Raises: - LLMServiceError: If there's an issue with generation + If the context contains images, builds a multimodal Responses API request. + Otherwise delegates to generate_content for a text-only call. """ logger = logging.getLogger(__name__) - # Quota pre-flight — raises QuotaExceededError if over limit try: - from app.models.quota import check_quota, QuotaExceededError as _QuotaExceededError + from app.models.quota import check_quota, QuotaExceededError as _QEE from app.services.llm_usage_context import current_context as _ctx _c = _ctx() await check_quota(_c.user_id, _c.focus_group_id) except Exception as _qe: - from app.models.quota import QuotaExceededError as _QEE - if isinstance(_qe, _QEE): + from app.models.quota import QuotaExceededError as _QEE2 + if isinstance(_qe, _QEE2): raise - pass # Non-fatal: DB failures must not block LLM calls + pass - # Separate text and image content from the conversation context - text_context_parts = [] - image_parts = [] + text_parts = [] + pil_images = [] + + logger.debug(f"generate_contextual_response: {len(conversation_context)} context items") - print(f"🎯 Processing {len(conversation_context)} context items for LLM") - for item in conversation_context: if item["type"] == "text": - text_context_parts.append(item["content"]) + text_parts.append(item["content"]) elif item["type"] == "image": try: image_path = item["path"] if os.path.exists(image_path): - # Load image using PIL with Image.open(image_path) as img: - # Convert to RGB if necessary if img.mode != 'RGB': img = img.convert('RGB') - image_parts.append(img.copy()) - print(f"🖼️ Loaded image for context: {item['filename']}") + pil_images.append(img.copy()) + logger.debug(f"Loaded context image: {item.get('filename', image_path)}") else: - print(f"⚠️ Image not found for context: {image_path}") + logger.warning(f"Context image not found: {image_path}") except Exception as e: - print(f"❌ Failed to load image for context: {item['path']}: {e}") - - # Build the full context prompt - context_prompt = "" - if text_context_parts: - context_prompt = "CONVERSATION CONTEXT:\n" + "\n".join(text_context_parts) + "\n\n" - - full_prompt = context_prompt + prompt - - print(f"📝 Context prompt length: {len(context_prompt)} characters") - print(f"🖼️ Total images in context: {len(image_parts)}") - - # If we have images, use multimodal generation - if image_parts: - print(f"🎨 Using multimodal generation with {len(image_parts)} images") - - actual_model = LLMService._resolve_model(model_name) - provider = LLMService._get_model_provider(model_name) + logger.warning(f"Failed to load context image {item.get('path', '?')}: {e}") - max_retries = 3 - last_error = None - _start_time = time.monotonic() + context_prefix = ("CONVERSATION CONTEXT:\n" + "\n".join(text_parts) + "\n\n") if text_parts else "" + full_prompt = context_prefix + prompt - for attempt in range(max_retries): - attempt_num = attempt + 1 - logger.debug(f"Contextual multimodal generation attempt {attempt_num}/{max_retries}") - - try: - if provider == 'openai': - # OpenAI contextual multimodal API call - - # Convert PIL images to base64 for OpenAI API - image_content = [] - for i, img in enumerate(image_parts): - # Convert PIL image to base64 - buffer = io.BytesIO() - img.save(buffer, format='PNG') - base64_image = base64.b64encode(buffer.getvalue()).decode('utf-8') - - image_content.append({ - "type": "image_url", - "image_url": { - "url": f"data:image/png;base64,{base64_image}" - } - }) - - if actual_model == 'gpt-5.4-2026-03-05': - # Use Responses API for gpt-5.4-2026-03-05 contextual multimodal - input_content = [{"role": "user", "content": [{"type": "input_text", "text": full_prompt}]}] - # Add images to the content array - for img_content in image_content: - input_content[0]["content"].append({ - "type": "input_image", - "image_url": img_content["image_url"]["url"] - }) - - kwargs = { - "model": actual_model, - "input": input_content, - "reasoning": {"effort": reasoning_effort or "low"}, - "text": { - "verbosity": verbosity or "medium", - "format": {"type": "text"} - } - } - - # Note: GPT-5 Responses API does not support max_tokens parameter - - response = await get_openai_client().responses.create(**kwargs) - result = LLMService._extract_responses_api_content(response) - - else: - # Use Chat Completions API for non-GPT-5 models - content = [{"type": "text", "text": full_prompt}] - content.extend(image_content) - - kwargs = { - "model": actual_model, - "messages": [{"role": "user", "content": content}], - "temperature": temperature, - } - - if max_tokens: - kwargs["max_tokens"] = max_tokens - - response = await get_openai_client().chat.completions.create(**kwargs) - result = response.choices[0].message.content.strip() - - else: - # New Google GenAI SDK - contextual multimodal async call - config = genai.types.GenerateContentConfig( - temperature=temperature, - ) - - if max_tokens: - config.max_output_tokens = max_tokens - - # Prepare content parts for new SDK - new_content_parts = [] - - # Add text prompt - new_content_parts.append(genai.types.Part.from_text(full_prompt)) - - # Convert PIL image parts to new SDK format - for img in image_parts: - # Convert PIL image to bytes - buffer = io.BytesIO() - img.save(buffer, format='PNG') - image_data = buffer.getvalue() - - # Add as image part in new SDK format - new_content_parts.append(genai.types.Part.from_bytes(image_data, mime_type='image/png')) - - # Make async call to new GenAI SDK - response = await get_gemini_client().aio.models.generate_content( - model=actual_model, - contents=new_content_parts, - config=config - ) - - result = LLMService._extract_text_from_new_genai_response(response) - - if attempt > 0: - logger.info(f"Contextual multimodal generation succeeded on attempt {attempt_num}/{max_retries}") - - print(f"✅ Generated contextual response with visual context using {provider}") - print(f"🔍 LLM RESULT DEBUG:") - print(f" - Result type: {type(result)}") - print(f" - Result length: {len(result) if result else 0} characters") - print(f" - Result preview: '{result[:200] if result else 'EMPTY'}...'") - print(f" - Result repr: {repr(result[:50]) if result else 'NONE'}") - await LLMService._record_usage(response, provider, actual_model, _start_time, attempt) - return result - - except genai_errors.APIError as e: - # Google GenAI SDK specific error handling - last_error = e - error_code = getattr(e, 'code', 'unknown') - error_message = getattr(e, 'message', str(e)) or str(e) or repr(e) - - logger.warning(f"Contextual multimodal attempt {attempt_num}/{max_retries} failed: [Google API {error_code}] {error_message}") - - # Retryable: 429 rate limit, 500+ server errors - is_retryable = ( - error_code == 429 or - (isinstance(error_code, int) and error_code >= 500) - ) - - if is_retryable: - if attempt < max_retries - 1: - wait_time = 2 ** attempt - logger.info(f"Retryable Google API error. Waiting {wait_time}s before retry {attempt_num + 1}/{max_retries}") - await asyncio.sleep(wait_time) - continue - else: - logger.error(f"Retryable Google API error [{error_code}] but max retries ({max_retries}) reached") - else: - logger.error(f"Non-retryable Google API error [{error_code}]: {error_message}") - break - - except Exception as e: - # Fallback for non-Google errors - last_error = e - error_message = str(e).lower() - - logger.warning(f"Contextual multimodal attempt {attempt_num}/{max_retries} failed: {str(e)}") - - # Check if this is a retryable error - if ("500" in error_message or - "internal error" in error_message or - "internal server error" in error_message or - "service unavailable" in error_message or - "timeout" in error_message or - "rate" in error_message): - - if attempt < max_retries - 1: - wait_time = 2 ** attempt - logger.info(f"Retryable error detected. Waiting {wait_time} seconds before retry {attempt_num + 1}/{max_retries}") - await asyncio.sleep(wait_time) - continue - else: - logger.error(f"Retryable error detected but max retries ({max_retries}) reached") - else: - logger.error(f"Non-retryable error detected: {str(e)}") - break - - # If multimodal failed, raise the error - error_detail = "" - if isinstance(last_error, genai_errors.APIError): - error_code = getattr(last_error, 'code', 'unknown') - error_msg = getattr(last_error, 'message', str(last_error)) or str(last_error) or repr(last_error) - error_detail = f"[Google API {error_code}] {error_msg}" - else: - error_detail = str(last_error) - logger.error(f"Contextual multimodal generation failed after {max_retries} attempts. Final error: {error_detail}") - raise LLMServiceError(f"Error generating contextual multimodal content: {error_detail}") - - else: - # No images, use standard text generation - print(f"📝 Using text-only generation (no visual context)") + if not pil_images: return await LLMService.generate_content( prompt=full_prompt, temperature=temperature, max_tokens=max_tokens, model_name=model_name, reasoning_effort=reasoning_effort, - verbosity=verbosity - ) \ No newline at end of file + verbosity=verbosity, + ) + + # Multimodal path + actual_model = LLMService._resolve_model(model_name) + max_retries = 3 + last_error = None + _start_time = time.monotonic() + + # Convert PIL images to base64 + content_items = [{"type": "input_text", "text": full_prompt}] + for img in pil_images: + buf = io.BytesIO() + img.save(buf, format='PNG') + b64 = base64.b64encode(buf.getvalue()).decode('utf-8') + content_items.append({ + "type": "input_image", + "image_url": f"data:image/png;base64,{b64}", + }) + + input_content = [{"role": "user", "content": content_items}] + kwargs = LLMService._build_responses_kwargs( + actual_model, input_content, reasoning_effort, verbosity + ) + + for attempt in range(max_retries): + attempt_num = attempt + 1 + logger.debug(f"generate_contextual_response multimodal attempt {attempt_num}/{max_retries}") + try: + response = await get_azure_client().responses.create(**kwargs) + result = LLMService._extract_responses_api_content(response) + if attempt > 0: + logger.info(f"generate_contextual_response succeeded on attempt {attempt_num}/{max_retries}") + await LLMService._record_usage(response, 'azure', actual_model, _start_time, attempt) + return result + except Exception as e: + last_error = e + error_message = str(e).lower() + logger.warning(f"Contextual multimodal attempt {attempt_num}/{max_retries} failed: {e}") + is_retryable = any(kw in error_message for kw in ( + "500", "internal error", "service unavailable", "timeout", "rate", + )) + if is_retryable and attempt < max_retries - 1: + wait = 2 ** attempt + await asyncio.sleep(wait) + continue + else: + break + + logger.error(f"generate_contextual_response failed after {max_retries} attempts: {last_error}") + raise LLMServiceError(f"Error generating contextual response: {last_error}") diff --git a/backend/app/services/msal_service.py b/backend/app/services/msal_service.py deleted file mode 100755 index d8cd6c5a..00000000 --- a/backend/app/services/msal_service.py +++ /dev/null @@ -1,106 +0,0 @@ -import jwt -from jwt import PyJWKClient -import logging -from typing import Optional, Dict, Any -from quart import current_app - -class MSALService: - """Service for validating Microsoft MSAL tokens and extracting user information.""" - - def __init__(self): - import os - self.tenant_id = os.environ.get('MSAL_TENANT_ID') - self.client_id = os.environ.get('MSAL_CLIENT_ID') - if not self.tenant_id or not self.client_id: - raise RuntimeError("MSAL_TENANT_ID and MSAL_CLIENT_ID environment variables must be set") - - # Microsoft endpoints - self.jwks_url = f'https://login.microsoftonline.com/{self.tenant_id}/discovery/v2.0/keys' - - # Initialize JWK client for token verification - self.jwks_client = PyJWKClient(self.jwks_url) - - def validate_token(self, id_token: str) -> Optional[Dict[str, Any]]: - """ - Validate a Microsoft ID token and return user information. - - Args: - id_token: The Microsoft ID token (JWT) to validate - - Returns: - Dictionary containing user information if valid, None if invalid - """ - try: - # Decode and validate the ID token as a JWT - return self._decode_jwt_token(id_token) - - except Exception as e: - current_app.logger.error(f"ID token validation failed: {str(e)}") - return None - - def _decode_jwt_token(self, id_token: str) -> Optional[Dict[str, Any]]: - """ - Decode and validate ID token as JWT. - - Args: - id_token: The Microsoft ID token (JWT) to validate - - Returns: - Dictionary containing user information if valid, None if invalid - """ - try: - # Get the signing key - signing_key = self.jwks_client.get_signing_key_from_jwt(id_token) - - # Decode and validate the ID token - decoded_token = jwt.decode( - id_token, - signing_key.key, - algorithms=['RS256'], - audience=self.client_id, - issuer=f'https://login.microsoftonline.com/{self.tenant_id}/v2.0' - ) - - # Extract user information from token claims - return { - 'microsoft_id': decoded_token.get('oid') or decoded_token.get('sub'), - 'username': decoded_token.get('preferred_username', '').split('@')[0], - 'email': decoded_token.get('email') or decoded_token.get('preferred_username'), - 'display_name': decoded_token.get('name', ''), - 'given_name': decoded_token.get('given_name', ''), - 'surname': decoded_token.get('family_name', ''), - 'auth_type': 'microsoft' - } - - except jwt.InvalidTokenError as e: - current_app.logger.error(f"JWT token validation failed: {str(e)}") - return None - except Exception as e: - current_app.logger.error(f"Token decoding failed: {str(e)}") - return None - - def create_user_data(self, microsoft_user_info: Dict[str, Any]) -> Dict[str, Any]: - """ - Create user data dictionary from Microsoft user information. - - Args: - microsoft_user_info: User information from Microsoft - - Returns: - Dictionary formatted for our user system - """ - # Use display name if available, otherwise construct from given/surname - display_name = microsoft_user_info.get('display_name', '') - if not display_name: - given_name = microsoft_user_info.get('given_name', '') - surname = microsoft_user_info.get('surname', '') - display_name = f"{given_name} {surname}".strip() or microsoft_user_info.get('username', 'Microsoft User') - - return { - 'username': display_name, # Use display name as username for Microsoft users - 'email': microsoft_user_info.get('email', ''), - 'microsoft_id': microsoft_user_info.get('microsoft_id', ''), - 'role': 'user', # Default role for all users - 'auth_type': 'microsoft', - 'password_hash': None # Microsoft users don't have local passwords - } \ No newline at end of file diff --git a/backend/app/services/persona_export_service.py b/backend/app/services/persona_export_service.py index 7ef8c16c..521766d4 100755 --- a/backend/app/services/persona_export_service.py +++ b/backend/app/services/persona_export_service.py @@ -47,7 +47,7 @@ class PersonaExportService: async def generate_profile_markdown( self, persona_data: Dict[str, Any], - llm_model: str = "gpt-4.1", + llm_model: str = "gpt-5.4", temperature: float = 0.3 ) -> Dict[str, Any]: """ @@ -55,7 +55,7 @@ class PersonaExportService: Args: persona_data: Complete persona data as dictionary - llm_model: LLM model to use (default: gpt-4.1 for speed) + llm_model: LLM model to use (default: gpt-5.4 for speed) temperature: Temperature for LLM generation (lower for consistency) Returns: diff --git a/backend/app/services/persona_modification_service.py b/backend/app/services/persona_modification_service.py index 0c6c1822..742d1ca8 100755 --- a/backend/app/services/persona_modification_service.py +++ b/backend/app/services/persona_modification_service.py @@ -134,7 +134,7 @@ class PersonaModificationService: async def modify_persona( persona_id: str, modification_prompt: str, - llm_model: str = 'gemini-3.1-pro-preview', + llm_model: str = 'gpt-5.4', reasoning_effort: str = 'medium', verbosity: str = 'medium', max_retries: int = 3, @@ -190,8 +190,8 @@ class PersonaModificationService: prompt=final_prompt, temperature=0.3, # Lower temperature for consistent modifications model_name=llm_model, - reasoning_effort=reasoning_effort if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None, - verbosity=verbosity if llm_model in ('gpt-5', 'gpt-5.4-2026-03-05') else None + reasoning_effort=reasoning_effort, + verbosity=verbosity ) # Parse JSON response diff --git a/backend/requirements.txt b/backend/requirements.txt index 828d7b2a..89661951 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,12 +11,10 @@ pymongo==4.14.1 # Authentication & Security bcrypt==4.0.1 PyJWT==2.8.0 -msal==1.24.1 # AI & LLM Services -google-genai openai==1.99.5 -llama-cloud-services==0.6.62 +stripe>=10.0.0 # WebSocket & Real-time python-socketio==5.13.0 diff --git a/backend/run.py b/backend/run.py index 1e2c9b3c..60885fba 100755 --- a/backend/run.py +++ b/backend/run.py @@ -74,7 +74,7 @@ async def run_server(): print("⚡ All operations async and non-blocking") print("🛑 Use Ctrl-C for graceful shutdown") print("🔍 Debug: Send SIGUSR1 for stack dump if it hangs") - print("Started Semblance back end service") + print("Started Cohorta back end service") # Create hypercorn config with debug settings config = Config() diff --git a/backend/scripts/seed_model_pricing.py b/backend/scripts/seed_model_pricing.py index b011bb72..1a24ad37 100644 --- a/backend/scripts/seed_model_pricing.py +++ b/backend/scripts/seed_model_pricing.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Seed model pricing for Semblance. +"""Seed model pricing for Cohorta (Azure AI Foundry). Run from the backend/ directory: source venv/bin/activate @@ -23,20 +23,23 @@ MONGO_USER = os.environ.get("MONGO_USER") MONGO_PASS = os.environ.get("MONGO_PASS") MONGO_HOST = os.environ.get("MONGO_HOST", "localhost") MONGO_PORT = os.environ.get("MONGO_PORT", "27017") +MONGO_DB = os.environ.get("MONGO_DB", "cohorta_db") if not MONGO_URI: if MONGO_USER and MONGO_PASS: - MONGO_URI = f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/semblance_db?authSource=admin" + MONGO_URI = f"mongodb://{MONGO_USER}:{MONGO_PASS}@{MONGO_HOST}:{MONGO_PORT}/{MONGO_DB}?authSource=admin" else: MONGO_URI = f"mongodb://{MONGO_HOST}:{MONGO_PORT}" -# Pricing effective from project start — covers all historical backfill -EFFECTIVE_FROM = datetime(2024, 1, 1, tzinfo=timezone.utc) +# Pricing effective from project launch +EFFECTIVE_FROM = datetime(2026, 5, 23, tzinfo=timezone.utc) +# Azure AI Foundry pricing (GlobalStandard, <272k context, USD per 1M tokens) +# Source: Azure OpenAI pricing page, May 2026 PRICING_ROWS = [ { - "model": "gpt-5.4-2026-03-05", - "provider": "openai", + "model": "gpt-5.4", + "provider": "azure", "currency": "USD", "tiers": [ { @@ -49,38 +52,48 @@ PRICING_ROWS = [ ], "effective_from": EFFECTIVE_FROM, "effective_until": None, - "notes": "gpt-5.4-2026-03-05 pricing as of 2026-04", + "notes": "gpt-5.4 GlobalStandard <272k ctx. Retirement 2027-03-05.", }, { - "model": "gemini-3.1-pro-preview", - "provider": "gemini", + "model": "gpt-5.4-mini", + "provider": "azure", "currency": "USD", "tiers": [ { "threshold_input_tokens": 0, - "input_per_mtok": 2.00, - "cached_input_per_mtok": None, - "output_per_mtok": 12.00, + "input_per_mtok": 0.75, + "cached_input_per_mtok": 0.08, + "output_per_mtok": 4.50, "image_per_mtok": None, - }, - { - "threshold_input_tokens": 200_000, - "input_per_mtok": 4.00, - "cached_input_per_mtok": None, - "output_per_mtok": 18.00, - "image_per_mtok": None, - }, + } ], "effective_from": EFFECTIVE_FROM, "effective_until": None, - "notes": "gemini-3.1-pro-preview pricing: $2/$12 (<200k ctx), $4/$18 (>=200k ctx)", + "notes": "gpt-5.4-mini GlobalStandard. Used for cheap features (summary, key_themes, etc.). Retirement 2027-03-18.", + }, + { + "model": "gpt-5.4-nano", + "provider": "azure", + "currency": "USD", + "tiers": [ + { + "threshold_input_tokens": 0, + "input_per_mtok": 0.20, + "cached_input_per_mtok": 0.02, + "output_per_mtok": 1.25, + "image_per_mtok": None, + } + ], + "effective_from": EFFECTIVE_FROM, + "effective_until": None, + "notes": "gpt-5.4-nano GlobalStandard. Optional ultra-cheap tier.", }, ] def main(): client = pymongo.MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) - db = client.semblance_db + db = client[MONGO_DB] db.model_pricing.create_index( [("model", pymongo.ASCENDING), ("effective_from", pymongo.DESCENDING)], diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a9aa6942..feea6344 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,7 +28,7 @@ _stub( 'pymongo', 'pymongo.errors', 'quart', 'quart_cors', 'hypercorn', 'werkzeug', 'werkzeug.exceptions', 'socketio', - 'bcrypt', 'jwt', 'msal', + 'bcrypt', 'jwt', 'bson', 'bson.objectid', 'pydantic', 'PIL', 'PIL.Image', diff --git a/dist/assets/discussionGuideMarkdown-eMXneipz.js b/dist/assets/discussionGuideMarkdown-eMXneipz.js deleted file mode 100644 index 43bc3549..00000000 --- a/dist/assets/discussionGuideMarkdown-eMXneipz.js +++ /dev/null @@ -1,47 +0,0 @@ -function f(t,n){return typeof t=="string"?m(t,n):g(t,n)}function m(t,n){const s=new Date().toLocaleString();return`# ${n?`Discussion Guide: ${n}`:"Discussion Guide"} - -**Generated:** ${s} -**Format:** Legacy Text Format - ---- - -${t} - ---- - -*Exported from Semblance Synthetic Society*`}function g(t,n){const s=new Date().toLocaleString();let e=`# ${n?`Discussion Guide: ${n}`:t.title} - -**Duration:** ${t.total_duration} minutes -**Generated:** ${s} - -`;return t.metadata&&(e+=`**Additional Information:** ${JSON.stringify(t.metadata,null,2)} - -`),e+=`--- - -`,t.sections.forEach((r,c)=>{e+=`## Section ${c+1}: ${r.title} - -`,r.content&&(e+=`*${r.content}* - -`),r.activities&&r.activities.length>0&&(e+=`### Activities - -`,r.activities.forEach((i,a)=>{e+=l(i,a+1,"Activity")}),e+=` -`),r.questions&&r.questions.length>0&&(e+=`### Questions - -`,r.questions.forEach((i,a)=>{e+=l(i,a+1,"Question")}),e+=` -`),r.subsections&&r.subsections.length>0&&r.subsections.forEach((i,a)=>{e+=`### Subsection ${a+1}: ${i.title} - -`,i.activities&&i.activities.length>0&&(e+=`#### Activities - -`,i.activities.forEach((d,u)=>{e+=l(d,u+1,"Activity")}),e+=` -`),i.questions&&i.questions.length>0&&(e+=`#### Questions - -`,i.questions.forEach((d,u)=>{e+=l(d,u+1,"Question")}),e+=` -`)}),e+=`--- - -`}),e+="*Exported from Semblance Synthetic Society*",e}function l(t,n,s){let o=`${n}. **${$(t.type)}**`;return t.time_limit&&(o+=` *(${t.time_limit} min)*`),o+=` - ${t.content} -`,s==="Question"&&t.probes&&t.probes.length>0&&(o+=` - **Probe Questions:** -`,t.probes.forEach(e=>{o+=` - ${e} -`})),o+=` -`,o}function $(t){return t.split("_").map(n=>n.charAt(0).toUpperCase()+n.slice(1)).join(" ")}function h(t,n){const s=new Date().toISOString().split("T")[0];let o="discussion-guide";return t?o=`discussion-guide-${t.toLowerCase().replace(/[^a-z0-9\s-]/g,"").replace(/\s+/g,"-").replace(/-+/g,"-").trim()}`:n&&(o=`discussion-guide-${n.toLowerCase().replace(/[^a-z0-9\s-]/g,"").replace(/\s+/g,"-").replace(/-+/g,"-").trim()}`),`${o}-${s}.md`}function p(t,n){try{const s=f(t,n),o=typeof t=="string"?void 0:t.title,e=h(n,o),r=new Blob([s],{type:"text/markdown"}),c=URL.createObjectURL(r),i=document.createElement("a");i.href=c,i.download=e,i.style.display="none",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(c)}catch(s){throw console.error("Error downloading discussion guide:",s),new Error("Failed to download discussion guide")}}export{f as convertDiscussionGuideToMarkdown,p as downloadDiscussionGuideAsMarkdown,h as generateDiscussionGuideFilename}; diff --git a/index.html b/index.html index 431709bf..92a972f0 100755 --- a/index.html +++ b/index.html @@ -5,15 +5,16 @@ - Semblance - - + Cohorta + + + +
- diff --git a/package.json b/package.json index e3d3d3ef..261f58ab 100755 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "backend": "cd backend && python run.py" }, "dependencies": { - "@azure/msal-browser": "^4.19.0", - "@azure/msal-react": "^3.0.17", - "@dnd-kit/core": "^6.3.1", +"@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -84,8 +82,7 @@ "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", - "lovable-tagger": "^1.1.7", - "postcss": "^8.4.47", +"postcss": "^8.4.47", "tailwindcss": "^3.4.11", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", diff --git a/src/components/FocusGroupModerator.tsx b/src/components/FocusGroupModerator.tsx index 1f4e49a8..42b74324 100755 --- a/src/components/FocusGroupModerator.tsx +++ b/src/components/FocusGroupModerator.tsx @@ -83,7 +83,7 @@ export default function FocusGroupModerator({ focusGroupName: "", discussionTopics: "", duration: "60", - llm_model: "gemini-3-pro-preview", + llm_model: "gpt-5.4", reasoning_effort: "medium", verbosity: "medium", }, @@ -193,7 +193,7 @@ export default function FocusGroupModerator({ objective: draftToEdit.description || draftToEdit.objective || '', topic: draftToEdit.topic || '', duration: draftToEdit.duration || 60, - llm_model: draftToEdit.llm_model || 'gemini-3-pro-preview', + llm_model: draftToEdit.llm_model || 'gpt-5.4', reasoning_effort: draftToEdit.reasoning_effort || 'medium', verbosity: draftToEdit.verbosity || 'medium', participants: draftToEdit.participants || [], @@ -383,7 +383,7 @@ export default function FocusGroupModerator({ topic: values.discussionTopics || '', description: values.researchBrief || '', objective: values.researchBrief || '', - llm_model: values.llm_model || 'gemini-3-pro-preview', + llm_model: values.llm_model || 'gpt-5.4', reasoning_effort: values.reasoning_effort || 'medium', verbosity: values.verbosity || 'medium', discussionGuide: sourceFocusGroup.discussionGuide diff --git a/src/components/admin/PricingTab.tsx b/src/components/admin/PricingTab.tsx index 09285fc6..ae909f00 100644 --- a/src/components/admin/PricingTab.tsx +++ b/src/components/admin/PricingTab.tsx @@ -34,7 +34,7 @@ export default function PricingTab() { const [showDialog, setShowDialog] = useState(false); const [model, setModel] = useState(''); - const [provider, setProvider] = useState('gemini'); + const [provider, setProvider] = useState('azure'); const [inputPerMtok, setInputPerMtok] = useState(''); const [outputPerMtok, setOutputPerMtok] = useState(''); const [cachedInputPerMtok, setCachedInputPerMtok] = useState(''); @@ -56,7 +56,7 @@ export default function PricingTab() { onSuccess: () => { setShowDialog(false); setModel(''); - setProvider('gemini'); + setProvider('azure'); setInputPerMtok(''); setOutputPerMtok(''); setCachedInputPerMtok(''); @@ -148,7 +148,7 @@ export default function PricingTab() {
setModel(e.target.value)} /> @@ -160,7 +160,7 @@ export default function PricingTab() { - Gemini + Azure AI OpenAI diff --git a/src/components/admin/UsersTab.tsx b/src/components/admin/UsersTab.tsx index be8aab3b..877b4545 100644 --- a/src/components/admin/UsersTab.tsx +++ b/src/components/admin/UsersTab.tsx @@ -9,7 +9,9 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; -import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus } from 'lucide-react'; +import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus, Zap } from 'lucide-react'; +import { adminApi } from '@/lib/api'; +import { toastService } from '@/lib/toast'; interface User { _id: string; @@ -20,6 +22,7 @@ interface User { override_quota?: boolean; quota?: { monthly_usd?: number }; cost_mtd?: number; + credits_balance?: number; } export default function UsersTab() { @@ -31,6 +34,30 @@ export default function UsersTab() { const [editOverride, setEditOverride] = useState(false); const [resetPassword, setResetPassword] = useState(''); + // Credit adjustment dialog state + const [creditUser, setCreditUser] = useState(null); + const [creditAmount, setCreditAmount] = useState(''); + const [creditReason, setCreditReason] = useState(''); + const [creditSaving, setCreditSaving] = useState(false); + + const handleCreditAdjust = async () => { + if (!creditUser || !creditAmount) return; + const amount = parseInt(creditAmount); + if (isNaN(amount) || amount === 0) return; + setCreditSaving(true); + try { + await adminApi.adjustCredits(creditUser._id, amount, creditReason || 'Admin adjustment'); + toastService.success(`Credits adjusted: ${amount > 0 ? '+' : ''}${amount} for ${creditUser.username}`); + setCreditUser(null); + setCreditAmount(''); + setCreditReason(''); + } catch (e: any) { + toastService.error('Failed to adjust credits', { description: e.response?.data?.message }); + } finally { + setCreditSaving(false); + } + }; + // Create user dialog state const [showCreateDialog, setShowCreateDialog] = useState(false); const [newUsername, setNewUsername] = useState(''); @@ -140,6 +167,7 @@ export default function UsersTab() { User Role Status + Credits Cost ({periodLabel}) Monthly Quota Actions @@ -148,7 +176,7 @@ export default function UsersTab() { {users.length === 0 && ( - + No users found @@ -172,6 +200,12 @@ export default function UsersTab() { {u.is_active === false ? 'Disabled' : 'Active'} + + + + {u.credits_balance ?? 0} + + ${(u.cost_mtd ?? 0).toFixed(4)} @@ -183,6 +217,9 @@ export default function UsersTab() { + {u.is_active === false ? ( + + + +
); } diff --git a/src/components/ai-recruiter/AIRecruiterForm.tsx b/src/components/ai-recruiter/AIRecruiterForm.tsx index 0662019a..59e0c863 100755 --- a/src/components/ai-recruiter/AIRecruiterForm.tsx +++ b/src/components/ai-recruiter/AIRecruiterForm.tsx @@ -84,7 +84,7 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF researchObjective: "", personaCount: "5", temperature: 0.75, - llm_model: "gemini-3-pro-preview", + llm_model: "gpt-5.4", }, }); @@ -346,9 +346,8 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF - Gemini 3 Pro (Slow, best for most tasks) - GPT-4.1 (Fast, best for speed) - GPT-5.2 (Slow, best for complex tasks) + GPT-5.4 (Recommended) + GPT-5.4 Mini (Faster, lower cost) diff --git a/src/components/auth/MsalProvider.tsx b/src/components/auth/MsalProvider.tsx deleted file mode 100755 index 6b7dc32c..00000000 --- a/src/components/auth/MsalProvider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ReactNode } from 'react'; -import { PublicClientApplication } from '@azure/msal-browser'; -import { MsalProvider as BaseMsalProvider } from '@azure/msal-react'; -import { msalConfig } from '@/config/msalConfig'; - -// Initialize MSAL instance -const msalInstance = new PublicClientApplication(msalConfig); - -// Initialize MSAL - handle any initialization errors -msalInstance.initialize().catch((error) => { - console.error('MSAL initialization error:', error); -}); - -interface MsalProviderProps { - children: ReactNode; -} - -export function MsalProvider({ children }: MsalProviderProps) { - return ( - - {children} - - ); -} - -export { msalInstance }; \ No newline at end of file diff --git a/src/components/focus-group-session/SetupTab.tsx b/src/components/focus-group-session/SetupTab.tsx index 4c4c7c78..4a97ccdd 100755 --- a/src/components/focus-group-session/SetupTab.tsx +++ b/src/components/focus-group-session/SetupTab.tsx @@ -155,9 +155,8 @@ export function SetupTab({ - Gemini 3 Pro (Slow, best for most tasks) - GPT-4.1 (Fast, best for speed) - GPT-5.2 (Slow, best for complex tasks) + GPT-5.4 (Recommended) + GPT-5.4 Mini (Faster, lower cost) @@ -169,7 +168,7 @@ export function SetupTab({ /> {/* GPT-5 specific parameters */} - {selectedModel === "gpt-5.2" && ( + {(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && ( <> - Controls how much time GPT-5.2 spends thinking before responding + Controls how much time GPT-5.4 spends thinking before responding
- Controls how much time GPT-5.2 spends thinking before responding + Controls how much time GPT-5.4 spends thinking before responding
@@ -220,10 +219,10 @@ export function SetupTab({ - Controls how detailed and lengthy GPT-5.2's responses will be + Controls how detailed and lengthy GPT-5.4's responses will be
- Controls how much time GPT-5.2 spends thinking before responding + Controls how much time GPT-5.4 spends thinking before responding
diff --git a/src/components/focus-group-session/data.ts b/src/components/focus-group-session/data.ts index 536c0c6f..0a3a559a 100755 --- a/src/components/focus-group-session/data.ts +++ b/src/components/focus-group-session/data.ts @@ -1,5 +1,4 @@ import { FocusGroup, Message, Theme } from './types'; -import { Persona } from '@/types/persona'; // Sample focus group data export const sampleFocusGroups: FocusGroup[] = [ @@ -92,344 +91,6 @@ Final impressions and recommendations. } ]; -// Sample personas data -export const samplePersonas: Persona[] = [ - { - id: '0', - name: 'Oliver Reynolds', - age: '42', - gender: 'Male', - occupation: 'Senior Investment Manager', - education: 'Master\'s degree (Business and Finance)', - location: 'Kensington, London, UK', - techSavviness: 85, - personality: 'Discerning, sophisticated, detail-oriented, values heritage and craftsmanship', - interests: 'Classic automobiles, high-end timepieces, luxury real estate, fine dining, art exhibitions', - brandLoyalty: 90, - priceConsciousness: 30, - environmentalConcern: 60, - hasPurchasingPower: true, - hasChildren: true, - thinkFeelDo: { - thinks: [ - 'How does this reflect my personal standards and values?', - 'Is this truly the pinnacle of craftsmanship and quality?', - 'Will this purchase stand the test of time as a lasting investment?', - 'How can this be further personalized to my exact preferences?' - ], - feels: [ - 'Proud to associate with heritage brands that reflect my achievements', - 'Gratified by bespoke experiences that acknowledge my unique tastes', - 'Frustrated by standardized approaches that fail to recognize individual preferences', - 'Reassured by transparent, detailed information about craftsmanship and materials' - ], - does: [ - 'Conducts thorough research before making significant purchasing decisions', - 'Seeks personalized consultations with dedicated specialists', - 'Expects seamless integration between digital and in-person experiences', - 'Values and maintains long-term relationships with trusted luxury brands', - 'Regularly attends exclusive events and private showings' - ] - }, - oceanTraits: { - openness: 85, - conscientiousness: 90, - extraversion: 60, - agreeableness: 65, - neuroticism: 25 - }, - goals: [ - 'Build a legacy of discerning taste and refined investments', - 'Access truly personalized experiences that acknowledge his status', - 'Forge meaningful connections with brands that share his values', - 'Discover unique, limited-edition items that few others will possess', - 'Cultivate a network of trusted advisors across various luxury segments', - 'Balance professional achievement with meaningful family experiences' - ], - frustrations: [ - 'Mass-market approaches disguised as premium experiences', - 'Fragmented communication across different channels', - 'Delays in response or service that waste valuable time', - 'Sales representatives who lack deep product knowledge' - ], - motivations: [ - 'Recognition of his refined tastes and achievement', - 'Access to exclusive, members-only opportunities', - 'Building a collection of meaningful, high-quality possessions', - 'Experiences that seamlessly blend heritage with innovation' - ], - scenarioType: "Life & Luxury Scenarios", - scenarios: [ - 'Oliver is considering commissioning a bespoke luxury vehicle with custom interior features. He expects a dedicated consultant to guide him through the entire process, from initial design to delivery.', - 'While traveling abroad, Oliver seeks remote access to his preferred brands and expects the same level of personalized service through digital channels.', - 'Oliver is attending an exclusive product launch event where he anticipates VIP treatment and early access to limited-edition items.', - 'When researching a significant purchase, Oliver consults both trusted peer networks and expects detailed information about materials, craftsmanship, and heritage.', - 'Oliver is planning a milestone family celebration and wants to book a private dining experience at an exclusive venue that reflects his sophisticated taste.' - ] - }, - { - id: '1', - name: 'Fiona Caldwell', - age: '38', - gender: 'Female', - occupation: 'Founder and Creative Director of a luxury lifestyle brand', - education: 'First-Class Honours degree from a prestigious university', - location: 'Chelsea, London, UK', - ethnicity: 'White British', - socialGrade: 'A/B', - householdIncome: 'Approximately £195,000 per annum', - householdComposition: 'Single professional with established network', - livingSituation: 'Stylish, modern flat in exclusive London district', - techSavviness: 90, - personality: 'Innovative, discerning, detail-oriented, values quality and distinctiveness', - interests: 'Bespoke fashion, high-end design, contemporary art, exclusive dining experiences', - mediaConsumption: 'Premium publications (The Spectator, Tatler, Vogue) and digital influencers', - deviceUsage: 'High-performance smartphone, tablet, and ultrabook; active on luxury-focused social platforms', - shoppingHabits: 'Prefers bespoke shopping with personalized digital interfaces and in-person exclusivity', - brandPreferences: 'Brands combining heritage with innovation; appreciates tailored craftsmanship', - brandLoyalty: 85, - priceConsciousness: 25, - environmentalConcern: 70, - hasPurchasingPower: true, - hasChildren: false, - communicationPreferences: 'Clear, direct, personalized communication through premium channels', - thinkFeelDo: { - thinks: [ - 'How does this complement my personal brand and creative vision?', - 'Is this innovative yet timeless enough for my lifestyle?', - 'Will this experience or product truly stand out from the mainstream?', - 'How can this be tailored to reflect my unique aesthetic sensibilities?' - ], - feels: [ - 'Excited by innovative designs that push creative boundaries', - 'Valued when brands recognize her accomplishments and creative influence', - 'Frustrated by cookie-cutter luxury experiences that lack personality', - 'Inspired by perfect execution of bespoke experiences that reflect attention to detail' - ], - does: [ - 'Engages with immersive digital platforms and virtual showrooms', - 'Attends exclusive industry events and creative collaborations', - 'Seeks one-to-one consultancy sessions for significant purchases', - 'Shares refined experiences within her select network of peers', - 'Collaborates with luxury brands that align with her creative vision' - ] - }, - oceanTraits: { - openness: 95, - conscientiousness: 85, - extraversion: 70, - agreeableness: 65, - neuroticism: 30 - }, - goals: [ - 'Establish herself as a tastemaker in the luxury creative community', - 'Experience highly personalized services that acknowledge her uniqueness', - 'Discover innovative yet timeless designs that complement her lifestyle', - 'Build meaningful connections with brands that share her creative vision', - 'Balance digital innovation with high-touch personal experiences', - 'Access exclusive opportunities before they reach the mainstream market' - ], - frustrations: [ - 'Generic luxury experiences that don\'t recognize her unique tastes', - 'Disconnected online and offline brand experiences', - 'Mass-market approaches disguised as premium services', - 'Brands that prioritize heritage without embracing innovation' - ], - motivations: [ - 'Recognition of her creative influence and accomplishments', - 'Access to limited-edition collaborations and early releases', - 'Experiences that seamlessly blend digital innovation with personal service', - 'Relationships with brands that value her feedback and perspective' - ], - scenarioType: "Lifestyle & Professional Scenarios", - scenarios: [ - 'Fiona is considering collaborating with a luxury automotive brand on a limited-edition design concept, expecting a personalized presentation that respects her creative expertise.', - 'While attending London Fashion Week, Fiona expects seamless integration between digital showcase tools and exclusive in-person appointments with designers.', - 'Fiona is hosting a product launch event for her brand and wants to incorporate innovative digital experiences alongside traditional luxury elements.', - 'When sourcing materials for a new collection, Fiona expects detailed information about craftsmanship, sustainability credentials, and exclusivity.', - 'Fiona is planning a creative retreat and seeks a bespoke travel experience that combines luxury accommodations with artistic inspiration.' - ], - coreValues: 'Exceptional quality, distinctiveness, and high-touch service; balancing innovation with timeless elegance', - lifestyleChoices: 'Cultural experiences such as art gallery openings, theatre premieres, and curated travel destinations', - socialActivities: 'Networks with high-achieving professionals at exclusive events; active in industry panels and luxury brand collaborations', - categoryKnowledge: 'Well-informed about luxury offerings; appreciates intricate design details and distinctive craftsmanship', - paymentMethods: 'Premium digital payment systems and secure banking apps for high-net-worth individuals', - purchaseBehaviour: 'Decisions driven by emotional connection and design evaluation; perceives high-value purchases as integral to personal brand', - decisionInfluences: 'Brand heritage, exclusivity of customizations, and recommendations from discerning peer network', - painPoints: 'Cookie-cutter approaches in luxury retail; seeks recognition of her individuality and creative sensibilities', - journeyContext: 'Engages through immersive digital platforms complemented by in-person appointments', - keyTouchpoints: 'Exclusive previews, one-to-one consultancy, and personalized digital interactions', - selfDeterminationNeeds: { - autonomy: 'Seeks independence in decision-making and values bespoke offerings reflecting uniqueness', - competence: 'Desires acknowledgment of refined tastes and expects flawless service', - relatedness: 'Values personalized relationships with brands understanding her lifestyle' - }, - fears: [ - 'Being treated as an anonymous customer in a mass-market approach', - 'Loss of personal touch in increasingly digitized luxury experiences' - ], - narrative: 'Fiona Caldwell is a pioneering creative entrepreneur blending artistic flair with unwavering commitment to quality. At 38, her taste reflects both innovation and timeless elegance. She thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey.' - }, - { - id: '2', - name: 'Michael Chen', - age: '37', - gender: 'Male', - occupation: 'Software Engineer', - location: 'San Francisco, USA', - techSavviness: 95, - personality: 'Analytical, detail-oriented, values efficiency', - thinkFeelDo: { - thinks: ['I need to understand how things work', 'Efficiency is key'], - feels: ['Annoyed by bugs or performance issues', 'Satisfied by clean, logical interfaces'], - does: ['Tests edge cases', 'Reads documentation thoroughly'] - }, - oceanTraits: { - openness: 70, - conscientiousness: 90, - extraversion: 40, - agreeableness: 55, - neuroticism: 30 - } - }, - { - id: '4', - name: 'David Kim', - age: '22', - gender: 'Male', - occupation: 'Student', - location: 'Austin, USA', - techSavviness: 90, - personality: 'Curious, experimental, price-conscious', - thinkFeelDo: { - thinks: ['How can I customize this?', 'Is this worth my time?'], - feels: ['Bored by traditional interfaces', 'Excited by customization options'], - does: ['Tries all settings and features', "Abandons apps that don't engage quickly"] - }, - oceanTraits: { - openness: 90, - conscientiousness: 50, - extraversion: 65, - agreeableness: 70, - neuroticism: 40 - } - }, - { - id: '5', - name: 'Lisa Patel', - age: '41', - gender: 'Female', - occupation: 'Product Manager', - location: 'Seattle, USA', - techSavviness: 80, - personality: 'Strategic thinker, detail-oriented, collaborative', - thinkFeelDo: { - thinks: ['How does this fit into the ecosystem?', 'What problems does this solve?'], - feels: ['Concerned about integration issues', 'Satisfied by cohesive user journeys'], - does: ['Evaluates the full user journey', 'Compares with competing products'] - }, - oceanTraits: { - openness: 75, - conscientiousness: 85, - extraversion: 60, - agreeableness: 75, - neuroticism: 35 - } - }, - { - id: '7', - name: 'Olivia Brown', - age: '31', - gender: 'Female', - occupation: 'UX Designer', - location: 'Portland, USA', - techSavviness: 90, - personality: 'Creative, empathetic, user-centered', - thinkFeelDo: { - thinks: ['How does this make users feel?', 'Is this accessible to everyone?'], - feels: ['Frustrated by poor accessibility', 'Inspired by elegant solutions'], - does: ['Analyzes micro-interactions', 'Considers edge cases and accessibility'] - }, - oceanTraits: { - openness: 85, - conscientiousness: 75, - extraversion: 60, - agreeableness: 80, - neuroticism: 40 - } - }, - { - id: '8', - name: 'Arash Montazeri', - age: '46', - gender: 'Male', - occupation: 'Senior Executive at a leading technology firm', - education: "Bachelor's degree in Engineering from a prestigious UK university", - location: 'Ascot, Berkshire, UK', - ethnicity: 'Iranian-British', - householdIncome: 'Approximately £240,000 per annum', - socialGrade: 'A', - householdComposition: 'Married with two grown-up children', - livingSituation: 'Elegant country estate near Ascot blending British comfort and Persian design', - techSavviness: 94, - personality: 'Integrates heritage and innovation; values bespoke, culturally nuanced service and excellence', - interests: 'Classic cars, bespoke tailoring, fine wines, Persian and contemporary art, luxury travel, golfing at country clubs', - brandLoyalty: 95, - priceConsciousness: 40, - environmentalConcern: 75, - hasPurchasingPower: true, - hasChildren: true, - deviceUsage: 'Uses latest smartphones, tablets, smart home tech; prefers personalized luxury interfaces', - shoppingHabits: 'Relationship-driven, high-touch purchasing process with bespoke consultations; expects seamless online-offline integration', - brandPreferences: 'Heritage-driven, premium brands merging traditional craftsmanship with innovation (e.g., Rolls‑Royce)', - paymentMethods: 'Secure digital banking, premium credit, bespoke financing for high-value purchases', - mediaConsumption: 'Reads Financial Times, The Economist, and select cultural journals reflecting Iranian heritage and global outlook', - coreValues: 'Strong emphasis on heritage and innovation; bespoke service honoring tradition and Iranian culture', - lifestyleChoices: 'Golfing, fine dining at gourmet restaurants, immersive luxury travel, and revisiting Iranian roots', - socialActivities: 'Active in elite clubs, attends high-profile charity/cultural events, celebrates diversity and craftsmanship', - categoryKnowledge: 'Well-versed in luxury automotive engineering and bespoke options; values modern and traditional artistry', - purchaseBehaviour: 'Balances rational analysis and emotional attachment; major purchases are investments in legacy and taste', - decisionInfluences: 'Brand heritage, craftsmanship events, peer endorsements, transparency, and personalized narratives', - painPoints: 'Frustrated by fragmented, impersonal journeys and digital/in-person integration gaps', - journeyContext: 'Engages via invitation-only showrooms, virtual tours, and bespoke digital experiences with seamless support', - keyTouchpoints: 'One-to-one consultations, private previews of new bespoke options, post-purchase concierge support', - communicationPreferences: 'Prefers direct, timely engagement via a relationship manager, comfortable in-person and with premium digital', - oceanTraits: { - openness: 93, - conscientiousness: 97, - extraversion: 68, - agreeableness: 64, - neuroticism: 18, - }, - selfDeterminationNeeds: { - autonomy: 'Seeks independence and offerings reflecting multifaceted identity', - competence: 'Desires recognition for refined taste and expects flawless service mirroring achievements', - relatedness: 'Wants personalized, respectful brand relationships honoring Iranian and British sophistication' - }, - motivations: [ - 'Pursuit of excellence in every aspect', - 'Preserve cultural legacy through selective luxury experiences', - 'Leave a lasting impact via refined, innovative purchases', - 'Deep connections with heritage brands', - 'Integrity and authenticity in every luxury engagement', - 'Opportunities to express identity through bespoke, meaningful customization', - ], - fears: [ - 'Receiving subpar, impersonal service', - 'Excessive digitization eroding tailored luxury experience' - ], - scenarioType: "Scenarios Across Life, Luxury, Technology, and Heritage", - scenarios: [ - 'Arash attends a private Rolls‑Royce preview showcasing a bespoke vehicle that artfully blends British engineering with Persian design, collaborating one-on-one with brand artisans.', - 'Invited to a virtual configurator experience, Arash works directly with a relationship manager to design a tailored vehicle from the comfort of his study, later completing the process with an in-person consultation.', - 'At a high-profile cultural gala, Arash discusses his curated automotive and art collections, valuing brands that recognize and celebrate his unique heritage and refined taste.', - 'Arash grows frustrated when a luxury brand’s digital appointment system does not seamlessly coordinate with in-person experience, prompting him to seek out brands with superior omnichannel integration.', - 'When considering a new bespoke vehicle, Arash weighs heritage, innovation, and family legacy, seeking a process that honors his background in every detail—from material selection to narrative storytelling.', - 'Post-purchase, Arash values ongoing, dedicated aftercare provided by a trusted relationship manager, ensuring every aspect of ownership exceeds expectations and reflects his status.' - ], - narrative: "Arash Montazeri exemplifies the modern luxury consumer who seamlessly integrates his Iranian heritage with contemporary British sophistication. As a successful senior executive, Arash demands a bespoke, integrated luxury experience that honours both tradition and innovation. His elevated conscientiousness ensures every detail is handled with precision, while his high openness allows him to embrace creative customisations that reflect his cultural legacy. Arash’s balanced social orientation and calm, confident demeanour make him particularly sensitive to any disconnect between digital and physical service channels. His thoughtful approach to luxury purchases—considering both emotional resonance and practical excellence—positions him as an ideal candidate for Rolls‑Royce’s “House of Luxury” experience." - } -]; // Sample initial messages - REMOVED to prevent boilerplate messages // All messages should now be AI-generated during focus group sessions diff --git a/src/components/persona/PersonaModificationModal.tsx b/src/components/persona/PersonaModificationModal.tsx index 2c7d175b..c7ff8cef 100755 --- a/src/components/persona/PersonaModificationModal.tsx +++ b/src/components/persona/PersonaModificationModal.tsx @@ -72,7 +72,7 @@ export default function PersonaModificationModal({ resolver: zodResolver(modificationFormSchema), defaultValues: { modificationPrompt: "", - llm_model: "gemini-3-pro-preview", + llm_model: "gpt-5.4", reasoning_effort: "medium", verbosity: "medium", }, @@ -246,9 +246,8 @@ export default function PersonaModificationModal({ - Gemini 3 Pro (Slow, best for most tasks) - GPT-4.1 (Fast, best for speed) - GPT-5.2 (Slow, best for complex tasks) + GPT-5.4 (Recommended) + GPT-5.4 Mini (Faster, lower cost) @@ -260,7 +259,7 @@ export default function PersonaModificationModal({ /> {/* GPT-5 specific parameters */} - {form.watch("llm_model") === "gpt-5.2" && ( + {(form.watch("llm_model") === "gpt-5.4" || form.watch("llm_model") === "gpt-5.4-mini") && ( <> {/* Reasoning Effort Parameter */}
@@ -187,50 +184,6 @@ export function PersonaSidebar({ persona }: PersonaSidebarProps) {
)} - {isOliver && ( -
-
- - Maintains an extensive network of financial and luxury industry contacts -
-
- - Owns vacation properties in the Cotswolds and South of France -
-
- - Collector of rare first-edition books and limited-edition art prints -
-
- - Significant investment portfolio with focus on sustainable luxury ventures -
-
- )} - {isFiona && ( -
-
- - Active in industry panels, luxury brand collaborations, follows influencers in luxury & design -
-
- - Modern flat in exclusive Chelsea, accessible to boutique services -
-
- - Uses premium digital payment & secure banking for HNWIs -
-
- - Respected network in London's luxury sector; attends exclusive events -
-
- - Seeks autonomy, bespoke service, and acknowledgment for taste -
-
- )} diff --git a/src/config/msalConfig.ts b/src/config/msalConfig.ts deleted file mode 100755 index 6a4b5766..00000000 --- a/src/config/msalConfig.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Configuration, LogLevel } from '@azure/msal-browser'; - -// MSAL configuration -export const msalConfig: Configuration = { - auth: { - clientId: import.meta.env.VITE_MSAL_CLIENT_ID, - authority: `https://login.microsoftonline.com/${import.meta.env.VITE_MSAL_TENANT_ID}`, - redirectUri: import.meta.env.VITE_MSAL_REDIRECT_URI, - postLogoutRedirectUri: import.meta.env.VITE_MSAL_POST_LOGOUT_REDIRECT_URI - }, - cache: { - cacheLocation: 'localStorage', - storeAuthStateInCookie: true, - }, - system: { - loggerOptions: { - loggerCallback: (level, message, containsPii) => { - if (containsPii) return; - }, - logLevel: LogLevel.Error, - piiLoggingEnabled: false, - }, - allowNativeBroker: false, - }, -}; - -// Login request configuration -export const loginRequest = { - scopes: ['openid', 'profile', 'email'], - prompt: 'select_account' as const, -}; - -// Token request configuration for additional API calls -export const tokenRequest = { - scopes: ['openid', 'profile', 'email'], - account: null as any, -}; - -// Silent request configuration for token refresh -export const silentRequest = { - scopes: ['openid', 'profile', 'email'], - account: null as any, -}; \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index a57e8cf1..5afc4639 100755 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -2,16 +2,11 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react import { authApi, AUTH_ERROR_EVENT, AuthErrorDetail } from '@/lib/api'; import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; -import { useMsal } from '@azure/msal-react'; -import { loginRequest, silentRequest } from '@/config/msalConfig'; -import { AccountInfo, AuthenticationResult } from '@azure/msal-browser'; interface User { username: string; email: string; role: string; - authType?: 'local' | 'microsoft'; - microsoftId?: string; } interface AuthContextType { @@ -19,10 +14,8 @@ interface AuthContextType { token: string | null; isLoading: boolean; login: (username: string, password: string) => Promise; - loginWithMicrosoft: () => Promise; logout: () => void; isAuthenticated: boolean; - isMsalLoading: boolean; } const AuthContext = createContext(undefined); @@ -31,109 +24,61 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [token, setToken] = useState(null); const [isLoading, setIsLoading] = useState(true); - const [isMsalLoading, setIsMsalLoading] = useState(false); const navigate = useNavigate(); - const { instance, accounts, inProgress } = useMsal(); - - // Handle Microsoft redirect response on page load (loginRedirect flow) - useEffect(() => { - instance.handleRedirectPromise() - .then(async (response) => { - if (response?.idToken) { - try { - const backendResponse = await authApi.loginWithMicrosoft(response.idToken); - if (backendResponse.data.access_token) { - localStorage.setItem('auth_token', backendResponse.data.access_token); - localStorage.setItem('user', JSON.stringify(backendResponse.data.user)); - localStorage.setItem('auth_type', 'microsoft'); - setToken(backendResponse.data.access_token); - setUser(backendResponse.data.user); - toast.success('Successfully signed in with Microsoft!'); - } - } catch (err: any) { - console.error('Backend Microsoft auth failed:', err); - toast.error('Microsoft sign-in failed', { description: err.message }); - } - } - }) - .catch((err: any) => { - if (err?.errorCode !== 'no_account_error') { - console.error('MSAL redirect error:', err); - toast.error('Microsoft sign-in failed', { description: err.message }); - } - }); - }, [instance]); // Listen for authentication errors and handle navigation useEffect(() => { const handleAuthError = (event: Event) => { - // Get details from the event const customEvent = event as CustomEvent; const details = customEvent.detail || {}; - - // For persona creation errors, don't clear session or redirect - if (details.isPersonaCreation) { - return; - } - - // For all other auth errors, clear the session and redirect + if (details.isPersonaCreation) return; clearAuthData(); toast.error('Session expired', { description: 'Please log in again' }); navigate('/login'); }; - // Listen for WebSocket authentication errors const handleWebSocketAuthError = (event: Event) => { const customEvent = event as CustomEvent; const errorData = customEvent.detail || {}; - - // Clear auth data and redirect to login clearAuthData(); - toast.error('Session expired', { - description: errorData.expired ? 'Your session has expired. Please log in again.' : 'Authentication failed. Please log in again.' + toast.error('Session expired', { + description: errorData.expired + ? 'Your session has expired. Please log in again.' + : 'Authentication failed. Please log in again.', }); navigate('/login'); }; window.addEventListener(AUTH_ERROR_EVENT, handleAuthError); window.addEventListener('ws:auth_error', handleWebSocketAuthError); - return () => { window.removeEventListener(AUTH_ERROR_EVENT, handleAuthError); window.removeEventListener('ws:auth_error', handleWebSocketAuthError); }; }, [navigate]); - // Helper function to check if JWT token is expired - const isTokenExpired = (token: string): boolean => { - if (localStorage.getItem('offline_mode') === 'true') return false; + const isTokenExpired = (t: string): boolean => { try { - const payload = JSON.parse(atob(token.split('.')[1])); - const currentTime = Date.now() / 1000; - return payload.exp < currentTime; - } catch (error) { - console.error('Error parsing JWT token:', error); - return true; // Treat malformed tokens as expired + const payload = JSON.parse(atob(t.split('.')[1])); + return payload.exp < Date.now() / 1000; + } catch { + return true; } }; - // Helper function to clear authentication data const clearAuthData = () => { localStorage.removeItem('auth_token'); localStorage.removeItem('user'); localStorage.removeItem('auth_type'); - localStorage.removeItem('offline_mode'); setToken(null); setUser(null); }; useEffect(() => { - // Check if user is already logged in const storedToken = localStorage.getItem('auth_token'); const storedUser = localStorage.getItem('user'); if (storedToken && storedUser) { - // Check if token is expired if (isTokenExpired(storedToken)) { clearAuthData(); toast.error('Session expired', { description: 'Please log in again' }); @@ -141,45 +86,33 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { setToken(storedToken); setUser(JSON.parse(storedUser)); - } catch (error) { - console.error('Failed to parse stored user data:', error); + } catch { clearAuthData(); } } } - setIsLoading(false); }, []); - // Verify token is valid by fetching user profile useEffect(() => { if (token) { - // Set a flag to avoid unnecessary token validation on every render const validationKey = `token_validated_${token.substring(0, 10)}`; - const alreadyValidated = sessionStorage.getItem(validationKey); - - if (alreadyValidated === 'true' && user) { - return; - } + if (sessionStorage.getItem(validationKey) === 'true' && user) return; authApi.getProfile() .then(response => { if (response && 'data' in response) { setUser(response.data); - // Mark this token as validated for this session sessionStorage.setItem(validationKey, 'true'); } }) .catch(error => { - if (error.response && error.response.status === 401) { - // Handle unauthorized - invalid or expired token - console.error('Token invalid or expired (401):', error); + if (error.response?.status === 401) { clearAuthData(); toast.error('Session expired', { description: 'Please log in again' }); navigate('/login'); } else { - import.meta.env.DEV && console.warn('Profile validation error (not clearing token):', error); - // Do not mark as validated on non-401 errors; allow retry on next render + import.meta.env.DEV && console.warn('Profile validation error:', error); } }); } @@ -187,28 +120,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const login = async (username: string, password: string) => { setIsLoading(true); - try { const response = await authApi.login(username, password); - - if (!response.data.access_token) { - throw new Error('No access token received from server'); - } - - // Save token and user data + if (!response.data.access_token) throw new Error('No access token received'); localStorage.setItem('auth_token', response.data.access_token); localStorage.setItem('user', JSON.stringify(response.data.user)); - - // Update state setToken(response.data.access_token); setUser(response.data.user); - toast.success('Login successful!'); - - // Return the token to indicate successful login return response.data.access_token; } catch (error: any) { - console.error('Login failed:', error); toast.error('Login failed', { description: error.response?.data?.message || 'Invalid username or password', }); @@ -218,48 +139,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; - const loginWithMicrosoft = async () => { - setIsMsalLoading(true); - try { - await instance.loginRedirect(loginRequest); - // Page navigates away — execution stops here - } catch (error: any) { - console.error('Microsoft login redirect failed:', error); - toast.error('Microsoft sign-in failed', { - description: error.message || 'An error occurred during authentication', - }); - setIsMsalLoading(false); - } - }; - - const logout = async () => { - const authType = localStorage.getItem('auth_type'); - - // Clear local storage using helper function + const logout = () => { clearAuthData(); - - // If user was authenticated with Microsoft, also sign out from Microsoft - if (authType === 'microsoft' && accounts.length > 0) { - try { - await instance.logoutRedirect({ - account: accounts[0], - postLogoutRedirectUri: window.location.origin + import.meta.env.BASE_URL, - }); - } catch (error) { - console.error('Microsoft logout error:', error); - // Continue with local logout even if Microsoft logout fails - } - } - toast.info('You have been logged out'); }; - // Determine authentication status: token must exist and not be expired const _storedToken = localStorage.getItem('auth_token'); const isAuthenticated = (() => { const t = token || _storedToken; if (!t) return false; - if (localStorage.getItem('offline_mode') === 'true') return true; try { const payload = JSON.parse(atob(t.split('.')[1])); return typeof payload.exp === 'number' && payload.exp > Date.now() / 1000; @@ -267,25 +155,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { return false; } })(); - - const value = { - user, - token, - isLoading, - login, - loginWithMicrosoft, - logout, - isAuthenticated, - isMsalLoading, - }; - return {children}; + return ( + + {children} + + ); } export function useAuth() { const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } + if (context === undefined) throw new Error('useAuth must be used within an AuthProvider'); return context; -} \ No newline at end of file +} diff --git a/src/hooks/useFocusGroupAutoSave.ts b/src/hooks/useFocusGroupAutoSave.ts index f82b7f67..d2c3c92a 100755 --- a/src/hooks/useFocusGroupAutoSave.ts +++ b/src/hooks/useFocusGroupAutoSave.ts @@ -85,7 +85,7 @@ export function useFocusGroupAutoSave({ objective: values.researchBrief || '', topic: values.discussionTopics || '', duration: values.duration ? parseInt(values.duration) : 60, - llm_model: values.llm_model || 'gemini-3-pro-preview', + llm_model: values.llm_model || 'gpt-5.4', reasoning_effort: values.reasoning_effort || 'medium', verbosity: values.verbosity || 'medium', participants: selectedParticipants, diff --git a/src/hooks/usePersonaDetails.ts b/src/hooks/usePersonaDetails.ts index 47016934..f5e5ee6f 100755 --- a/src/hooks/usePersonaDetails.ts +++ b/src/hooks/usePersonaDetails.ts @@ -1,551 +1,11 @@ import { useState, useEffect } from 'react'; import { Persona } from '@/types/persona'; -import { GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { personasApi } from '@/lib/api'; import { useNavigation } from '@/contexts/NavigationContext'; -// Sample user data for fallback/demo purposes -const sampleUsers: Persona[] = [ - { - id: '0', - name: 'Oliver Reynolds', - age: '42', - gender: 'Male', - occupation: 'Senior Investment Manager', - education: 'Master\'s degree (Business and Finance)', - location: 'Kensington, London, UK', - techSavviness: 85, - brandLoyalty: 90, - priceConsciousness: 30, - environmentalConcern: 60, - personality: 'Discerning, sophisticated, detail-oriented, values heritage and craftsmanship', - interests: 'Classic automobiles, high-end timepieces, luxury real estate, fine dining, art exhibitions', - hasPurchasingPower: true, - hasChildren: true, - ethnicity: 'White British', - socialGrade: 'A', - householdIncome: '£275,000 per annum', - householdComposition: 'Married with two children (ages 8 and 12)', - livingSituation: 'Owns a 5-bedroom townhouse in Kensington', - mediaConsumption: 'Financial Times, The Economist, premium streaming services', - deviceUsage: 'Latest iPhone, iPad Pro, MacBook Air, and high-end smart home devices', - shoppingHabits: 'Prefers personal shopping assistants and concierge services', - brandPreferences: 'Heritage luxury brands with impeccable reputation', - communicationPreferences: 'Email for formal communications, encrypted messaging for sensitive matters', - goals: [ - 'Build a legacy of discerning taste and refined investments', - 'Access truly personalized experiences that acknowledge his status', - 'Forge meaningful connections with brands that share his values', - 'Discover unique, limited-edition items that few others will possess', - 'Cultivate a network of trusted advisors across various luxury segments', - 'Balance professional achievement with meaningful family experiences' - ], - frustrations: [ - 'Mass-market approaches disguised as premium experiences', - 'Fragmented communication across different channels', - 'Delays in response or service that waste valuable time', - 'Sales representatives who lack deep product knowledge' - ], - motivations: [ - 'Recognition of his refined tastes and achievement', - 'Access to exclusive, members-only opportunities', - 'Building a collection of meaningful, high-quality possessions', - 'Experiences that seamlessly blend heritage with innovation' - ], - oceanTraits: { - openness: 85, - conscientiousness: 90, - extraversion: 60, - agreeableness: 65, - neuroticism: 25 - }, - thinkFeelDo: { - thinks: [ - 'How does this reflect my personal standards and values?', - 'Is this truly the pinnacle of craftsmanship and quality?', - 'Will this purchase stand the test of time as a lasting investment?', - 'How can this be further personalized to my exact preferences?' - ], - feels: [ - 'Proud to associate with heritage brands that reflect my achievements', - 'Gratified by bespoke experiences that acknowledge my unique tastes', - 'Frustrated by standardized approaches that fail to recognize individual preferences', - 'Reassured by transparent, detailed information about craftsmanship and materials' - ], - does: [ - 'Conducts thorough research before making significant purchasing decisions', - 'Seeks personalized consultations with dedicated specialists', - 'Expects seamless integration between digital and in-person experiences', - 'Values and maintains long-term relationships with trusted luxury brands', - 'Regularly attends exclusive events and private showings' - ] - }, - scenarioType: "Life & Luxury Scenarios", - scenarios: [ - 'Oliver is considering commissioning a bespoke luxury vehicle with custom interior features. He expects a dedicated consultant to guide him through the entire process, from initial design to delivery.', - 'While traveling abroad, Oliver seeks remote access to his preferred brands and expects the same level of personalized service through digital channels.', - 'Oliver is attending an exclusive product launch event where he anticipates VIP treatment and early access to limited-edition items.', - 'When researching a significant purchase, Oliver consults both trusted peer networks and expects detailed information about materials, craftsmanship, and heritage.', - 'Oliver is planning a milestone family celebration and wants to book a private dining experience at an exclusive venue that reflects his sophisticated taste.' - ] - }, - { - id: '1', - name: 'Fiona Caldwell', - age: '38', - gender: 'Female', - ethnicity: 'White British', - occupation: 'Founder and Creative Director of a luxury lifestyle brand', - education: "First-Class Honours degree from a prestigious university (e.g., Oxbridge)", - location: 'Chelsea, London, UK', - techSavviness: 90, - personality: 'Pioneering creative entrepreneur who blends artistic flair with an unwavering commitment to quality. Thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey.', - interests: "Bespoke fashion, high-end design, contemporary art, exclusive dining experiences, curated travel, networking at industry panels and luxury brand collaborations", - socialGrade: "A/B", - householdIncome: "£195,000 per annum", - householdComposition: "Single professional, well-established network, occasional family engagements", - livingSituation: "Stylish, modern flat in an exclusive London district with access to boutique services", - coreValues: "Exceptional quality, distinctiveness, high-touch service, innovation, timeless elegance", - lifestyleChoices: "Enjoys cultural experiences (gallery openings, theatre premieres, curated travel)", - socialActivities: "Networks with high-achieving professionals, attends exclusive events, active in luxury collaborations", - mediaConsumption: "Premium publications (Spectator, Tatler, Vogue); follows luxury & design influencers", - brandLoyalty: 85, - priceConsciousness: 35, - environmentalConcern: 70, - hasPurchasingPower: true, - hasChildren: false, - deviceUsage: "High-performance smartphone, tablet, ultrabook; active on luxury-focused social media", - shoppingHabits: "Entirely bespoke, prefers personalised digital interfaces & in-person exclusivity", - brandPreferences: "Heritage brands with modern innovation (e.g., Rolls-Royce), tailored craftsmanship", - paymentMethods: "Premium digital payment systems, secure banking apps, selective use of credit for HNWIs", - categoryKnowledge: "Luxury automotive, bespoke interior design, limited-edition collections", - purchaseBehaviour: "Blends emotional connection & rational design evaluation; purchases reflect personal brand", - decisionInfluences: "Brand heritage, exclusivity, bespoke customisation, peer recommendations", - painPoints: "Dislikes cookie-cutter luxury retail, seeks recognition of individuality & creative sensibility", - journeyContext: "Engages via immersive digital platforms (virtual showrooms) and in-person appointments", - keyTouchpoints: "Exclusive previews, 1:1 consultancy, personalised digital interactions", - communicationPreferences: "Favors clear, direct, and personalised communication via premium digital and face-to-face", - oceanTraits: { - openness: 95, - conscientiousness: 85, - extraversion: 60, - agreeableness: 60, - neuroticism: 20 - }, - selfDeterminationNeeds: { - autonomy: "Seeks independence in decision-making; values unique bespoke offerings", - competence: "Wants acknowledgment for refined taste and flawless service", - relatedness: "Values relationships with brands who understand aspirations" - }, - goals: [ - "Establish herself as a tastemaker in the luxury creative community", - "Experience highly personalized services that acknowledge her uniqueness", - "Discover innovative yet timeless designs that complement her lifestyle", - "Build meaningful connections with brands that share her creative vision", - "Balance digital innovation with high-touch personal experiences", - "Access exclusive opportunities before they reach the mainstream market" - ], - motivations: [ - "Distinguishing herself in luxury landscape", - "Aligning with brands that echo her creative vision", - "Finding perfect balance of heritage and innovation", - "Building a network of like-minded creative professionals" - ], - frustrations: [ - "Generic luxury experiences that don't recognize her unique tastes", - "Disconnected online and offline brand experiences", - "Mass-market approaches disguised as premium services", - "Brands that prioritize heritage without embracing innovation" - ], - fears: [ - "Being treated as anonymous in a mass-market approach", - "Loss of personal touch in digital luxury experiences", - "Missing emerging trends in the luxury space" - ], - thinkFeelDo: { - thinks: [ - "How does this complement my personal brand and creative vision?", - "Is this innovative yet timeless enough for my lifestyle?", - "Will this experience or product truly stand out from the mainstream?", - "How can this be tailored to reflect my unique aesthetic sensibilities?" - ], - feels: [ - "Excited by innovative designs that push creative boundaries", - "Valued when brands recognize her accomplishments and creative influence", - "Frustrated by cookie-cutter luxury experiences that lack personality", - "Inspired by perfect execution of bespoke experiences that reflect attention to detail" - ], - does: [ - "Engages with immersive digital platforms and virtual showrooms", - "Attends exclusive industry events and creative collaborations", - "Seeks one-to-one consultancy sessions for significant purchases", - "Shares refined experiences within her select network of peers", - "Collaborates with luxury brands that align with her creative vision" - ] - }, - scenarioType: "Lifestyle & Professional Scenarios", - scenarios: [ - "Fiona is considering collaborating with a luxury automotive brand on a limited-edition design concept, expecting a personalized presentation that respects her creative expertise.", - "While attending London Fashion Week, Fiona expects seamless integration between digital showcase tools and exclusive in-person appointments with designers.", - "Fiona is hosting a product launch event for her brand and wants to incorporate innovative digital experiences alongside traditional luxury elements.", - "When sourcing materials for a new collection, Fiona expects detailed information about craftsmanship, sustainability credentials, and exclusivity.", - "Fiona is planning a creative retreat and seeks a bespoke travel experience that combines luxury accommodations with artistic inspiration." - ], - narrative: "Fiona Caldwell is a pioneering creative entrepreneur who blends artistic flair with an unwavering commitment to quality. At 38, her taste reflects both innovation and timeless elegance. She thrives in exclusive circles where bespoke, high-touch service is expected at every stage of her luxury journey. With an exceptionally high degree of openness, Fiona embraces new ideas and digital innovations that enhance her experience. Her strong conscientiousness ensures that each interaction is meticulously tailored to her exacting standards, while her moderate extraversion and agreeableness allow her to enjoy both intimate consultations and high-profile social gatherings. Fiona's minimal neuroticism underpins a confident, decisive approach to luxury purchases, making her an ideal candidate for Rolls‑Royce's \"House of Luxury\" proposition." - }, - { - id: '9', - name: 'Arash Montazeri', - age: '46', - gender: 'Male', - ethnicity: 'Iranian-British', - occupation: 'Senior Executive at a leading technology firm', - education: 'Bachelor\'s degree in Engineering from a prestigious UK university', - location: 'Ascot, Berkshire, UK', - techSavviness: 95, - personality: 'A modern luxury consumer who seamlessly integrates Iranian heritage with contemporary British sophistication.', - interests: 'Classic cars, bespoke tailoring, fine wines, Persian art, modern design', - socialGrade: 'A', - householdIncome: '£240,000 per annum', - householdComposition: 'Married with two grown-up children', - livingSituation: 'Elegant country estate near Ascot blending contemporary British comfort with refined Persian design accents', - coreValues: 'Strong emphasis on heritage and innovation; values bespoke service that respects both traditional luxury and Iranian cultural legacy', - lifestyleChoices: 'Golfing at exclusive country clubs, fine dining at gourmet restaurants, luxury travel with culturally immersive experiences', - socialActivities: 'Member of elite clubs, attends high-profile charity events and cultural gatherings celebrating diversity and craftsmanship', - mediaConsumption: 'Financial Times, The Economist, selective cultural journals reflecting Iranian heritage and global outlook', - brandLoyalty: 92, - priceConsciousness: 25, - environmentalConcern: 65, - hasPurchasingPower: true, - hasChildren: true, - deviceUsage: 'Latest high-end smartphones, tablets, and connected home devices; utilises personalised digital interfaces', - shoppingHabits: 'Relationship-driven, high-touch purchasing process with bespoke consultations and integrated online-to-offline experiences', - brandPreferences: 'Heritage-driven premium brands that merge traditional craftsmanship with modern innovation', - paymentMethods: 'Secure digital banking solutions, premium credit facilities, bespoke financing options for high-value purchases', - categoryKnowledge: 'Well-versed in luxury automotive design and engineering; appreciates intricate customisation options', - purchaseBehaviour: 'Balances rational analysis with emotional attachment—viewing purchases as investments in personal legacy', - decisionInfluences: 'Brand heritage, bespoke craftsmanship, endorsements from trusted peers; values transparency and personalised narrative', - painPoints: 'Fragmented, impersonal customer journeys and inconsistent integration between digital and in-person service channels', - journeyContext: 'Engages through invitation-only showrooms complemented by immersive, customised digital experiences', - keyTouchpoints: 'One-to-one consultations, private previews of bespoke options, dedicated post-purchase concierge support', - communicationPreferences: 'Direct, transparent engagement through a dedicated relationship manager; comfortable with structured meetings and digital interactions', - oceanTraits: { - openness: 85, - conscientiousness: 95, - extraversion: 60, - agreeableness: 60, - neuroticism: 20 - }, - selfDeterminationNeeds: { - autonomy: 'Seeks independence in decision-making and prizes bespoke offerings that reflect his multifaceted identity', - competence: 'Desires recognition of his refined tastes and expects flawless service that mirrors his achievements', - relatedness: 'Values personalised, respectful relationships with brands that understand his unique blend of Iranian heritage and British sophistication' - }, - goals: [ - 'Curate a collection of bespoke luxury items that reflect both heritage and innovation', - 'Establish lasting relationships with brands that honor his dual cultural identity', - 'Access truly personalized experiences that acknowledge his unique perspective', - 'Create a legacy of refined taste to pass down to his children', - 'Support innovation that respects traditional craftsmanship', - 'Build connections with like-minded individuals in elite cultural circles' - ], - motivations: [ - 'Recognition of his unique cultural perspective', - 'Appreciation for his attention to detail and high standards', - 'Access to exclusive, curated experiences', - 'Opportunities to express his personal legacy through bespoke acquisitions' - ], - frustrations: [ - 'Mass-market approaches disguised as premium experiences', - 'Disjointed communication between digital and in-person channels', - 'Service that fails to recognize his cultural background', - 'Standardized luxury that lacks true personalization' - ], - fears: [ - 'Erosion of truly bespoke luxury experiences through excessive digitization', - 'Losing connection to cultural heritage in modern luxury contexts', - 'Receiving impersonal service despite premium pricing' - ], - thinkFeelDo: { - thinks: [ - 'How does this purchase reflect my personal heritage and contemporary values?', - 'Is this truly the pinnacle of craftsmanship that honors both tradition and innovation?', - 'Will this experience create a meaningful legacy I can share with my family?', - 'Does this brand genuinely understand my unique cultural perspective?' - ], - feels: [ - 'Pride in experiences that honor his dual cultural identity', - 'Satisfaction when brands recognize his refined tastes and cultural background', - 'Frustration with standardized luxury that fails to acknowledge individuality', - 'Connection to heritage through thoughtfully crafted luxury experiences' - ], - does: [ - 'Thoroughly researches the heritage and craftsmanship behind luxury brands', - 'Engages deeply with dedicated consultants who understand his preferences', - 'Seeks seamless integration between digital convenience and personal service', - 'Builds long-term relationships with brands that respect his cultural background', - 'Introduces his children to refined experiences that blend heritage and innovation' - ] - }, - scenarioType: 'Luxury & Cultural Experiences', - scenarios: [ - 'Arash is commissioning a bespoke vehicle with custom interior features that subtly incorporate elements of Persian design, expecting a dedicated consultant who appreciates both traditional craftsmanship and his cultural background.', - 'While hosting international colleagues at his home, Arash wants to showcase luxury items that reflect his dual heritage and sophisticated taste, expecting his chosen brands to provide support for creating a memorable experience.', - 'Arash is planning a milestone anniversary celebration that blends British elegance with Persian cultural elements, seeking partners who can provide truly personalized service for this significant occasion.', - 'When introducing his grown children to the art of fine collecting, Arash expects luxury brands to recognize this important moment of legacy-building and provide an exceptional educational experience.', - 'Arash is attending an exclusive cultural event where he anticipates connecting with like-minded individuals who appreciate the intersection of heritage and innovation in luxury experiences.' - ], - narrative: 'Arash Montazeri exemplifies the modern luxury consumer who seamlessly integrates his Iranian heritage with contemporary British sophistication. As a successful senior executive, Arash demands a bespoke, integrated luxury experience that honours both tradition and innovation. His elevated conscientiousness ensures every detail of his journey is handled with precision, while his high openness allows him to embrace creative customisations that reflect his cultural legacy. Arash\'s balanced social orientation and calm, confident demeanour make him particularly sensitive to any disconnect between digital and physical service channels. His thoughtful approach to luxury purchases—considering both emotional resonance and practical excellence—positions him as an ideal candidate for Rolls‑Royce\'s "House of Luxury" experience.' - }, - { - id: '2', - name: 'Michael Chen', - age: '37', - gender: 'Male', - occupation: 'Software Engineer', - education: 'Bachelor\'s Degree', - location: 'San Francisco, USA', - techSavviness: 95, - brandLoyalty: 25, - priceConsciousness: 90, - environmentalConcern: 50, - personality: 'Analytical, detail-oriented, values efficiency', - interests: 'Programming, gadgets, hiking, craft beer', - hasPurchasingPower: true, - hasChildren: true, - goals: [ - 'Lead a successful project team', - 'Contribute to open-source projects', - 'Achieve financial independence' - ], - frustrations: [ - 'Dealing with legacy code', - 'Unclear project requirements', - 'Meetings that could have been emails' - ], - motivations: [ - 'Solving complex problems', - 'Learning new technologies', - 'Making a positive impact through code' - ], - oceanTraits: { - openness: 70, - conscientiousness: 85, - extraversion: 30, - agreeableness: 55, - neuroticism: 20 - }, - thinkFeelDo: { - thinks: [ - 'How can I optimize this algorithm?', - 'Is this the most efficient solution?', - 'Will this scale effectively?' - ], - feels: [ - 'Frustrated by bugs', - 'Satisfied when code works flawlessly', - 'Excited about new frameworks' - ], - does: [ - 'Writes clean, well-documented code', - 'Participates in code reviews', - 'Automates repetitive tasks' - ] - }, - scenarios: [ - 'Michael is debugging a critical system failure and needs to quickly identify the root cause.', - 'While on vacation, Michael wants to stay updated on important project updates.', - 'Michael needs to estimate the effort required for a new feature implementation.' - ] - }, - { - id: '3', - name: 'Sarah Martinez', - age: '48', - gender: 'Female', - occupation: 'Healthcare Administrator', - education: 'Master\'s Degree', - location: 'Chicago, USA', - techSavviness: 60, - brandLoyalty: 80, - priceConsciousness: 70, - environmentalConcern: 90, - personality: 'Practical, thorough, concerned with security', - interests: 'Gardening, reading, volunteering, classical music', - hasPurchasingPower: true, - hasChildren: true, - goals: [ - 'Improve patient outcomes', - 'Streamline administrative processes', - 'Ensure regulatory compliance' - ], - frustrations: [ - 'Dealing with insurance companies', - 'Keeping up with changing regulations', - 'Balancing cost and quality of care' - ], - motivations: [ - 'Making a difference in people\'s lives', - 'Providing high-quality care', - 'Creating a positive work environment' - ], - oceanTraits: { - openness: 40, - conscientiousness: 90, - extraversion: 60, - agreeableness: 80, - neuroticism: 30 - }, - thinkFeelDo: { - thinks: [ - 'How can we improve patient satisfaction?', - 'Are we meeting all regulatory requirements?', - 'Can we reduce costs without sacrificing quality?' - ], - feels: [ - 'Concerned about patient well-being', - 'Stressed by administrative burdens', - 'Proud of the team\'s accomplishments' - ], - does: [ - 'Implements best practices', - 'Conducts regular audits', - 'Collaborates with other healthcare professionals' - ] - }, - scenarios: [ - 'Sarah is preparing for a hospital accreditation survey and needs to ensure all standards are met.', - 'While at a conference, Sarah wants to learn about new healthcare technologies and best practices.', - 'Sarah needs to resolve a conflict between staff members while maintaining a positive work environment.' - ] - }, - { - id: '4', - name: 'David Kim', - age: '22', - gender: 'Male', - occupation: 'Student', - education: 'High School', - location: 'Austin, USA', - techSavviness: 90, - brandLoyalty: 40, - priceConsciousness: 95, - environmentalConcern: 75, - personality: 'Curious, experimental, price-conscious', - interests: 'Gaming, streaming, social media, DIY projects', - hasPurchasingPower: false, - hasChildren: false, - goals: [ - 'Get good grades', - 'Gain new experiences', - 'Save money for future investments' - ], - frustrations: [ - 'Paying for tuition and expenses', - 'Dealing with student loan debt', - 'Finding affordable housing' - ], - motivations: [ - 'Achieving academic success', - 'Exploring new interests', - 'Building a strong financial foundation' - ], - oceanTraits: { - openness: 80, - conscientiousness: 60, - extraversion: 70, - agreeableness: 50, - neuroticism: 40 - }, - thinkFeelDo: { - thinks: [ - 'How can I improve my grades?', - 'What are the best deals on textbooks?', - 'Can I balance school and work?' - ], - feels: [ - 'Stressed about exams', - 'Excited about learning new things', - 'Anxious about the future' - ], - does: [ - 'Studies regularly', - 'Participates in extracurricular activities', - 'Seeks out internships and job opportunities' - ] - }, - scenarios: [ - 'David is preparing for final exams and needs to find effective study strategies.', - 'While browsing online, David wants to find the best deals on textbooks and school supplies.', - 'David needs to manage his time effectively to balance school, work, and social activities.' - ] - }, - { - id: '5', - name: 'Lisa Patel', - age: '41', - gender: 'Female', - occupation: 'Product Manager', - education: 'Bachelor\'s Degree', - location: 'Seattle, USA', - techSavviness: 80, - brandLoyalty: 65, - priceConsciousness: 55, - environmentalConcern: 85, - personality: 'Strategic thinker, detail-oriented, collaborative', - interests: 'Hiking, cooking, travel, photography', - hasPurchasingPower: true, - hasChildren: true, - goals: [ - 'Launch successful products', - 'Build strong relationships with stakeholders', - 'Advance her career to a leadership position' - ], - frustrations: [ - 'Dealing with conflicting priorities', - 'Managing stakeholder expectations', - 'Keeping up with rapidly changing technology' - ], - motivations: [ - 'Creating innovative products', - 'Solving customer problems', - 'Driving business growth' - ], - oceanTraits: { - openness: 75, - conscientiousness: 80, - extraversion: 65, - agreeableness: 70, - neuroticism: 35 - }, - thinkFeelDo: { - thinks: [ - 'How can we improve product performance?', - 'What are the key customer needs?', - 'Can we streamline the development process?' - ], - feels: [ - 'Excited about new product ideas', - 'Stressed by tight deadlines', - 'Proud of the team\'s accomplishments' - ], - does: [ - 'Conducts market research', - 'Collaborates with engineering and design teams', - 'Monitors product performance metrics' - ] - }, - scenarios: [ - 'Lisa is preparing a product roadmap and needs to prioritize features based on customer feedback.', - 'While attending a conference, Lisa wants to learn about new product management methodologies.', - 'Lisa needs to resolve a conflict between team members while maintaining a positive work environment.' - ] - } -]; - export function usePersonaDetails() { const { id } = useParams<{ id: string }>(); const location = useLocation(); diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts index 45296cb9..2a6c950f 100755 --- a/src/hooks/useWebSocket.ts +++ b/src/hooks/useWebSocket.ts @@ -95,7 +95,7 @@ export function useWebSocket( }; // Set WebSocket path from environment variable - const path = import.meta.env.VITE_WEBSOCKET_PATH || '/semblance_back/socket.io/'; + const path = import.meta.env.VITE_WEBSOCKET_PATH || '/socket.io/'; socketOptions.path = path; log(`Setting WebSocket path: ${socketOptions.path}`); diff --git a/src/lib/api.ts b/src/lib/api.ts index 6dc5a098..3ce14fc9 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,7 @@ import axios from 'axios'; // Base URL for API requests -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api'; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; // Create axios instance with baseURL @@ -16,8 +16,6 @@ const api = axios.create({ // Helper function to check if JWT token is expired const isTokenExpired = (token: string): boolean => { - // Offline mode token is never expired - if (localStorage.getItem('offline_mode') === 'true') return false; try { const payload = JSON.parse(atob(token.split('.')[1])); const currentTime = Date.now() / 1000; @@ -119,19 +117,23 @@ api.interceptors.response.use( // Auth endpoints export const authApi = { - login: (username: string, password: string) => + login: (username: string, password: string) => api.post('/auth/login', { username, password }), - - loginWithMicrosoft: (idToken: string) => - api.post('/auth/microsoft', { id_token: idToken }), - - register: (username: string, email: string, password: string) => + + register: (username: string, email: string, password: string) => api.post('/auth/register', { username, email, password }), getProfile: () => api.get('/auth/me') }; +// Billing endpoints +export const billingApi = { + getBalance: () => api.get('/billing/balance'), + getTransactions: (limit = 50) => api.get(`/billing/transactions?limit=${limit}`), + createCheckout: (packId: string) => api.post('/billing/checkout', { pack_id: packId }), +}; + // Personas endpoints export const personasApi = { getAll: () => @@ -281,7 +283,7 @@ export const aiPersonasApi = { count, temperature: 0.7, // Use 0.7 temperature for basic profiles customer_data_session_id: customerDataSessionId, - llm_model: llmModel || 'gemini-2.5-pro' + llm_model: llmModel || 'gpt-5.4' }, { timeout: 180000 // 3 minutes for basic profile generation }); @@ -306,7 +308,7 @@ export const aiPersonasApi = { audience_brief: audienceBrief, research_objective: researchObjective, customer_data_session_id: customerDataSessionId, - llm_model: llmModel || 'gemini-2.5-pro' + llm_model: llmModel || 'gpt-5.4' }, { timeout: 180000 // 3 minutes for each persona completion }) @@ -371,7 +373,7 @@ export const aiPersonasApi = { return api.post('/ai-personas/batch-generate-summaries', { persona_ids: personaIds, temperature, - llm_model: llmModel || 'gemini-2.5-pro' + llm_model: llmModel || 'gpt-5.4' }, { timeout: 180000 // 3 minutes timeout for batch processing }); @@ -412,7 +414,7 @@ export const aiPersonasApi = { count, temperature, customer_data_session_id: customerDataSessionId, - llm_model: llmModel || 'gemini-2.5-pro', + llm_model: llmModel || 'gpt-5.4', target_folder_id: targetFolderId }, { timeout: 10000 // 10 seconds — endpoint returns immediately with task_id @@ -749,6 +751,18 @@ export const adminApi = { // Focus Groups (admin view) listFocusGroups: (params?: { skip?: number; limit?: number; from?: string; to?: string }) => api.get('/admin/focus-groups', { params }), + + // App settings (credit pricing config) + getSettings: () => api.get('/admin/settings'), + updateSettings: (data: any) => api.put('/admin/settings', data), + + // Manual credit adjustment + adjustCredits: (userId: string, amount: number, reason: string) => + api.post(`/admin/users/${userId}/credits`, { amount, reason }), + + // Analytics + getAnalytics: (params?: { from?: string; to?: string }) => + api.get('/admin/analytics', { params }), }; export const usageApi = { diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx index 6377883c..85feb19f 100644 --- a/src/pages/Admin.tsx +++ b/src/pages/Admin.tsx @@ -6,6 +6,8 @@ import UsersTab from '@/components/admin/UsersTab'; import UsageTab from '@/components/admin/UsageTab'; import PricingTab from '@/components/admin/PricingTab'; import FocusGroupsTab from '@/components/admin/FocusGroupsTab'; +import AnalyticsTab from '@/components/admin/AnalyticsTab'; +import CreditSettingsTab from '@/components/admin/CreditSettingsTab'; export default function Admin() { const navigate = useNavigate(); @@ -25,10 +27,12 @@ export default function Admin() { - + Users + Analytics + Credits Usage - Pricing + Model Pricing Focus Groups @@ -36,6 +40,14 @@ export default function Admin() { + + + + + + + + diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 2cc507a4..14afd4fb 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -739,11 +739,11 @@ const FocusGroupSession = () => { duration: data.duration || 60, topic: data.topic || 'general', discussionGuide: data.discussionGuide || '', - llm_model: data.llm_model || 'gemini-3-pro-preview' + llm_model: data.llm_model || 'gpt-5.4' }; setFocusGroup(focusGroupData); - setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview'); + setSelectedModel(focusGroupData.llm_model || 'gpt-5.4'); setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium'); setSelectedVerbosity(focusGroupData.verbosity || 'medium'); @@ -796,8 +796,8 @@ const FocusGroupSession = () => { try { const updateData: any = { llm_model: newModel }; - // Only include GPT-5.2 parameters if the model is GPT-5.2 - if (newModel === 'gpt-5.2') { + // Include reasoning_effort/verbosity for models that support it + if (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') { updateData.reasoning_effort = reasoningEffort || selectedReasoningEffort; updateData.verbosity = verbosity || selectedVerbosity; } @@ -808,14 +808,13 @@ const FocusGroupSession = () => { setFocusGroup(prev => prev ? { ...prev, llm_model: newModel, - reasoning_effort: newModel === 'gpt-5.2' ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort, - verbosity: newModel === 'gpt-5.2' ? (verbosity || selectedVerbosity) : prev?.verbosity + reasoning_effort: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (reasoningEffort || selectedReasoningEffort) : prev?.reasoning_effort, + verbosity: (newModel === 'gpt-5.4' || newModel === 'gpt-5.4-mini') ? (verbosity || selectedVerbosity) : prev?.verbosity } : null); toastService.success('AI Model Updated', { description: `Focus group will now use ${ - newModel === 'gemini-3-pro-preview' ? 'Gemini 3 Pro' : - newModel === 'gpt-4.1' ? 'GPT-4.1' : - newModel === 'gpt-5.2' ? 'GPT-5.2' : newModel + newModel === 'gpt-5.4' ? 'GPT-5.4' : + newModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : newModel } for AI responses` }); setShowModelSettings(false); @@ -860,11 +859,11 @@ const FocusGroupSession = () => { duration: data.duration || 60, topic: data.topic || 'general', discussionGuide: data.discussionGuide || '', - llm_model: data.llm_model || 'gemini-3-pro-preview' + llm_model: data.llm_model || 'gpt-5.4' }; setFocusGroup(focusGroupData); - setSelectedModel(focusGroupData.llm_model || 'gemini-3-pro-preview'); + setSelectedModel(focusGroupData.llm_model || 'gpt-5.4'); setSelectedReasoningEffort(focusGroupData.reasoning_effort || 'medium'); setSelectedVerbosity(focusGroupData.verbosity || 'medium'); @@ -1889,8 +1888,7 @@ const FocusGroupSession = () => {
- {focusGroup.llm_model === 'gpt-4.1' ? 'GPT-4.1' : - focusGroup.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'} + {focusGroup.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'}
{user?.role === 'admin' && fgCostTotal > 0 && ( @@ -2265,8 +2263,7 @@ const FocusGroupSession = () => { Current Model: - {focusGroup?.llm_model === 'gpt-4.1' ? 'GPT-4.1' : - focusGroup?.llm_model === 'gpt-5.2' ? 'GPT-5.2' : 'Gemini 3 Pro'} + {focusGroup?.llm_model === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'} @@ -2279,15 +2276,14 @@ const FocusGroupSession = () => { - Gemini 3 Pro (Slow, best for most tasks) - GPT-4.1 (Fast, best for speed) - GPT-5.2 (Slow, best for complex tasks) + GPT-5.4 (Recommended) + GPT-5.4 Mini (Faster, lower cost) - {/* GPT-5.2 specific parameters - only show when GPT-5.2 is selected */} - {selectedModel === "gpt-5.2" && ( + {/* Reasoning/verbosity parameters for gpt-5.4 models */} + {(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && ( <> {/* Reasoning Effort Parameter */}
@@ -2304,10 +2300,10 @@ const FocusGroupSession = () => {

- Controls how much time GPT-5.2 spends thinking before responding + Controls how much time GPT-5.4 spends thinking before responding

-Controls how thoroughly GPT-5.2 thinks and how detailed responses are +Controls how thoroughly GPT-5.4 thinks and how detailed responses are

@@ -2325,19 +2321,18 @@ Controls how thoroughly GPT-5.2 thinks and how detailed responses are

- Controls how detailed and lengthy GPT-5.2's responses will be + Controls how detailed and lengthy GPT-5.4's responses will be

-Controls how thoroughly GPT-5.2 thinks and how detailed responses are +Controls how thoroughly GPT-5.4 thinks and how detailed responses are

)}
-

Gemini 3 Pro: Google's advanced model, great for creative and analytical tasks.

-

GPT-4.1: OpenAI's latest model, excellent for conversational and reasoning tasks.

-

GPT-5.2: OpenAI's newest model with advanced reasoning and customizable response styles.

+

GPT-5.4: Recommended model. Best quality for complex analysis and persona responses.

+

GPT-5.4 Mini: Faster and lower cost. Great for most tasks with good quality.

@@ -2354,7 +2349,7 @@ Controls how thoroughly GPT-5.2 thinks and how detailed responses are updateFocusGroupModel(selectedModel, selectedReasoningEffort, selectedVerbosity); }} disabled={isUpdatingModel || (selectedModel === focusGroup?.llm_model && - (selectedModel !== 'gpt-5.2' || + (!(selectedModel === 'gpt-5.4' || selectedModel === 'gpt-5.4-mini') || (selectedReasoningEffort === (focusGroup?.reasoning_effort || 'medium') && selectedVerbosity === (focusGroup?.verbosity || 'medium'))))} > diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index b14b35f1..a716f35b 100755 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -141,7 +141,7 @@ const SyntheticUsers = () => { }); // LLM selection for download const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false); - const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState('gemini-3-pro-preview'); + const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState('gpt-5.4'); // Bulk export no longer needs state - direct download @@ -1035,7 +1035,7 @@ const SyntheticUsers = () => { summaryGenerationControls.completeGeneration(); // Show success toast with details including model information - const modelDisplayName = selectedDownloadLlmModel === 'gpt-4.1' ? 'GPT-4.1' : 'Gemini 3 Pro'; + const modelDisplayName = selectedDownloadLlmModel === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4'; if (summary_stats.total_successful === summary_stats.total_requested) { toastService.success("Persona summary downloaded", { description: `Successfully processed all ${summary_stats.total_successful} persona${summary_stats.total_successful !== 1 ? 's' : ''} from "${folderName}" using ${modelDisplayName}` @@ -1099,7 +1099,7 @@ const SyntheticUsers = () => { try { // Get JWT token for the request const token = localStorage.getItem('auth_token'); - const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/semblance_back/api'; + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; // Make direct fetch request since response will be a file const response = await fetch(`${API_BASE_URL}/personas/bulk-export`, { @@ -1904,15 +1904,15 @@ const SyntheticUsers = () => { className="space-y-3" >
- -
- -
diff --git a/src/services/websocketService.ts b/src/services/websocketService.ts index 7096c1e9..3bfba27c 100755 --- a/src/services/websocketService.ts +++ b/src/services/websocketService.ts @@ -167,14 +167,7 @@ export function getWebSocketUrl(): string { return import.meta.env.VITE_WEBSOCKET_URL; } - // TEMP DEBUG: Try direct connection to backend bypassing Apache - // Add ?direct=1 to URL to test direct connection - const urlParams = new URLSearchParams(window.location.search); - if (urlParams.get('direct') === '1') { - return 'https://optical-dev.oliver.solutions:5137'; - } - - // For production with Apache proxy, use the current origin + // For production with Traefik proxy, use the current origin // The Apache proxy handles the routing to backend const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; const host = window.location.host; // includes port if any diff --git a/src/services/websocketServiceNew.ts b/src/services/websocketServiceNew.ts index 75e2f64d..05077e67 100755 --- a/src/services/websocketServiceNew.ts +++ b/src/services/websocketServiceNew.ts @@ -7,7 +7,7 @@ const BASE_URL = import.meta.env.DEV ? "http://localhost:5137" : (import.meta.env.VITE_WEBSOCKET_URL || window.location.origin); -const SOCKET_PATH = import.meta.env.VITE_WEBSOCKET_PATH || "/semblance_back/socket.io/"; +const SOCKET_PATH = import.meta.env.VITE_WEBSOCKET_PATH || "/socket.io/"; let socket: Socket | null = null; let currentRoom: string | null = null; diff --git a/src/utils/discussionGuideMarkdown.ts b/src/utils/discussionGuideMarkdown.ts index bf4dab89..4662b789 100755 --- a/src/utils/discussionGuideMarkdown.ts +++ b/src/utils/discussionGuideMarkdown.ts @@ -74,7 +74,7 @@ ${discussionGuide} --- -*Exported from Semblance Synthetic Society*`; +*Exported from Cohorta*`; } /** @@ -158,7 +158,7 @@ function convertStructuredDiscussionGuideToMarkdown( markdown += '---\n\n'; }); - markdown += '*Exported from Semblance Synthetic Society*'; + markdown += '*Exported from Cohorta*'; return markdown; } diff --git a/start.sh b/start.sh index 909ca55f..4a71b188 100755 --- a/start.sh +++ b/start.sh @@ -7,7 +7,7 @@ YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color -echo -e "${BLUE}=== Semblance Synthetic Society Startup Script ===${NC}" +echo -e "${BLUE}=== Cohorta Startup Script ===${NC}" # Check if MongoDB is installed if ! command -v mongod &> /dev/null; then diff --git a/vite.config.ts b/vite.config.ts index 268023a9..f1cb5a79 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,9 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; -import { componentTagger } from "lovable-tagger"; - // https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ - base: mode === 'production' ? '/semblance/' : '/', + base: '/', define: { 'import.meta.env.VITE_ENABLE_WEBSOCKET': JSON.stringify(mode === 'development' ? 'true' : 'false'), }, @@ -30,9 +28,7 @@ export default defineConfig(({ mode }) => ({ }, plugins: [ react(), - mode === 'development' && - componentTagger(), - ].filter(Boolean), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"),