feat: commit all app changes — billing API, new auth, design overhaul
All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s
All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s
Includes frontend redesign (Navigation, billingApi), backend updates (auth routes, admin routes, LLM service refactor), MSAL removal, and dependency updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b88d0a2a5
commit
e01569c412
49 changed files with 848 additions and 2326 deletions
|
|
@ -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://<project>.services.ai.azure.com/api/projects/<name>/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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/<user_id>/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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -191,5 +191,13 @@ class CustomerDataService:
|
|||
return True # Nothing to clean up
|
||||
|
||||
|
||||
# Global service instance
|
||||
customer_data_service = CustomerDataService()
|
||||
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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
47
dist/assets/discussionGuideMarkdown-eMXneipz.js
vendored
47
dist/assets/discussionGuideMarkdown-eMXneipz.js
vendored
|
|
@ -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};
|
||||
|
|
@ -5,15 +5,16 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>Semblance</title>
|
||||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<title>Cohorta</title>
|
||||
<meta name="description" content="AI-powered synthetic focus groups for product research" />
|
||||
<meta name="author" content="AImpress" />
|
||||
<meta property="og:title" content="Cohorta" />
|
||||
<meta property="og:description" content="AI-powered synthetic focus groups for product research" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- GPT Engineer script removed to fix CORS and MIME type issues -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="space-y-1">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
placeholder="e.g. gemini-2.5-pro"
|
||||
placeholder="e.g. gpt-5.4"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
/>
|
||||
|
|
@ -160,7 +160,7 @@ export default function PricingTab() {
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
<SelectItem value="azure">Azure AI</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -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<User | null>(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() {
|
|||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Credits</TableHead>
|
||||
<TableHead>Cost ({periodLabel})</TableHead>
|
||||
<TableHead>Monthly Quota</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
|
|
@ -148,7 +176,7 @@ export default function UsersTab() {
|
|||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -172,6 +200,12 @@ export default function UsersTab() {
|
|||
{u.is_active === false ? 'Disabled' : 'Active'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
||||
{u.credits_balance ?? 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
${(u.cost_mtd ?? 0).toFixed(4)}
|
||||
</TableCell>
|
||||
|
|
@ -183,6 +217,9 @@ export default function UsersTab() {
|
|||
<Button size="sm" variant="ghost" onClick={() => openEdit(u)} title="Edit">
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setCreditUser(u); setCreditAmount(''); setCreditReason(''); }} title="Adjust credits">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
</Button>
|
||||
{u.is_active === false ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => enableUser.mutate(u._id)} title="Enable">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
|
|
@ -331,6 +368,47 @@ export default function UsersTab() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Credit Adjustment Dialog */}
|
||||
<Dialog open={!!creditUser} onOpenChange={open => !open && setCreditUser(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
Adjust Credits — {creditUser?.username}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current balance: <strong>{creditUser?.credits_balance ?? 0} cr</strong>
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label>Amount (positive = grant, negative = deduct)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 50 or -10"
|
||||
value={creditAmount}
|
||||
onChange={e => setCreditAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Reason (optional)</Label>
|
||||
<Input
|
||||
placeholder="Admin adjustment reason"
|
||||
value={creditReason}
|
||||
onChange={e => setCreditReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreditUser(null)}>Cancel</Button>
|
||||
<Button onClick={handleCreditAdjust} disabled={!creditAmount || creditSaving}>
|
||||
{creditSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
|
||||
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
|
||||
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
|
||||
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<BaseMsalProvider instance={msalInstance}>
|
||||
{children}
|
||||
</BaseMsalProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { msalInstance };
|
||||
|
|
@ -155,9 +155,8 @@ export function SetupTab({
|
|||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
|
||||
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
|
||||
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
|
||||
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
|
@ -169,7 +168,7 @@ export function SetupTab({
|
|||
/>
|
||||
|
||||
{/* GPT-5 specific parameters */}
|
||||
{selectedModel === "gpt-5.2" && (
|
||||
{(selectedModel === "gpt-5.4" || selectedModel === "gpt-5.4-mini") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -191,10 +190,10 @@ export function SetupTab({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Controls how much time GPT-5.2 spends thinking before responding
|
||||
Controls how much time GPT-5.4 spends thinking before responding
|
||||
</FormDescription>
|
||||
<div className="text-xs text-amber-600 font-medium mt-1">
|
||||
Controls how much time GPT-5.2 spends thinking before responding
|
||||
Controls how much time GPT-5.4 spends thinking before responding
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -220,10 +219,10 @@ export function SetupTab({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Controls how detailed and lengthy GPT-5.2's responses will be
|
||||
Controls how detailed and lengthy GPT-5.4's responses will be
|
||||
</FormDescription>
|
||||
<div className="text-xs text-amber-600 font-medium mt-1">
|
||||
Controls how much time GPT-5.2 spends thinking before responding
|
||||
Controls how much time GPT-5.4 spends thinking before responding
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
|
||||
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
|
||||
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
|
||||
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
|
|
@ -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 */}
|
||||
<FormField
|
||||
|
|
|
|||
|
|
@ -99,15 +99,15 @@ export default function PersonaProfile() {
|
|||
|
||||
try {
|
||||
toastService.info("Generating persona profile...", {
|
||||
description: "Using GPT-4.1 to create a beautifully formatted markdown profile"
|
||||
description: "Using GPT-5.4 to create a beautifully formatted markdown profile"
|
||||
});
|
||||
|
||||
|
||||
// Use the persona's MongoDB _id or fallback to id
|
||||
const personaId = currentPersona._id || currentPersona.id;
|
||||
|
||||
// Call the export API with GPT-4.1
|
||||
|
||||
// Call the export API with GPT-5.4
|
||||
const response = await personasApi.exportProfile(personaId, {
|
||||
llm_model: 'gpt-4.1',
|
||||
llm_model: 'gpt-5.4',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ export default function PersonaProfile() {
|
|||
description: `${persona_name} profile saved as ${filename}`
|
||||
});
|
||||
} else {
|
||||
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
|
||||
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
|
||||
toastService.success("Profile downloaded successfully", {
|
||||
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
|
||||
});
|
||||
|
|
@ -170,7 +170,7 @@ export default function PersonaProfile() {
|
|||
description: `${persona_name} profile saved as ${filename}`
|
||||
});
|
||||
} else {
|
||||
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
|
||||
const modelDisplay = model_used === 'gpt-5.4-mini' ? 'GPT-5.4 Mini' : 'GPT-5.4';
|
||||
toastService.success("Profile downloaded successfully", {
|
||||
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { User, Users, MapPin, BookOpen, Heart, DollarSign, Briefcase, Home, Monitor, ShoppingBag, Info } from 'lucide-react';
|
||||
import { User, Users, MapPin, BookOpen, Heart, Monitor, ShoppingBag, Info } from 'lucide-react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Persona } from '@/types/persona';
|
||||
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
|
||||
|
|
@ -8,10 +9,6 @@ interface PersonaSidebarProps {
|
|||
}
|
||||
|
||||
export function PersonaSidebar({ persona }: PersonaSidebarProps) {
|
||||
// Use for special cases of detailed profiles
|
||||
const isOliver = persona.id === '0';
|
||||
const isFiona = persona.id === '1';
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
|
@ -187,50 +184,6 @@ export function PersonaSidebar({ persona }: PersonaSidebarProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isOliver && (
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="sidebar-section">
|
||||
<Briefcase className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Maintains an extensive network of financial and luxury industry contacts</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<Home className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Owns vacation properties in the Cotswolds and South of France</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<BookOpen className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Collector of rare first-edition books and limited-edition art prints</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<DollarSign className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Significant investment portfolio with focus on sustainable luxury ventures</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isFiona && (
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="sidebar-section">
|
||||
<BookOpen className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Active in industry panels, luxury brand collaborations, follows influencers in luxury & design</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<Home className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Modern flat in exclusive Chelsea, accessible to boutique services</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<DollarSign className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Uses premium digital payment & secure banking for HNWIs</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<Briefcase className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Respected network in London's luxury sector; attends exclusive events</span>
|
||||
</div>
|
||||
<div className="sidebar-section">
|
||||
<User className="sidebar-icon" />
|
||||
<span className="text-muted-foreground text-sm">Seeks autonomy, bespoke service, and acknowledgment for taste</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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<void>;
|
||||
loginWithMicrosoft: () => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
isMsalLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
|
@ -31,109 +24,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(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<AuthErrorDetail>;
|
||||
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<any>;
|
||||
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, logout, isAuthenticated }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
<Tabs defaultValue="users">
|
||||
<TabsList className="mb-6">
|
||||
<TabsList className="mb-6 flex-wrap">
|
||||
<TabsTrigger value="users">Users</TabsTrigger>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
<TabsTrigger value="credits">Credits</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Pricing</TabsTrigger>
|
||||
<TabsTrigger value="pricing">Model Pricing</TabsTrigger>
|
||||
<TabsTrigger value="focus-groups">Focus Groups</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -36,6 +40,14 @@ export default function Admin() {
|
|||
<UsersTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<AnalyticsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="credits">
|
||||
<CreditSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage">
|
||||
<UsageTab />
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<div className="flex items-center mt-1">
|
||||
<Bot className="h-3 w-3 text-slate-500 mr-1" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{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'}
|
||||
</Badge>
|
||||
</div>
|
||||
{user?.role === 'admin' && fgCostTotal > 0 && (
|
||||
|
|
@ -2265,8 +2263,7 @@ const FocusGroupSession = () => {
|
|||
<Bot className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium">Current Model:</span>
|
||||
<Badge variant="secondary">
|
||||
{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'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
|
@ -2279,15 +2276,14 @@ const FocusGroupSession = () => {
|
|||
<SelectValue placeholder="Select AI model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-3-pro-preview">Gemini 3 Pro (Slow, best for most tasks)</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1 (Fast, best for speed)</SelectItem>
|
||||
<SelectItem value="gpt-5.2">GPT-5.2 (Slow, best for complex tasks)</SelectItem>
|
||||
<SelectItem value="gpt-5.4">GPT-5.4 (Recommended)</SelectItem>
|
||||
<SelectItem value="gpt-5.4-mini">GPT-5.4 Mini (Faster, lower cost)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div>
|
||||
|
|
@ -2304,10 +2300,10 @@ const FocusGroupSession = () => {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-600 mt-1">
|
||||
Controls how much time GPT-5.2 spends thinking before responding
|
||||
Controls how much time GPT-5.4 spends thinking before responding
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 font-medium mt-1">
|
||||
Controls how thoroughly GPT-5.2 thinks and how detailed responses are
|
||||
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -2325,19 +2321,18 @@ Controls how thoroughly GPT-5.2 thinks and how detailed responses are
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-600 mt-1">
|
||||
Controls how detailed and lengthy GPT-5.2's responses will be
|
||||
Controls how detailed and lengthy GPT-5.4's responses will be
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 font-medium mt-1">
|
||||
Controls how thoroughly GPT-5.2 thinks and how detailed responses are
|
||||
Controls how thoroughly GPT-5.4 thinks and how detailed responses are
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-600">
|
||||
<p><strong>Gemini 3 Pro:</strong> Google's advanced model, great for creative and analytical tasks.</p>
|
||||
<p><strong>GPT-4.1:</strong> OpenAI's latest model, excellent for conversational and reasoning tasks.</p>
|
||||
<p><strong>GPT-5.2:</strong> OpenAI's newest model with advanced reasoning and customizable response styles.</p>
|
||||
<p><strong>GPT-5.4:</strong> Recommended model. Best quality for complex analysis and persona responses.</p>
|
||||
<p><strong>GPT-5.4 Mini:</strong> Faster and lower cost. Great for most tasks with good quality.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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'))))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ const SyntheticUsers = () => {
|
|||
});
|
||||
// LLM selection for download
|
||||
const [downloadLlmModalOpen, setDownloadLlmModalOpen] = useState(false);
|
||||
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('gemini-3-pro-preview');
|
||||
const [selectedDownloadLlmModel, setSelectedDownloadLlmModel] = useState<string>('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"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="gemini-3-pro-preview" id="download-gemini" />
|
||||
<Label htmlFor="download-gemini" className="text-sm font-medium">
|
||||
Gemini 3 Pro (Slow, best for most tasks)
|
||||
<RadioGroupItem value="gpt-5.4" id="download-gpt54" />
|
||||
<Label htmlFor="download-gpt54" className="text-sm font-medium">
|
||||
GPT-5.4 (Recommended)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="gpt-4.1" id="download-gpt" />
|
||||
<Label htmlFor="download-gpt" className="text-sm font-medium">
|
||||
GPT-4.1 (Fast, best for speed)
|
||||
<RadioGroupItem value="gpt-5.4-mini" id="download-gpt54mini" />
|
||||
<Label htmlFor="download-gpt54mini" className="text-sm font-medium">
|
||||
GPT-5.4 Mini (Faster, lower cost)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
2
start.sh
2
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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue