refactored image/asset attachment to discussion guide and discussion messages to avoid back end filenames being displayed to user - use metadata system instead. Also added a detailed persona export to markdown. Also, bug fixes

This commit is contained in:
michael 2025-08-12 15:43:34 -05:00
parent d5dc0bc3af
commit 8a5c50cacb
39 changed files with 2381 additions and 1091 deletions

View file

@ -414,7 +414,8 @@ class FocusGroup:
"created_at": datetime.utcnow(),
"highlighted": message_data.get("highlighted", False),
"attached_assets": message_data.get("attached_assets", []), # List of asset filenames
"activates_visual_context": message_data.get("activates_visual_context", False) # Visual context activation flag
"activates_visual_context": message_data.get("activates_visual_context", False), # Visual context activation flag
"visual_asset": message_data.get("visual_asset") # Visual asset metadata {filename, displayReference}
}
# Insert the message
@ -437,7 +438,8 @@ class FocusGroup:
'type': message["type"],
'highlighted': message["highlighted"],
'attached_assets': message.get("attached_assets", []),
'activates_visual_context': message.get("activates_visual_context", False)
'activates_visual_context': message.get("activates_visual_context", False),
'visualAsset': message.get("visual_asset") # Include visual asset metadata
}
print(f"🔔 EMITTING WEBSOCKET EVENT: message_update for focus group {focus_group_id}")
print(f"🔔 Message data: sender={message_for_websocket['senderId']}, type={message_for_websocket['type']}")
@ -838,6 +840,7 @@ class FocusGroup:
cleaned_asset = {
"filename": asset["filename"],
"original_name": asset["original_name"],
"user_assigned_name": asset.get("user_assigned_name"), # New field for custom naming
"size": asset["size"],
"mime_type": asset["mime_type"],
"upload_date": asset["upload_date"]
@ -893,6 +896,27 @@ class FocusGroup:
print(traceback.format_exc())
return []
@staticmethod
def update_asset_name(focus_group_id, filename, user_assigned_name):
"""Update the user assigned name for an uploaded asset."""
db = get_db()
try:
result = db.focus_groups.update_one(
{"_id": ObjectId(focus_group_id), "uploaded_assets.filename": filename},
{
"$set": {
"uploaded_assets.$.user_assigned_name": user_assigned_name,
"updated_at": datetime.utcnow()
}
}
)
return result.modified_count > 0
except Exception as e:
print(f"Error updating asset name for focus group {focus_group_id}: {e}")
print(traceback.format_exc())
return False
@staticmethod
def clear_uploaded_assets(focus_group_id):
"""Clear all uploaded assets for a focus group from database."""
@ -928,23 +952,41 @@ class FocusGroup:
new_records = []
updated_filenames = []
# Get uploaded assets to fetch display references
uploaded_assets = focus_group.get('uploaded_assets', [])
for filename in asset_filenames:
# Find the asset metadata to get display reference
asset_metadata = next((asset for asset in uploaded_assets if asset.get('filename') == filename), None)
# Generate display reference
if asset_metadata:
if asset_metadata.get('user_assigned_name'):
display_reference = asset_metadata['user_assigned_name']
else:
# Find the index of this asset in the uploaded assets to generate "Asset N"
asset_index = next((i for i, asset in enumerate(uploaded_assets) if asset.get('filename') == filename), 0)
display_reference = f"Asset {asset_index + 1}"
else:
display_reference = f"Unknown Asset"
# Check if this asset is already in the active context
existing_asset = next((asset for asset in existing_context if asset["filename"] == filename), None)
if existing_asset:
# Asset already exists - we'll update its sequence to current position
updated_filenames.append(filename)
print(f"🔄 Re-activating existing visual asset: {filename} (moving to sequence {message_count})")
print(f"🔄 Re-activating existing visual asset: {filename} ({display_reference}) (moving to sequence {message_count})")
else:
# New asset - add to records
new_records.append({
"filename": filename,
"display_reference": display_reference,
"activated_at_message_id": message_id,
"activated_at_sequence": message_count,
"activation_timestamp": datetime.utcnow()
})
print(f"🆕 Activating new visual asset: {filename} at sequence {message_count}")
print(f"🆕 Activating new visual asset: {filename} ({display_reference}) at sequence {message_count}")
# First, update existing assets to current sequence
for filename in updated_filenames:

View file

@ -203,9 +203,14 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
verbosity=verbosity
)
# Log success
# Log success with response details
response_type = "contextual with visual context" if has_visual_context else "standard"
print(f"✅ Generated {response_type} response for persona {persona_id}")
print(f"🔍 RESPONSE DEBUG:")
print(f" - Response length: {len(response_text) if response_text else 0} characters")
print(f" - Response type: {type(response_text)}")
print(f" - Response preview: '{response_text[:200] if response_text else 'EMPTY'}...'")
print(f" - Response repr: {repr(response_text[:50]) if response_text else 'NONE'}")
current_app.logger.info(f"Generated {response_type} response for persona {persona_id} in focus group {focus_group_id}")
except Exception as e:
print(f"❌ Error in response generation: {str(e)}")
@ -221,7 +226,10 @@ Be genuine and specific in your feedback, drawing on your personal experiences a
"type": "response",
"senderId": persona_id
}
print(f"💾 Message data: {message_data}")
print(f"💾 Message data keys: {list(message_data.keys())}")
print(f"💾 Message text length: {len(message_data['text']) if message_data['text'] else 0}")
print(f"💾 Message text preview: '{message_data['text'][:100] if message_data['text'] else 'EMPTY'}...'")
print(f"💾 Message text repr: {repr(message_data['text'][:20]) if message_data['text'] else 'NONE'}")
print(f"💾 Calling FocusGroup.add_message...")
message_id = FocusGroup.add_message(focus_group_id, message_data)
@ -498,14 +506,27 @@ def advance_moderator_discussion(focus_group_id):
current_item = result.get("current_item")
if current_item:
# Extract asset filename from the activity content (works for any item type)
activity_content = current_item.get("content", "")
asset_filename = extract_asset_filename_from_content(activity_content)
# Try to get asset info from metadata (new metadata-driven approach)
asset_filename = None
display_reference = None
metadata = current_item.get('metadata', {})
visual_asset = metadata.get('visual_asset')
if visual_asset:
# Use metadata (preferred method)
asset_filename = visual_asset.get('filename')
display_reference = visual_asset.get('display_reference')
print(f"🎨 Found asset metadata: {display_reference} -> {asset_filename}")
else:
# Fallback to content parsing (legacy support)
activity_content = current_item.get("content", "")
asset_filename = extract_asset_filename_from_content(activity_content)
print(f"🎨 Legacy asset filename extraction: {asset_filename}")
if asset_filename:
print(f"🎨 ADVANCE DISCUSSION: Item with image detected (type: {current_item.get('type')})")
print(f"🎨 Activity content: {activity_content}")
print(f"🎨 Extracted asset filename: {asset_filename}")
print(f"🎨 Asset: {display_reference or 'legacy'} -> {asset_filename}")
if asset_filename:
attached_assets = [asset_filename]
@ -515,10 +536,16 @@ def advance_moderator_discussion(focus_group_id):
print(f"🎨 AI MODE: Generating description for {asset_filename}")
description = ImageDescriptionService.generate_description(focus_group_id, asset_filename)
# Enhance the moderator response with the description
enhanced_response = ImageDescriptionService.enhance_creative_review_question(
result["moderator_response"], asset_filename, description
)
# Enhance the moderator response with the description using display reference if available
if display_reference:
enhanced_response = ImageDescriptionService.enhance_creative_review_question_with_display_reference(
result["moderator_response"], display_reference, description
)
else:
# Fallback to old method for legacy content
enhanced_response = ImageDescriptionService.enhance_creative_review_question(
result["moderator_response"], asset_filename, description
)
# Update the result with enhanced response
result["moderator_response"] = enhanced_response

View file

@ -523,8 +523,15 @@ def get_focus_group_messages(focus_group_id):
messages = FocusGroup.get_messages(focus_group_id)
mode_events = FocusGroup.get_mode_events(focus_group_id)
# Make messages and events serializable
# Make messages and events serializable and convert field names for frontend compatibility
serializable_messages = make_serializable(messages)
# Convert visual_asset field to visualAsset for frontend compatibility
for message in serializable_messages:
if 'visual_asset' in message and message['visual_asset']:
message['visualAsset'] = message['visual_asset']
del message['visual_asset']
serializable_mode_events = make_serializable(mode_events)
return jsonify({
@ -551,8 +558,35 @@ def add_focus_group_message(focus_group_id):
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Check if this is a facilitator message with a creative asset
if data.get('senderId') == 'facilitator':
# Handle visual asset metadata for messages with visual context
if data.get('visualAsset') and data.get('visualAsset', {}).get('filename'):
visual_asset = data.get('visualAsset')
filename = visual_asset.get('filename')
# Add asset information for legacy compatibility
data['attached_assets'] = [filename]
data['activates_visual_context'] = True
# Store visual asset metadata in proper format for database
data['visual_asset'] = {
'filename': visual_asset.get('filename'),
'displayReference': visual_asset.get('displayReference')
}
print(f"🎨 MESSAGE WITH VISUAL ASSET: {visual_asset.get('displayReference')} -> {filename}")
# Activate visual assets in the focus group for LLM context
try:
success = FocusGroup._activate_visual_assets(focus_group_id, [filename], None)
if success:
print(f"✅ VISUAL CONTEXT ACTIVATED: {filename} ({visual_asset.get('displayReference')})")
else:
print(f"⚠️ Failed to activate visual context for: {filename}")
except Exception as activation_error:
print(f"⚠️ Error activating visual context: {activation_error}")
# Legacy fallback: Check if this is a facilitator message with a creative asset (for backward compatibility)
elif data.get('senderId') == 'facilitator':
try:
from app.services.focus_group_response_service import extract_asset_filename_from_content
@ -565,7 +599,7 @@ def add_focus_group_message(focus_group_id):
data['attached_assets'] = [asset_filename]
data['activates_visual_context'] = True
print(f"🎨 FACILITATOR MESSAGE: Detected creative asset: {asset_filename}")
print(f"🎨 LEGACY FACILITATOR MESSAGE: Detected creative asset: {asset_filename}")
print(f"🎨 Message text: {message_text}")
# Activate visual assets in the focus group for LLM context
@ -1416,6 +1450,7 @@ def get_assets(focus_group_id):
asset_info = {
"filename": asset.get("filename"),
"original_name": asset.get("original_name"),
"user_assigned_name": asset.get("user_assigned_name"),
"size": asset.get("size"),
"mime_type": asset.get("mime_type"),
"upload_date": asset.get("upload_date")
@ -1520,6 +1555,44 @@ def delete_asset(focus_group_id, filename):
print(f"Error in delete_asset: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/assets/<filename>', methods=['PATCH'])
@jwt_required(optional=True) # Make JWT optional for development
def update_asset_name(focus_group_id, filename):
"""Update the user assigned name for an uploaded asset."""
try:
# Verify focus group exists
focus_group = FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Get request data
data = request.get_json()
if not data or 'user_assigned_name' not in data:
return jsonify({"error": "Missing user_assigned_name field"}), 400
user_assigned_name = data['user_assigned_name']
# Validate that the asset exists
assets = focus_group.get('uploaded_assets', [])
asset = next((a for a in assets if a.get('filename') == filename), None)
if not asset:
return jsonify({"error": "Asset not found"}), 404
# Update the asset name
success = FocusGroup.update_asset_name(focus_group_id, filename, user_assigned_name)
if not success:
return jsonify({"error": "Failed to update asset name"}), 500
return jsonify({
"message": "Asset name updated successfully",
"filename": filename,
"user_assigned_name": user_assigned_name
}), 200
except Exception as e:
print(f"Error in update_asset_name: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/test-endpoint', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
def test_endpoint(focus_group_id):

View file

@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.persona import Persona
from app.services.persona_export_service import PersonaExportService
from bson import ObjectId
import datetime
@ -150,4 +151,65 @@ def create_multiple_personas():
return jsonify({
"message": f"Successfully created {len(persona_ids)} personas",
"persona_ids": persona_ids
}), 201
}), 201
@personas_bp.route('/<persona_id>/export-profile', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
def export_persona_profile(persona_id):
"""
Export a persona profile as beautifully formatted markdown.
Request body can optionally include:
- llm_model: Model to use (defaults to 'gpt-4.1')
- temperature: Temperature for generation (defaults to 0.3)
"""
try:
# Get the persona data
persona = Persona.find_by_id(persona_id)
if not persona:
return jsonify({"error": "Persona not found"}), 404
# Get optional parameters from request
request_data = request.get_json() or {}
llm_model = request_data.get('llm_model', 'gpt-4.1')
temperature = request_data.get('temperature', 0.3)
# Initialize export service
export_service = PersonaExportService()
# Make persona data serializable for JSON processing
persona_data = make_serializable(persona)
print(f"🤖 Backend: Exporting profile for persona {persona_data.get('name', persona_id)} using {llm_model}")
# Generate the markdown profile
result = export_service.generate_profile_markdown(
persona_data=persona_data,
llm_model=llm_model,
temperature=temperature
)
if result.get('success'):
return jsonify({
"success": True,
"markdown_content": result['markdown_content'],
"persona_name": result['persona_name'],
"model_used": result.get('model_used'),
"content_length": result.get('content_length')
}), 200
else:
# If LLM generation failed, try fallback
print(f"⚠️ LLM generation failed, using fallback for {persona_data.get('name', persona_id)}")
fallback_markdown = export_service.generate_fallback_markdown(persona_data)
return jsonify({
"success": True,
"markdown_content": fallback_markdown,
"persona_name": persona_data.get('name', 'Unknown Persona'),
"model_used": "fallback",
"warning": "Used fallback formatting due to LLM error"
}), 200
except Exception as e:
print(f"Error in export_persona_profile: {e}")
return jsonify({"error": f"Failed to export persona profile: {str(e)}"}), 500

View file

@ -157,7 +157,6 @@ class AutonomousConversationController:
# The FocusGroup.update() will trigger the websocket event automatically
# Log the mode change event for automatic completion
from app.models.focus_group import FocusGroup
completion_events = ['completed', 'discussion_guide_completed', 'natural_completion']
if reason in completion_events:
mode_event_id = FocusGroup.add_mode_event(self.focus_group_id, 'ai_session_concluded', None)
@ -836,14 +835,28 @@ class AutonomousConversationController:
print(f"🔍 Item to check: {current_item}")
if current_item:
print(f"🔍 Item type: {current_item.get('type')}")
# Extract asset filename from the content (works for any item type)
activity_content = current_item.get('content', '')
asset_filename = extract_asset_filename_from_content(activity_content)
print(f"🔍 Asset filename extraction: {asset_filename}")
# Try to get asset info from metadata (new metadata-driven approach)
asset_filename = None
display_reference = None
metadata = current_item.get('metadata', {})
visual_asset = metadata.get('visual_asset')
if visual_asset:
# Use metadata (preferred method)
asset_filename = visual_asset.get('filename')
display_reference = visual_asset.get('display_reference')
print(f"🔍 Found asset metadata: {display_reference} -> {asset_filename}")
else:
# Fallback to content parsing (legacy support)
activity_content = current_item.get('content', '')
asset_filename = extract_asset_filename_from_content(activity_content)
print(f"🔍 Legacy asset filename extraction: {asset_filename}")
if asset_filename:
print(f"🔍 Item with image found! Type: {current_item.get('type')}, Content: {activity_content}")
print(f"🔍 Extracted asset filename: {asset_filename}")
print(f"🔍 Item with image found! Type: {current_item.get('type')}")
print(f"🔍 Asset: {display_reference or 'legacy'} -> {asset_filename}")
attached_assets = [asset_filename]
activates_visual_context = True
@ -859,10 +872,16 @@ class AutonomousConversationController:
print(f"🎨 AI MODE: Generating description for {asset_filename}")
description = ImageDescriptionService.generate_description(self.focus_group_id, asset_filename)
# Enhance the content with the description
enhanced_content = ImageDescriptionService.enhance_creative_review_question(
content, asset_filename, description
)
# Enhance the content with the description using display reference if available
if display_reference:
enhanced_content = ImageDescriptionService.enhance_creative_review_question_with_display_reference(
content, display_reference, description
)
else:
# Fallback to old method for legacy content
enhanced_content = ImageDescriptionService.enhance_creative_review_question(
content, asset_filename, description
)
# Update the content to use enhanced version
content = enhanced_content

View file

@ -621,16 +621,19 @@ class ConversationContextService:
focus_group_id, asset["filename"]
)
display_reference = asset.get("display_reference", asset["filename"])
conversation_context.append({
"type": "image",
"path": asset_path,
"filename": asset["filename"],
"display_reference": display_reference,
"sequence": sequence,
"activated_at_message_id": asset.get("activated_at_message_id"),
"activation_timestamp": asset.get("activation_timestamp")
})
print(f"🖼️ Added image to context: {asset['filename']} at sequence {sequence}")
print(f"🖼️ Added image to context: {asset['filename']} ({display_reference}) at sequence {sequence}")
return conversation_context

View file

@ -122,8 +122,8 @@ class FocusGroupService:
'has_assets': len(uploaded_assets) > 0,
'asset_count': len(uploaded_assets),
'asset_requirement_note': ' (will require creative review activities)' if len(uploaded_assets) > 0 else '',
# Create a formatted list of asset filenames for the LLM
'uploaded_asset_list': '\n'.join([f"- {asset.get('filename', 'unknown')} ({asset.get('original_name', asset.get('original_filename', 'unknown'))})" for asset in uploaded_assets]) if uploaded_assets else 'No assets uploaded',
# Create a formatted list of asset display references for the LLM
'uploaded_asset_list': '\n'.join([f"- {DiscussionGuideValidator.generate_display_reference(uploaded_assets, i)} ({asset.get('original_name', asset.get('original_filename', 'unknown'))})" for i, asset in enumerate(uploaded_assets)]) if uploaded_assets else 'No assets uploaded',
# Conditional content for asset sections
'assets_section': FocusGroupService._generate_assets_section(uploaded_assets) if uploaded_assets else 'No creative assets have been uploaded for this focus group.'
}
@ -146,10 +146,11 @@ class FocusGroupService:
if uploaded_assets and len(uploaded_assets) > 0:
asset_emphasis = f"\n\n🚨🚨🚨 CRITICAL FOR GPT MODELS - READ THIS FIRST 🚨🚨🚨\n"
asset_emphasis += f"YOU ABSOLUTELY MUST INCLUDE EXACTLY {len(uploaded_assets)} ACTIVITIES WITH type='creative_review'\n"
asset_emphasis += f"EACH activity must reference ONE of these exact filenames:\n"
for asset in uploaded_assets:
asset_emphasis += f"- {asset.get('filename', 'unknown')}\n"
asset_emphasis += f"FAILURE TO INCLUDE ALL {len(uploaded_assets)} CREATIVE_REVIEW ACTIVITIES WILL RESULT IN INVALID OUTPUT\n"
asset_emphasis += f"EACH activity must reference ONE of these display references in content AND include metadata:\n"
for i, asset in enumerate(uploaded_assets):
display_ref = DiscussionGuideValidator.generate_display_reference(uploaded_assets, i)
asset_emphasis += f"- Display Reference: '{display_ref}' -> Filename: {asset.get('filename', 'unknown')}\n"
asset_emphasis += f"FAILURE TO INCLUDE ALL {len(uploaded_assets)} CREATIVE_REVIEW ACTIVITIES WITH PROPER METADATA WILL RESULT IN INVALID OUTPUT\n"
asset_emphasis += f"🚨🚨🚨 END CRITICAL INSTRUCTIONS 🚨🚨🚨\n\n"
enhanced_prompt = asset_emphasis + prompt
@ -256,6 +257,12 @@ class FocusGroupService:
logger.info(f"Discussion guide generation successful on attempt {attempt}/{max_retries}")
logger.info(f"Generated guide has {len(guide_json.get('sections', []))} sections")
# Post-process the discussion guide to add visual asset metadata to creative_review activities
if uploaded_assets and len(uploaded_assets) > 0:
logger.info(f"Post-processing discussion guide to add visual asset metadata")
guide_json = FocusGroupService._add_visual_asset_metadata_to_guide(guide_json, uploaded_assets)
return guide_json
else:
error_msg = f"Generated JSON failed validation: {validation_errors}"
@ -290,29 +297,167 @@ class FocusGroupService:
return 'No creative assets have been uploaded for this focus group.'
asset_count = len(uploaded_assets)
uploaded_asset_list = '\n'.join([f"- {asset.get('filename', 'unknown')} ({asset.get('original_name', asset.get('original_filename', 'unknown'))})" for asset in uploaded_assets])
# Create list of display references and asset metadata for the LLM
asset_entries = []
for i, asset in enumerate(uploaded_assets):
display_ref = DiscussionGuideValidator.generate_display_reference(uploaded_assets, i)
asset_entries.append({
'display_reference': display_ref,
'filename': asset.get('filename', 'unknown'),
'original_name': asset.get('original_name', asset.get('original_filename', 'unknown'))
})
uploaded_asset_list = '\n'.join([f"- {entry['display_reference']} (original: {entry['original_name']})" for entry in asset_entries])
asset_metadata_list = '\n'.join([f"- Display Reference: '{entry['display_reference']}' -> System Filename: {entry['filename']}" for entry in asset_entries])
@staticmethod
def _add_visual_asset_metadata_to_guide(guide_json: Dict[str, Any], uploaded_assets: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Post-process the discussion guide to add visual asset metadata to creative_review activities.
This ensures that moderator systems can identify which asset each activity references.
"""
from app.utils.discussion_guide_schema import DiscussionGuideValidator
# Create a mapping of display references to asset data
asset_mapping = {}
for i, asset in enumerate(uploaded_assets):
display_ref = DiscussionGuideValidator.generate_display_reference(uploaded_assets, i)
asset_mapping[display_ref.lower()] = {
'filename': asset.get('filename'),
'display_reference': display_ref
}
processed_count = 0
# Process all sections
sections = guide_json.get('sections', [])
for section in sections:
# Process activities in section
activities = section.get('activities', [])
for activity in activities:
if activity.get('type') == 'creative_review':
if FocusGroupService._add_metadata_to_activity(activity, asset_mapping):
processed_count += 1
# Process questions in section (some may be creative_review type)
questions = section.get('questions', [])
for question in questions:
if question.get('type') == 'creative_review':
if FocusGroupService._add_metadata_to_activity(question, asset_mapping):
processed_count += 1
# Process subsections
subsections = section.get('subsections', [])
for subsection in subsections:
# Process activities in subsection
activities = subsection.get('activities', [])
for activity in activities:
if activity.get('type') == 'creative_review':
if FocusGroupService._add_metadata_to_activity(activity, asset_mapping):
processed_count += 1
# Process questions in subsection
questions = subsection.get('questions', [])
for question in questions:
if question.get('type') == 'creative_review':
if FocusGroupService._add_metadata_to_activity(question, asset_mapping):
processed_count += 1
print(f"✅ POST-PROCESS: Added metadata to {processed_count} creative_review activities")
return guide_json
@staticmethod
def _add_metadata_to_activity(activity: Dict[str, Any], asset_mapping: Dict[str, Dict[str, str]]) -> bool:
"""
Add visual asset metadata to a single activity based on its content.
Returns True if metadata was added, False otherwise.
"""
content = activity.get('content', '').lower()
# Find which asset this activity references by checking content for display references
matched_asset = None
for display_ref, asset_data in asset_mapping.items():
if display_ref in content:
matched_asset = asset_data
break
if matched_asset:
# Add metadata to the activity
if 'metadata' not in activity:
activity['metadata'] = {}
activity['metadata']['visual_asset'] = {
'filename': matched_asset['filename'],
'display_reference': matched_asset['display_reference']
}
print(f"📎 Added metadata to activity: {matched_asset['display_reference']} -> {matched_asset['filename']}")
return True
else:
print(f"⚠️ Could not match creative_review activity to asset: {activity.get('content', '')[:50]}...")
return False
@staticmethod
def _generate_assets_section(uploaded_assets: List[Dict[str, Any]]) -> str:
"""Generate the assets section content for the discussion guide prompt."""
if not uploaded_assets:
return 'No creative assets have been uploaded for this focus group.'
asset_count = len(uploaded_assets)
# Create list of display references and asset metadata for the LLM
asset_entries = []
for i, asset in enumerate(uploaded_assets):
display_ref = DiscussionGuideValidator.generate_display_reference(uploaded_assets, i)
asset_entries.append({
'display_reference': display_ref,
'filename': asset.get('filename', 'unknown'),
'original_name': asset.get('original_name', asset.get('original_filename', 'unknown'))
})
uploaded_asset_list = '\n'.join([f"- {entry['display_reference']} (original: {entry['original_name']})" for entry in asset_entries])
asset_metadata_list = '\n'.join([f"- Display Reference: '{entry['display_reference']}' -> System Filename: {entry['filename']}" for entry in asset_entries])
return f"""🚨 CRITICAL REQUIREMENT: This focus group has {asset_count} uploaded creative asset(s) that MUST be included in the discussion guide.
**MANDATORY CREATIVE REVIEW ACTIVITIES:**
YOU MUST CREATE EXACTLY {asset_count} "creative_review" ACTIVITIES - ONE FOR EACH ASSET BELOW:
**UPLOADED ASSET FILENAMES:**
**UPLOADED ASSETS:**
{uploaded_asset_list}
**CREATIVE REVIEW ACTIVITY REQUIREMENTS:**
- CREATE one "creative_review" activity for EACH asset filename listed above
- CREATE one "creative_review" activity for EACH asset listed above
- Each activity type MUST be "creative_review" (not "open_question" or any other type)
- MANDATORY: Include the exact asset filename in the activity content
- Example format: "Please take a look at the creative asset on your screen, titled 'EXACT_FILENAME_HERE'. What is your immediate gut reaction? What words come to mind?"
- MANDATORY: Reference the display name (e.g., "Asset 1", "My Campaign Ad") in the activity content - DO NOT use system filenames
- Example format: "Please review [DISPLAY_REFERENCE] on your screen. What is your immediate gut reaction? What words come to mind?"
- Distribute these activities throughout different sections (not all in one place)
- Allow 3-5 minutes per creative review activity
- Add 1-2 probe questions after each creative review
**IMPORTANT METADATA REQUIREMENTS:**
For each creative_review activity, you MUST also include metadata that maps the display reference to the system filename:
```json
{{
"id": "creative_review_1",
"type": "creative_review",
"content": "Please review Asset 1 on your screen. What is your immediate gut reaction?",
"metadata": {{
"visual_asset": {{
"filename": "fg-123-abc.jpg",
"display_reference": "Asset 1"
}}
}}
}}
```
**ASSET METADATA MAPPING:**
{asset_metadata_list}
**VALIDATION CHECKLIST:**
Before finalizing your JSON, verify:
You have created exactly {asset_count} activities with type "creative_review"
Each creative_review activity includes an exact filename from the asset list above
Each creative_review activity references a display name (not system filename) in the content
Each creative_review activity has proper metadata with visual_asset field
Creative review activities are spread across different sections of the guide
Each creative review activity has adequate time allocation

View file

@ -183,4 +183,75 @@ class ImageDescriptionService:
error_msg = f"Failed to enhance question for {asset_filename}: {str(e)}"
print(f"❌ ENHANCEMENT: {error_msg}")
# Return original question if enhancement fails
return original_question
@staticmethod
def enhance_creative_review_question_with_display_reference(original_question: str, display_reference: str, description: str) -> str:
"""
Enhance a creative review question by incorporating AI-generated image description using display reference.
This is the new metadata-driven approach that doesn't rely on filename parsing.
Args:
original_question: The original question text
display_reference: The display reference (e.g., "Asset 1", "My Campaign Ad")
description: The AI-generated description of the image
Returns:
Enhanced question text with detailed visual description
"""
try:
import re
print(f"🔧 ENHANCEMENT: Enhancing question for display reference: {display_reference}")
print(f"🔧 Original: {original_question[:100]}...")
print(f"🔧 Description: {description[:100]}...")
# Use regex patterns to handle punctuation and variations
# Escape the display reference for regex use
escaped_reference = re.escape(display_reference)
# Define patterns that match display references in various contexts
regex_patterns = [
# Quoted display references with optional punctuation
(rf"('{escaped_reference}')([.,;!?]*)", rf"\1 - {description}\2"),
(rf'("{escaped_reference}")([.,;!?]*)', rf'\1 - {description}\2'),
# Common phrases with display references
(rf"(review\s+{escaped_reference})([.,;!?]*)", rf"\1 - {description}\2"),
(rf"(look at\s+{escaped_reference})([.,;!?]*)", rf"\1 - {description}\2"),
(rf"(consider\s+{escaped_reference})([.,;!?]*)", rf"\1 - {description}\2"),
(rf"(examine\s+{escaped_reference})([.,;!?]*)", rf"\1 - {description}\2"),
# Direct display reference with word boundaries
(rf"\b({escaped_reference})\b([.,;!?]*)", rf"\1 - {description}\2")
]
enhanced_question = original_question
enhancement_applied = False
# Try each regex pattern
for pattern, replacement in regex_patterns:
if re.search(pattern, enhanced_question, re.IGNORECASE):
enhanced_question = re.sub(pattern, replacement, enhanced_question, flags=re.IGNORECASE)
print(f"✅ ENHANCEMENT: Enhanced with regex pattern: {enhanced_question[:150]}...")
enhancement_applied = True
break
# If no regex patterns worked, try simple string replacement as fallback
if not enhancement_applied and display_reference in original_question:
enhanced_question = enhanced_question.replace(display_reference, f"{display_reference} - {description}")
print(f"✅ ENHANCEMENT: Enhanced with simple replacement: {enhanced_question[:150]}...")
enhancement_applied = True
# Final fallback: append description if no enhancements worked
if not enhancement_applied:
enhanced_question = f"{original_question} The {display_reference.lower()} shows {description}."
print(f"⚠️ ENHANCEMENT: Appended description to end: {enhanced_question[:150]}...")
return enhanced_question
except Exception as e:
error_msg = f"Failed to enhance question for {display_reference}: {str(e)}"
print(f"❌ ENHANCEMENT: {error_msg}")
# Return original question if enhancement fails
return original_question

View file

@ -761,7 +761,7 @@ class LLMService:
kwargs["max_tokens"] = max_tokens
response = openai_client.chat.completions.create(**kwargs)
result = LLMService._extract_responses_api_content(response)
result = response.choices[0].message.content.strip()
else:
# Gemini contextual multimodal API call (existing logic)
@ -789,6 +789,11 @@ class LLMService:
logger.info(f"Contextual multimodal generation succeeded on attempt {attempt_num}/{max_retries}")
print(f"✅ Generated contextual response with visual context using {provider}")
print(f"🔍 LLM RESULT DEBUG:")
print(f" - Result type: {type(result)}")
print(f" - Result length: {len(result) if result else 0} characters")
print(f" - Result preview: '{result[:200] if result else 'EMPTY'}...'")
print(f" - Result repr: {repr(result[:50]) if result else 'NONE'}")
return result
except Exception as e:

View file

@ -0,0 +1,171 @@
"""
Persona Profile Export Service
Generates beautifully formatted markdown profiles for individual personas using LLM processing.
"""
import os
import json
import logging
from typing import Dict, Any, Optional
from app.services.llm_service import LLMService
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class PersonaExportService:
"""Service for exporting individual persona profiles as formatted markdown."""
def __init__(self):
"""Initialize the persona export service."""
self.llm_service = LLMService()
self.prompt_template = self._load_prompt_template()
def _load_prompt_template(self) -> str:
"""Load the persona profile export prompt template."""
try:
prompt_path = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"prompts",
"persona-profile-export.md"
)
with open(prompt_path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
logger.error(f"Failed to load persona export prompt template: {e}")
# Fallback prompt if file loading fails
return """
You are a professional documentation specialist. Transform the provided persona JSON data
into a well-structured, professional markdown document with proper headers, tables, lists,
and formatting. Include all available information organized logically with sections for
demographics, goals, personality traits, scenarios, and additional data.
"""
def generate_profile_markdown(
self,
persona_data: Dict[str, Any],
llm_model: str = "gpt-4.1",
temperature: float = 0.3
) -> Dict[str, Any]:
"""
Generate a formatted markdown profile for a persona using LLM processing.
Args:
persona_data: Complete persona data as dictionary
llm_model: LLM model to use (default: gpt-4.1 for speed)
temperature: Temperature for LLM generation (lower for consistency)
Returns:
Dictionary containing:
- success: Boolean indicating success
- markdown_content: Generated markdown string
- error: Error message if failed
- persona_name: Name of the persona
"""
try:
# Validate input data
if not persona_data:
return {
"success": False,
"error": "No persona data provided",
"markdown_content": None,
"persona_name": None
}
persona_name = persona_data.get('name', 'Unknown Persona')
logger.info(f"🤖 Backend: Generating profile markdown for persona: {persona_name} using {llm_model}")
# Prepare the full prompt
persona_json = json.dumps(persona_data, indent=2, ensure_ascii=False)
full_prompt = f"{self.prompt_template}\n\n## Persona Data\n```json\n{persona_json}\n```"
# Generate markdown using LLM
markdown_content = self.llm_service.generate_content(
prompt=full_prompt,
model_name=llm_model,
temperature=temperature,
max_tokens=4000 # Allow for comprehensive profiles
)
if not markdown_content:
return {
"success": False,
"error": "LLM failed to generate markdown content",
"markdown_content": None,
"persona_name": persona_name
}
markdown_content = markdown_content.strip()
# Basic validation of generated content
if len(markdown_content) < 100: # Too short, likely an error
logger.warning(f"Generated markdown seems too short for {persona_name}")
return {
"success": False,
"error": "Generated markdown content appears incomplete",
"markdown_content": None,
"persona_name": persona_name
}
logger.info(f"✅ Successfully generated profile markdown for {persona_name} ({len(markdown_content)} characters)")
return {
"success": True,
"markdown_content": markdown_content,
"persona_name": persona_name,
"model_used": llm_model,
"content_length": len(markdown_content)
}
except Exception as e:
logger.error(f"Error generating persona profile markdown: {str(e)}")
return {
"success": False,
"error": f"Failed to generate markdown profile: {str(e)}",
"markdown_content": None,
"persona_name": persona_data.get('name', 'Unknown') if persona_data else None
}
def generate_fallback_markdown(self, persona_data: Dict[str, Any]) -> str:
"""
Generate a basic fallback markdown if LLM processing fails.
Args:
persona_data: Complete persona data as dictionary
Returns:
Basic markdown string
"""
try:
name = persona_data.get('name', 'Unknown Persona')
occupation = persona_data.get('occupation', 'Unknown')
age = persona_data.get('age', 'Unknown')
location = persona_data.get('location', 'Unknown')
# Create basic markdown structure
markdown = f"""# {name} - Complete Profile
## Overview
{name} is a {age} year old {occupation} based in {location}.
## Basic Information
- **Name:** {name}
- **Age:** {age}
- **Occupation:** {occupation}
- **Location:** {location}
## Raw Data
```json
{json.dumps(persona_data, indent=2, ensure_ascii=False)}
```
*This is a basic export. For enhanced formatting, please try again later.*
"""
return markdown
except Exception as e:
logger.error(f"Failed to generate fallback markdown: {e}")
return f"# Persona Profile Export\n\nError generating profile. Raw data:\n\n```json\n{json.dumps(persona_data, indent=2)}\n```"

View file

@ -66,6 +66,45 @@ class StructuredDiscussionGuide:
class DiscussionGuideValidator:
"""Validates and processes discussion guide JSON structures."""
@staticmethod
def create_visual_asset_metadata(filename: str, display_reference: str) -> Dict[str, Any]:
"""
Create visual asset metadata for questions/activities.
Args:
filename: The system filename (e.g., 'fg-123-abc.jpg')
display_reference: User-friendly reference (e.g., 'Asset 1' or custom name)
Returns:
Visual asset metadata dictionary
"""
return {
"visual_asset": {
"filename": filename,
"display_reference": display_reference
}
}
@staticmethod
def generate_display_reference(assets: List[Dict[str, Any]], asset_index: int) -> str:
"""
Generate a display reference for an asset based on user assignment or default numbering.
Args:
assets: List of asset metadata objects
asset_index: Index of the current asset
Returns:
Display reference string
"""
asset = assets[asset_index]
# Use user-assigned name if available, otherwise use numbered reference
if asset.get("user_assigned_name"):
return asset["user_assigned_name"]
else:
return f"Asset {asset_index + 1}"
@staticmethod
def validate_json_structure(guide_json: Dict[str, Any]) -> tuple[bool, List[str]]:
"""

View file

@ -0,0 +1,84 @@
# Persona Profile Export
You are a professional documentation specialist tasked with creating a comprehensive, beautifully formatted markdown profile for a synthetic persona used in market research and user experience design.
## Task
Transform the provided persona JSON data into a well-structured, professional markdown document that presents all available information in a logical, hierarchical manner.
## Formatting Guidelines
### Structure
Use the following hierarchical structure:
1. **Profile Header** - Name and key identifier
2. **Overview** - Brief summary of who they are
3. **Demographics & Background** - Core demographic information in table format
4. **Digital Behavior & Preferences** - Technology usage and digital patterns
5. **Goals & Motivations** - What drives them
6. **Personality Analysis** - OCEAN traits and behavioral insights
7. **Behavioral Patterns** - Think/Feel/Do framework
8. **Life Scenarios** - Real-world usage scenarios
9. **Additional Information** - Any remaining relevant data
### Formatting Rules
- Use proper markdown headers (`#`, `##`, `###`)
- Create tables for structured data (demographics, traits, metrics)
- Use bullet points for lists (goals, frustrations, scenarios)
- Use numbered lists for sequential items when appropriate
- Include horizontal rules (`---`) between major sections
- Format percentages and metrics clearly
- Use **bold** for important terms and values
- Use *italics* for emphasis when needed
- Ensure proper spacing between sections
### Content Guidelines
- Present information in a professional, research-oriented tone
- Group related information logically
- Include all available data fields, but organize them sensibly
- For OCEAN personality traits, include both numeric values and descriptive interpretations
- For metrics like tech savviness, brand loyalty, etc., present as percentages with context
- Format scenarios as numbered, descriptive use cases
- Handle missing or null fields gracefully (omit rather than show "null")
## Example Structure
```markdown
# [Persona Name] - Complete Profile
## Overview
Brief 2-3 sentence summary of the persona's key characteristics and role.
## Demographics & Background
| Attribute | Value |
|-----------|-------|
| Age | [age] |
| Gender | [gender] |
| Occupation | [occupation] |
| Education | [education] |
| Location | [location] |
## Digital Behavior & Preferences
- **Tech Savviness:** [X]% - [interpretation]
- **Brand Loyalty:** [X]% - [interpretation]
- **Price Consciousness:** [X]% - [interpretation]
## Goals & Motivations
### Primary Goals
1. [goal 1]
2. [goal 2]
### Key Frustrations
- [frustration 1]
- [frustration 2]
---
[Continue with remaining sections...]
```
## Input
You will receive a JSON object containing all persona data. Transform this into the beautifully formatted markdown structure described above.
## Output
Return only the formatted markdown content, ready for saving as a .md file. Do not include any explanatory text or comments outside the markdown content itself.

1
dist/assets/index-BLNu9bos.css vendored Normal file

File diff suppressed because one or more lines are too long

715
dist/assets/index-BgDz3VL9.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

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

View file

@ -1,19 +1,37 @@
import { useState } from 'react';
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { focusGroupsApi } from '@/lib/api';
interface Asset {
// Backend asset interface (matches what we get from the API)
interface BackendAsset {
filename: string;
original_name: string;
mime_type: string;
size: number;
user_assigned_name?: string;
upload_date: string;
}
// Local asset state for managing uploads
interface LocalAsset {
id: string;
file: File;
previewUrl?: string;
type: string;
status: 'uploading' | 'uploaded' | 'failed' | 'retry';
backendAsset?: BackendAsset;
error?: string;
}
interface AssetUploaderProps {
onAssetsChange?: (assets: Asset[]) => void;
onAssetsChange?: (assets: BackendAsset[]) => void;
onUploadComplete?: (assets: BackendAsset[]) => void;
onUploadError?: (error: unknown) => void;
focusGroupId?: string;
disabled?: boolean;
maxAssets?: number;
allowedTypes?: string[];
label?: string;
@ -22,144 +40,473 @@ interface AssetUploaderProps {
export default function AssetUploader({
onAssetsChange,
onUploadComplete,
onUploadError,
focusGroupId,
disabled = false,
maxAssets = 10,
allowedTypes = ['image/*', 'application/pdf', 'video/*'],
label = 'Upload Assets',
description = 'Upload creative assets for testing'
}: AssetUploaderProps) {
const [assets, setAssets] = useState<Asset[]>([]);
const [localAssets, setLocalAssets] = useState<LocalAsset[]>([]);
const [backendAssets, setBackendAssets] = useState<BackendAsset[]>([]);
const [editingAsset, setEditingAsset] = useState<string | null>(null);
const [editingName, setEditingName] = useState<string>('');
const handleFileUpload = (files: FileList | null) => {
if (!files || files.length === 0) return;
// Fetch existing backend assets when focusGroupId changes
useEffect(() => {
if (focusGroupId) {
fetchBackendAssets();
}
}, [focusGroupId]); // fetchBackendAssets is stable and doesn't need to be in deps
const fetchBackendAssets = async () => {
if (!focusGroupId) return;
try {
const response = await focusGroupsApi.getAssets(focusGroupId);
const assets = response.data.assets || [];
setBackendAssets(assets);
if (onAssetsChange) {
onAssetsChange(assets);
}
} catch (error) {
console.error("Error fetching backend assets:", error);
// Don't show error toast for initial fetch failures
}
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0 || !focusGroupId) return;
// Check if adding these files would exceed the limit
if (assets.length + files.length > maxAssets) {
const totalAssets = localAssets.length + backendAssets.length;
if (totalAssets + files.length > maxAssets) {
toast.error(`You can only upload up to ${maxAssets} assets`);
return;
}
// Convert FileList to array and create asset objects
const newAssets: Asset[] = Array.from(files).map(file => {
// Generate a preview URL for images
// Create local asset objects for immediate UI feedback
const newLocalAssets: LocalAsset[] = Array.from(files).map(file => {
const previewUrl = file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined;
return {
id: `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
id: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file,
previewUrl,
type: file.type,
status: 'uploading'
};
});
const updatedAssets = [...assets, ...newAssets];
setAssets(updatedAssets);
// Add to local state for immediate UI feedback
setLocalAssets(prev => [...prev, ...newLocalAssets]);
// Notify parent component about the change
if (onAssetsChange) {
onAssetsChange(updatedAssets);
// Upload each file to backend
for (const localAsset of newLocalAssets) {
try {
const formData = new FormData();
formData.append('assets', localAsset.file);
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
const uploadResult = uploadResponse.data;
if (uploadResult.uploaded_assets > 0) {
// Update local asset status
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploaded' }
: asset
));
// Fetch updated backend assets
await fetchBackendAssets();
toast.success(`${localAsset.file.name} uploaded successfully`);
} else {
throw new Error('Upload failed');
}
} catch (error: unknown) {
console.error(`Upload failed for ${localAsset.file.name}:`, error);
// Update local asset status to failed
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? {
...asset,
status: 'failed',
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
}
: asset
));
if (onUploadError) {
onUploadError(error);
}
}
}
toast.success(`${newAssets.length} asset(s) uploaded`, {
description: "Assets added to your project",
});
// Trigger upload complete callback
if (onUploadComplete) {
// Wait a bit for fetchBackendAssets to complete
setTimeout(() => {
onUploadComplete(backendAssets);
}, 500);
}
};
const handleRemoveAsset = (assetId: string) => {
const assetToRemove = assets.find(asset => asset.id === assetId);
const handleRemoveAsset = async (filename: string) => {
if (!focusGroupId) return;
try {
await focusGroupsApi.deleteAsset(focusGroupId, filename);
await fetchBackendAssets();
toast.info('Asset removed');
} catch (error) {
console.error("Error removing asset:", error);
toast.error("Failed to remove asset");
}
};
const handleRemoveLocalAsset = (assetId: string) => {
const assetToRemove = localAssets.find(asset => asset.id === assetId);
if (assetToRemove?.previewUrl) {
URL.revokeObjectURL(assetToRemove.previewUrl);
}
const updatedAssets = assets.filter(asset => asset.id !== assetId);
setAssets(updatedAssets);
setLocalAssets(prev => prev.filter(asset => asset.id !== assetId));
};
const handleRetryUpload = async (localAsset: LocalAsset) => {
if (!focusGroupId) return;
// Notify parent component about the change
if (onAssetsChange) {
onAssetsChange(updatedAssets);
// Update status to uploading
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploading', error: undefined }
: asset
));
try {
const formData = new FormData();
formData.append('assets', localAsset.file);
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
const uploadResult = uploadResponse.data;
if (uploadResult.uploaded_assets > 0) {
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? { ...asset, status: 'uploaded' }
: asset
));
await fetchBackendAssets();
toast.success(`${localAsset.file.name} uploaded successfully`);
} else {
throw new Error('Upload failed');
}
} catch (error: unknown) {
setLocalAssets(prev => prev.map(asset =>
asset.id === localAsset.id
? {
...asset,
status: 'failed',
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
}
: asset
));
toast.error(`Failed to upload ${localAsset.file.name}`);
}
};
const startEditingAssetName = (asset: BackendAsset) => {
setEditingAsset(asset.filename);
setEditingName(asset.user_assigned_name || '');
};
const saveAssetName = async (filename: string) => {
if (!focusGroupId || !editingName.trim()) {
cancelEditingAssetName();
return;
}
toast.info('Asset removed');
try {
await focusGroupsApi.updateAssetName(focusGroupId, filename, editingName.trim());
// Update local state
setBackendAssets(prev => prev.map(asset =>
asset.filename === filename
? { ...asset, user_assigned_name: editingName.trim() }
: asset
));
if (onAssetsChange) {
onAssetsChange(backendAssets);
}
setEditingAsset(null);
setEditingName('');
toast.success("Asset name updated");
} catch (error) {
console.error("Error updating asset name:", error);
toast.error("Failed to update asset name");
}
};
const cancelEditingAssetName = () => {
setEditingAsset(null);
setEditingName('');
};
// Determine the icon to use based on file type
const getAssetIcon = (type: string) => {
if (type.startsWith('image/')) {
return <ImageIcon className="h-10 w-10 text-slate-400" />;
} else if (type.startsWith('video/')) {
return <FileVideo className="h-10 w-10 text-slate-400" />;
} else if (type === 'application/pdf') {
return <FileText className="h-10 w-10 text-slate-400" />;
const getAssetIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) {
return <ImageIcon className="h-8 w-8 text-slate-400" />;
} else if (mimeType.startsWith('video/')) {
return <FileVideo className="h-8 w-8 text-slate-400" />;
} else if (mimeType === 'application/pdf') {
return <FileText className="h-8 w-8 text-slate-400" />;
} else {
return <FileText className="h-10 w-10 text-slate-400" />;
return <FileText className="h-8 w-8 text-slate-400" />;
}
};
const getDisplayName = (asset: BackendAsset, index: number) => {
return asset.user_assigned_name || `Asset ${index + 1}`;
};
const totalAssets = localAssets.length + backendAssets.length;
const remainingSlots = maxAssets - totalAssets;
return (
<div className="space-y-4">
{/* Upload area */}
<div className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 hover:bg-slate-100 transition cursor-pointer">
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-1">{label}</p>
<p className="text-xs text-slate-500 mb-3">{description}</p>
<input
type="file"
accept={allowedTypes.join(',')}
multiple
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
id="asset-uploader-input"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('asset-uploader-input')?.click()}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
<p className="text-xs text-slate-500 mt-2">
{maxAssets - assets.length} of {maxAssets} uploads remaining
</p>
<div
className={`border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center transition ${
disabled
? 'border-slate-100 bg-slate-25 cursor-not-allowed'
: 'border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer'
}`}
>
{disabled ? (
<>
<UploadCloud className="h-10 w-10 text-slate-300 mb-2" />
<p className="text-sm text-slate-400 mb-1">Asset Upload Disabled</p>
<p className="text-xs text-slate-400 mb-3">Complete focus group details above to enable asset uploads</p>
</>
) : (
<>
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-1">{label}</p>
<p className="text-xs text-slate-500 mb-3">{description}</p>
<input
type="file"
accept={allowedTypes.join(',')}
multiple
onChange={(e) => handleFileUpload(e.target.files)}
className="hidden"
id="asset-uploader-input"
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('asset-uploader-input')?.click()}
disabled={disabled || remainingSlots <= 0}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
<p className="text-xs text-slate-500 mt-2">
{remainingSlots} of {maxAssets} uploads remaining
</p>
</>
)}
</div>
{/* Assets preview */}
{assets.length > 0 && (
{(backendAssets.length > 0 || localAssets.length > 0) && (
<Card className="p-4">
<h4 className="text-sm font-medium mb-3">Uploaded Assets ({assets.length})</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{assets.map((asset) => (
<div key={asset.id} className="relative border rounded-md p-2 group">
<button
onClick={() => handleRemoveAsset(asset.id)}
className="absolute top-1 right-1 bg-white rounded-full p-1 shadow-sm opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove asset"
>
<X className="h-3 w-3 text-slate-500" />
</button>
<h4 className="text-sm font-medium mb-3">
Uploaded Assets ({backendAssets.length + localAssets.filter(a => a.status === 'uploaded').length})
</h4>
<div className="space-y-3">
{/* Backend assets (successfully uploaded) */}
{backendAssets.map((asset, index) => (
<div key={asset.filename} className="flex items-center gap-4 p-3 border rounded-lg bg-white">
{/* Asset preview */}
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
{asset.mime_type?.startsWith('image/') ? (
<img
src={focusGroupsApi.getAssetUrl(focusGroupId!, asset.filename)}
alt={getDisplayName(asset, index)}
className="max-h-full max-w-full object-contain rounded"
/>
) : (
getAssetIcon(asset.mime_type)
)}
</div>
<div className="aspect-square bg-slate-100 rounded flex items-center justify-center mb-2">
{/* Asset info and naming */}
<div className="flex-grow min-w-0">
{editingAsset === asset.filename ? (
<div className="flex items-center gap-2">
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
placeholder={`Asset ${index + 1}`}
className="flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission
saveAssetName(asset.filename);
} else if (e.key === 'Escape') {
cancelEditingAssetName();
}
}}
/>
<Button
size="sm"
variant="outline"
type="button"
onClick={() => saveAssetName(asset.filename)}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
type="button"
onClick={cancelEditingAssetName}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{getDisplayName(asset, index)}
</p>
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => startEditingAssetName(asset)}
className="h-6 w-6 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
</div>
<p className="text-xs text-slate-500 truncate">
Original: {asset.original_name}
</p>
</div>
)}
</div>
{/* Actions and status */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="text-right">
<div className="text-xs text-slate-500 mb-1">Will appear as:</div>
<div className="text-sm font-medium text-primary">
"{getDisplayName(asset, index)}"
</div>
</div>
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => handleRemoveAsset(asset.filename)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
{/* Local assets (uploading/failed) */}
{localAssets.map((asset) => (
<div key={asset.id} className="flex items-center gap-4 p-3 border rounded-lg bg-slate-50">
{/* Asset preview */}
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
{asset.previewUrl ? (
<img
src={asset.previewUrl}
alt={asset.file.name}
className="max-h-full max-w-full object-contain"
className="max-h-full max-w-full object-contain rounded"
/>
) : (
getAssetIcon(asset.type)
getAssetIcon(asset.file.type)
)}
</div>
<p className="text-xs truncate">{asset.file.name}</p>
<p className="text-xs text-slate-500 truncate">
{(asset.file.size / 1024).toFixed(1)} KB
</p>
{/* Asset info */}
<div className="flex-grow min-w-0">
<p className="font-medium text-sm truncate">{asset.file.name}</p>
<p className="text-xs text-slate-500">
{(asset.file.size / 1024).toFixed(1)} KB
</p>
{asset.error && (
<p className="text-xs text-red-500 truncate">{asset.error}</p>
)}
</div>
{/* Status and actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{asset.status === 'uploading' && (
<div className="flex items-center gap-2 text-blue-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
)}
{asset.status === 'failed' && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => handleRetryUpload(asset)}
className="h-7 text-xs"
>
<RefreshCw className="h-3 w-3 mr-1" />
Retry
</Button>
</div>
)}
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => handleRemoveLocalAsset(asset.id)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Help text */}
{backendAssets.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Asset Names:</strong> Click the edit icon to customize how assets will be referenced
in the discussion guide. Leave blank to use default numbering.
</p>
</div>
)}
</Card>
)}
</div>
);
}
}

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { MessageSquare, UserCircle, Bot, Star, User, Image as ImageIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Persona } from '@/types/persona';
@ -8,15 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { getPersonaAvatarSrc } from '@/utils/avatarUtils';
import { parseMentions, formatMentionsForDisplay } from '@/utils/mentionUtils';
import { focusGroupsApi } from '@/lib/api';
interface Message {
id: string;
senderId: string; // 'moderator' = AI Moderator, 'facilitator' = Human Facilitator, or participant ID
text: string;
timestamp: Date;
type: 'question' | 'response' | 'system' | 'highlight';
highlighted?: boolean;
}
import { Message } from '@/components/focus-group-session/types';
interface ChatMessageProps {
message: Message;
@ -36,31 +28,47 @@ const ChatMessage = ({ message, persona, toggleHighlight, participants = [], foc
const parsedMentions = parseMentions(message.text, participants);
const formattedText = formatMentionsForDisplay(message.text, parsedMentions.mentions);
// Extract creative asset filename from message text if this is a creative review
const extractAssetFilename = (text: string): string | null => {
// Look for patterns like "asset: filename.jpg" or similar
const patterns = [
// Match quoted filenames (most specific pattern first)
// Check for visual asset using metadata (new system) or fallback to legacy parsing
const hasCreativeAsset = (isModerator || isFacilitator) &&
(message.visualAsset || extractLegacyAssetFilename(message.text)) &&
focusGroupId;
// Get asset info from metadata or fallback to legacy extraction
const getAssetInfo = () => {
if (message.visualAsset) {
// New metadata-driven approach
return {
filename: message.visualAsset.filename,
displayReference: message.visualAsset.displayReference
};
} else {
// Legacy fallback for existing messages
const legacyFilename = extractLegacyAssetFilename(message.text);
return legacyFilename ? {
filename: legacyFilename,
displayReference: legacyFilename
} : null;
}
};
const assetInfo = getAssetInfo();
// Legacy filename extraction for backward compatibility
function extractLegacyAssetFilename(text: string): string | null {
const filenamePatterns = [
/titled\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "titled 'filename.jpg'"
/asset\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "asset 'filename.jpg'"
/image\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "image 'filename.jpg'"
/['"]([a-zA-Z0-9_\-]+\.(jpg|jpeg|png))['\"]/i, // Any quoted filename
// Match focus group asset pattern without quotes
/asset\s+['"]([^'"]+\.(jpg|jpeg|png))['\"]/i, // "asset 'filename.jpg'"
/(fg-[a-f0-9]+-[a-f0-9]{32}\.(jpg|jpeg|png))/i, // fg-{id}-{uuid}.{ext}
];
for (const pattern of patterns) {
for (const pattern of filenamePatterns) {
const match = text.match(pattern);
if (match) {
return match[1];
}
}
return null;
};
const assetFilename = extractAssetFilename(message.text);
const hasCreativeAsset = (isModerator || isFacilitator) && assetFilename && focusGroupId;
}
const handleToggleHighlight = () => {
toggleHighlight();
@ -120,27 +128,38 @@ const ChatMessage = ({ message, persona, toggleHighlight, participants = [], foc
</span>
</div>
<p className="text-slate-700">{formattedText}</p>
<p className="text-slate-700">
{!message.text || message.text.trim() === '' || message.text === '...' ? (
<span className="text-red-500 italic">
[No response content - AI generation may have failed]
</span>
) : (
formattedText
)}
</p>
{/* Display creative asset if this is a moderator message with an asset */}
{hasCreativeAsset && (
{/* Display creative asset if this is a moderator/facilitator message with an asset */}
{hasCreativeAsset && assetInfo && (
<div className="mt-3 p-3 border rounded-lg bg-slate-50">
<div className="flex items-center gap-2 mb-2">
<ImageIcon className="h-4 w-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Creative Asset</span>
{assetInfo.displayReference !== assetInfo.filename && (
<span className="text-xs text-slate-500">({assetInfo.displayReference})</span>
)}
</div>
<img
src={focusGroupsApi.getAssetUrl(focusGroupId!, assetFilename!)}
src={focusGroupsApi.getAssetUrl(focusGroupId!, assetInfo.filename)}
alt="Creative asset for review"
className="max-w-full h-auto rounded border shadow-sm"
style={{ maxHeight: '300px' }}
onError={(e) => {
console.error('Failed to load creative asset:', focusGroupsApi.getAssetUrl(focusGroupId!, assetFilename!));
console.error('Failed to load creative asset:', focusGroupsApi.getAssetUrl(focusGroupId!, assetInfo.filename));
e.currentTarget.style.display = 'none';
// Show placeholder on error
const placeholder = document.createElement('div');
placeholder.className = 'text-xs text-slate-500 italic p-2 border rounded bg-slate-100';
placeholder.textContent = `Creative asset not found: ${assetFilename}`;
placeholder.textContent = `Creative asset not found: ${assetInfo.displayReference}`;
e.currentTarget.parentNode?.appendChild(placeholder);
}}
/>

View file

@ -23,12 +23,14 @@ import {
Plus,
Check,
X,
Download
Download,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import { personasApi, focusGroupsApi, foldersApi } from '@/lib/api';
import GenerationProgressBar from '@/components/ui/GenerationProgressBar';
import DiscussionGuideViewer from './focus-group-session/DiscussionGuideViewer';
import AssetUploader from '@/components/AssetUploader';
import { Button } from "@/components/ui/button";
import {
@ -111,7 +113,6 @@ const formSchema = z.object({
discussionTopics: z.string().min(10, {
message: "Discussion topics are required.",
}),
creativeAssets: z.instanceof(FileList).optional(),
duration: z.string().min(1, {
message: "Duration is required.",
}),
@ -164,7 +165,8 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
return guide && typeof guide === 'object' && guide.title && guide.sections;
};
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [uploadedAssets, setUploadedAssets] = useState<File[]>([]);
const [backendAssets, setBackendAssets] = useState<any[]>([]);
const [isLoadingAssets, setIsLoadingAssets] = useState(false);
const [personas, setPersonas] = useState<any[]>([]);
const [isLoadingPersonas, setIsLoadingPersonas] = useState(false);
@ -538,7 +540,7 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
participants_count: selectedParticipants.length,
status: 'draft',
date: new Date().toISOString(),
uploadedAssets: uploadedAssets.map(file => file.name)
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
};
if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) {
@ -602,13 +604,45 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
}, 2000);
};
// Function to fetch backend assets
const fetchBackendAssets = async (focusGroupId: string) => {
try {
setIsLoadingAssets(true);
const response = await focusGroupsApi.getAssets(focusGroupId);
setBackendAssets(response.data.assets || []);
} catch (error) {
console.error("Error fetching backend assets:", error);
toast.error("Failed to load asset information");
} finally {
setIsLoadingAssets(false);
}
};
// Function to update asset name
const updateAssetName = async (focusGroupId: string, filename: string, newName: string) => {
try {
await focusGroupsApi.updateAssetName(focusGroupId, filename, newName);
// Update local state
setBackendAssets(prev => prev.map(asset =>
asset.filename === filename
? { ...asset, user_assigned_name: newName }
: asset
));
toast.success("Asset name updated");
} catch (error) {
console.error("Error updating asset name:", error);
toast.error("Failed to update asset name");
}
};
// Watch for form field changes to trigger auto-save
const watchedFields = form.watch();
// Use refs to track previous values to prevent unnecessary saves
const prevWatchedFieldsRef = useRef<string>('');
const prevSelectedParticipantsRef = useRef<string>('');
const prevUploadedAssetsRef = useRef<string>('');
// Effect to handle form field changes and trigger auto-save
useEffect(() => {
@ -628,14 +662,7 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
}
}, [selectedParticipants, activeTab]);
// Effect to handle uploaded assets changes
useEffect(() => {
const currentAssets = JSON.stringify(uploadedAssets.map(f => f.name));
if (activeTab === 'setup' && currentAssets !== prevUploadedAssetsRef.current) {
prevUploadedAssetsRef.current = currentAssets;
triggerAutoSave();
}
}, [uploadedAssets, activeTab]);
// Asset uploads are now handled immediately via AssetUploader component
// Effect to clear timers when leaving setup tab or component unmounts
useEffect(() => {
@ -674,6 +701,11 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
setDraftFocusGroupId(draftId);
console.log("Setting draft ID from draftToEdit:", draftId);
// Load backend assets for this focus group
if (draftId) {
fetchBackendAssets(draftId);
}
// Load form data if available
if (draftToEdit.name) {
form.setValue('focusGroupName', draftToEdit.name);
@ -725,7 +757,7 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
participants_count: (draftToEdit.participants || []).length,
status: 'draft',
date: draftToEdit.date || new Date().toISOString(),
uploadedAssets: []
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
};
setLastSavedData(currentDraftData);
console.log("Set lastSavedData to current draft:", currentDraftData);
@ -874,7 +906,7 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
// First, save focus group to database to get an ID for asset uploads
// Use existing focus group ID or create new draft for discussion guide generation
let focusGroupId = draftFocusGroupId;
if (!focusGroupId) {
@ -896,56 +928,11 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSele
const savedDraft = await focusGroupsApi.create(draftData);
focusGroupId = savedDraft.data.focus_group_id || savedDraft.data.id || savedDraft.data._id;
setDraftFocusGroupId(focusGroupId);
console.log("Draft focus group created for asset upload:", savedDraft, "with ID:", focusGroupId);
console.log("Draft focus group created for discussion guide generation:", savedDraft, "with ID:", focusGroupId);
}
// Handle creative assets upload if any
if (values.creativeAssets && values.creativeAssets.length > 0 && focusGroupId) {
try {
const formData = new FormData();
Array.from(values.creativeAssets).forEach(file => {
formData.append('assets', file);
});
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, true);
const uploadResult = uploadResponse.data;
console.log("Assets uploaded successfully:", uploadResult);
toast.success(`${uploadResult.uploaded_assets} asset(s) uploaded successfully`, {
description: "Assets will be included in the discussion guide",
});
// Store uploaded asset info for display
const assets = Array.from(values.creativeAssets);
setUploadedAssets(assets);
} catch (uploadError: any) {
console.error("Asset upload failed:", uploadError);
// Handle specific error codes from backend
const errorData = uploadError.response?.data;
let errorTitle = "Asset upload failed";
let errorDescription = "Some assets could not be uploaded";
if (errorData?.code === 'TEMP_DIR_ERROR') {
errorTitle = "Upload temporarily unavailable";
errorDescription = "Server storage issue. Please try again in a moment.";
} else if (errorData?.code === 'UPLOAD_SYSTEM_FAILURE') {
errorTitle = "Upload system unavailable";
errorDescription = "Critical server issue. Please contact support.";
} else if (errorData?.can_retry) {
errorTitle = "Upload failed - can retry";
errorDescription = errorData?.details || "Please try uploading again.";
}
toast.error(errorTitle, {
description: errorDescription,
});
// Continue with discussion guide generation even if upload fails
console.log("Continuing without assets due to upload failure");
}
}
// Assets are now uploaded immediately via AssetUploader component
// No need to handle asset uploads here
// Update focus group with current form values before generating guide
// This ensures the backend uses the latest model selection
@ -1144,16 +1131,8 @@ true;
};
const handleAssetUpload = (files: FileList | null) => {
if (files && files.length > 0) {
const newAssets = Array.from(files);
setUploadedAssets(prev => [...prev, ...newAssets]);
toast.success(`${newAssets.length} asset(s) uploaded`, {
description: "Assets will be included in the focus group",
});
}
};
// Asset upload is now handled by AssetUploader component
// This function is no longer needed
// Function to save the focus group to the database
const saveFocusGroup = async () => {
@ -1546,51 +1525,34 @@ Controls how much time GPT-5 spends thinking before responding
</div>
</div>
<FormField
control={form.control}
name="creativeAssets"
render={({ field: { value, onChange, ...fieldProps } }) => (
<FormItem>
<FormLabel>Creative Assets (Optional)</FormLabel>
<FormControl>
<div className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 hover:bg-slate-100 transition cursor-pointer">
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
<p className="text-sm text-slate-600 mb-1">Upload creative assets for testing</p>
<p className="text-xs text-slate-500 mb-3">Images, mockups, or product designs</p>
<Input
{...fieldProps}
type="file"
accept="image/*,.pdf"
multiple
onChange={(e) => {
onChange(e.target.files);
}}
className="hidden"
id="assets-file-input"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => document.getElementById('assets-file-input')?.click()}
>
<Upload className="mr-2 h-4 w-4" />
Select Files
</Button>
{value && value.length > 0 && (
<p className="text-xs text-primary mt-2">
{value.length} file(s) selected
</p>
)}
</div>
</FormControl>
<FormDescription>
Upload visuals that you want feedback on during the session
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Asset Uploader */}
<div>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-2 block">
Creative Assets (Optional)
</label>
<AssetUploader
focusGroupId={draftFocusGroupId}
disabled={!draftFocusGroupId}
onUploadComplete={(assets) => {
setBackendAssets(assets);
// Trigger auto-save to update focus group metadata
}}
onUploadError={(error) => {
console.error('Asset upload error:', error);
// Error handling is already done in AssetUploader component
}}
onAssetsChange={(assets) => {
setBackendAssets(assets);
}}
maxAssets={10}
allowedTypes={['image/*', 'application/pdf', 'video/*']}
label="Upload Creative Assets"
description="Upload images, mockups, or product designs for testing"
/>
<p className="text-sm text-muted-foreground mt-2">
Upload visuals that you want feedback on during the session
</p>
</div>
<div className="space-y-3">
<div className="flex justify-end">
@ -1655,30 +1617,50 @@ Controls how much time GPT-5 spends thinking before responding
</CardContent>
</Card>
{uploadedAssets.length > 0 && (
{backendAssets.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="font-sf text-lg font-medium mb-4">Uploaded Creative Assets</h3>
<h3 className="font-sf text-lg font-medium mb-4">Creative Assets</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{uploadedAssets.map((asset, index) => (
<div key={index} className="border rounded-md p-2">
<div className="aspect-square bg-slate-100 rounded flex items-center justify-center mb-2">
{asset.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(asset)}
alt={`Asset ${index + 1}`}
className="max-h-full max-w-full object-contain"
/>
) : (
<FileText className="h-10 w-10 text-slate-400" />
)}
</div>
<p className="text-xs truncate">{asset.name}</p>
</div>
))}
<div className="space-y-3">
<p className="text-sm text-slate-600">
Assets that will be referenced in the discussion guide:
</p>
<div className="space-y-2">
{backendAssets.map((asset, index) => {
const displayName = asset.user_assigned_name || `Asset ${index + 1}`;
return (
<div key={asset.filename} className="flex items-center gap-3 p-3 border rounded-lg bg-slate-50">
{/* Asset preview */}
<div className="w-10 h-10 bg-slate-200 rounded flex items-center justify-center flex-shrink-0">
{asset.mime_type?.startsWith('image/') ? (
<img
src={focusGroupsApi.getAssetUrl(draftFocusGroupId, asset.filename)}
alt={displayName}
className="max-h-full max-w-full object-contain rounded"
/>
) : (
<FileText className="h-6 w-6 text-slate-600" />
)}
</div>
{/* Asset name */}
<div className="flex-grow">
<p className="font-medium text-sm">"{displayName}"</p>
<p className="text-xs text-slate-500">Will appear in discussion guide</p>
</div>
</div>
);
})}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-700">
<strong>Note:</strong> To rename assets, go back to the Setup tab and click the edit icon next to each asset.
</p>
</div>
</div>
</CardContent>
</Card>
)}

View file

@ -85,7 +85,7 @@ interface DiscussionGuideViewerProps {
discussionGuide: StructuredDiscussionGuide | string;
moderatorStatus?: ModeratorStatus;
onSectionSelect?: (sectionId: string, itemId?: string) => void;
onSetPosition?: (sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string) => void;
onSetPosition?: (sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string, metadata?: Record<string, any>) => void;
onSave?: (updatedGuide: StructuredDiscussionGuide) => Promise<void>;
showProgress?: boolean;
collapsible?: boolean;
@ -548,14 +548,12 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
const isCurrent = status === 'current';
const isCompleted = status === 'completed';
// Extract image filename from content if it contains an image reference
const extractImageFilename = (content: string): string | null => {
// Look for patterns like 'fg-[id]-[hash].[ext]' in single or double quotes
const match = content.match(/['"`]([^'"`]*fg-[^'"`]*\.(jpe?g|png|gif|webp))['"`]/i);
return match ? match[1] : null;
// Get visual asset filename from metadata instead of extracting from content
const getVisualAssetFilename = (item: DiscussionGuideItem): string | null => {
return item.metadata?.visual_asset?.filename || null;
};
const imageFilename = extractImageFilename(item.content);
const imageFilename = getVisualAssetFilename(item);
// Check if this is a default placeholder item
const isPlaceholder = isDefaultPlaceholderContent(item.content, itemType);
@ -766,7 +764,7 @@ const DiscussionGuideViewer: React.FC<DiscussionGuideViewerProps> = React.memo((
e.stopPropagation();
const section = structuredGuide!.sections[sectionIndex];
const itemTitle = itemType === 'activity' ? `Activity ${itemIndex + 1}` : `Question ${itemIndex + 1}`;
onSetPosition(section.id, item.id, item.content, section.title, itemTitle, itemType);
onSetPosition(section.id, item.id, item.content, section.title, itemTitle, itemType, item.metadata);
}}
className="h-6 px-2 ml-auto"
>

View file

@ -9,20 +9,11 @@ import { parseMentions, type ParsedMentions } from '@/utils/mentionUtils';
import ChatMessage from '@/components/ChatMessage';
import ReasoningPanel from './ReasoningPanel';
import { Persona } from '@/types/persona';
import { ModeEvent } from './types';
import { ModeEvent, Message } from './types';
import { focusGroupsApi, focusGroupAiApi } from '@/lib/api';
import { toast } from 'sonner';
import ModeSwitchMarker from './ModeSwitchMarker';
interface Message {
id: string;
senderId: string; // 'moderator' = AI Moderator, 'facilitator' = Human Facilitator, or participant ID
text: string;
timestamp: Date;
type: 'question' | 'response' | 'system' | 'highlight';
highlighted?: boolean;
}
interface DiscussionPanelProps {
messages: Message[];
modeEvents: ModeEvent[];
@ -274,6 +265,7 @@ const DiscussionPanel = ({
let finalMessageText = userInput;
let uploadedFilename: string | null = null;
let uploadedAssetMetadata: { filename: string; displayReference: string } | null = null;
// Store current mentions for response generation
const mentionsToProcess = currentMentions;
@ -311,8 +303,47 @@ const DiscussionPanel = ({
}
if (uploadedFilename) {
// Format message text to include the asset reference for ChatMessage to detect
finalMessageText = `Please review this creative asset titled '${uploadedFilename}'. ${userInput}`;
// Get the latest asset count to generate display reference
try {
const assetsResponse = await focusGroupsApi.getAssets(focusGroupId);
const allAssets = assetsResponse?.data?.assets || [];
// Find our newly uploaded asset
const uploadedAsset = allAssets.find(asset => asset.filename === uploadedFilename);
// Generate display reference
let displayReference = 'the uploaded asset';
if (uploadedAsset) {
if (uploadedAsset.user_assigned_name) {
displayReference = uploadedAsset.user_assigned_name;
} else {
// Count assets to generate "Asset N" reference
const assetIndex = allAssets.findIndex(asset => asset.filename === uploadedFilename);
displayReference = `Asset ${assetIndex + 1}`;
}
}
// Store asset metadata for message
uploadedAssetMetadata = {
filename: uploadedFilename,
displayReference: displayReference
};
// Format message text to include the display reference instead of filename
finalMessageText = `Please review ${displayReference}. ${userInput}`;
console.log('Using display reference in message:', displayReference);
} catch (assetError) {
console.error('Error fetching asset metadata:', assetError);
// Fallback to generic reference
finalMessageText = `Please review the uploaded asset. ${userInput}`;
// Still store the basic metadata
uploadedAssetMetadata = {
filename: uploadedFilename,
displayReference: 'the uploaded asset'
};
}
toast.success('Creative asset uploaded successfully', {
description: 'The image has been attached to your message.'
@ -332,31 +363,30 @@ const DiscussionPanel = ({
clearSelectedFile();
}
// Create user message with final text (including asset reference if uploaded)
const userMessage: Message = {
id: `msg-${Date.now()}`,
senderId: 'facilitator',
text: finalMessageText,
timestamp: new Date(),
type: 'question'
};
// Send message to API
const response = await focusGroupsApi.sendMessage(focusGroupId, {
// Send message to API first to get server timestamp
const messageData: any = {
text: finalMessageText,
type: 'question',
senderId: 'facilitator'
});
};
// Add visual asset information if file was uploaded
if (uploadedFilename) {
messageData.attached_assets = [uploadedFilename];
messageData.activates_visual_context = true;
// Add visual asset metadata for database storage
if (uploadedAssetMetadata) {
messageData.visualAsset = uploadedAssetMetadata;
}
}
const response = await focusGroupsApi.sendMessage(focusGroupId, messageData);
console.log('Message sent to API:', response);
// Update message ID if available from API
if (response?.data?.message_id) {
userMessage.id = response.data.message_id;
}
// Update UI with the new message
onNewMessage(userMessage);
// Message will be handled by WebSocket system with correct server timestamp
// No need to manually create and add message here
// Scroll to the latest message when the user sends something
// regardless of auto-scroll setting
@ -370,12 +400,14 @@ const DiscussionPanel = ({
setTimeout(() => {
generateMentionedResponses(
mentionsToProcess.mentionedParticipantIds,
userMessage.text
finalMessageText
);
}, 500);
} else {
// No mentions to process - let useEffect clear loading state when message appears
// (Don't manually clear isTyping here since message might be delayed by polling)
// No mentions to process - clear typing indicator immediately since no AI responses are expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}
} catch (error) {
console.error('Error sending message:', error);
@ -612,6 +644,12 @@ const DiscussionPanel = ({
};
onNewMessage(completionMessage);
// Clear typing state immediately since no AI response is expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
return;
}
@ -650,6 +688,11 @@ const DiscussionPanel = ({
// Add the message to the UI
onNewMessage(moderatorMessage);
// Clear typing state immediately in manual mode since no AI response is expected
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
// Scroll to see the new message
setTimeout(() => {
scrollToBottom();
@ -909,7 +952,7 @@ const DiscussionPanel = ({
id: response.data.message_id || `msg-${Date.now()}-${participantId}`,
senderId: participantId,
text: response.data.response,
timestamp: new Date(),
timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()),
type: 'response'
};
@ -925,6 +968,11 @@ const DiscussionPanel = ({
}
}
// Clear typing state immediately after all mentioned responses are processed
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
} catch (error) {
console.error('Error generating mentioned responses:', error);
toast.error('Failed to generate responses from mentioned participants');
@ -933,7 +981,6 @@ const DiscussionPanel = ({
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
}
// Note: Don't clear loading in finally block - let message arrival handle it
};
// Generate an AI response using intelligent participant selection
@ -1005,7 +1052,7 @@ const DiscussionPanel = ({
id: response.data.message_id,
senderId: participantId,
text: response.data.response,
timestamp: new Date(),
timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()),
type: 'response',
highlighted: false
};
@ -1013,6 +1060,11 @@ const DiscussionPanel = ({
// Add the message to the UI
onNewMessage(newMessage);
// Clear typing state immediately since the participant response is now available
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
// Scroll to see the AI response
setTimeout(() => {
scrollToBottom();
@ -1028,6 +1080,12 @@ const DiscussionPanel = ({
toast.info("AI suggests moderator intervention", {
description: `AI reasoning: ${decision.reasoning.substring(0, 100)}${decision.reasoning.length > 100 ? '...' : ''}`
});
// Clear typing state since no participant response will be generated
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
return; // Don't generate participant response
}
@ -1053,13 +1111,18 @@ const DiscussionPanel = ({
id: response.data.message_id,
senderId: personaId,
text: response.data.response,
timestamp: new Date(),
timestamp: new Date(response.data.timestamp || response.data.created_at || new Date()),
type: 'response',
highlighted: false
};
onNewMessage(newMessage);
// Clear typing state immediately since the participant response is now available
setIsTyping(false);
setIsExpectingMessage(false);
loadingStartTimeRef.current = null;
setTimeout(() => {
scrollToBottom();
}, 100);

View file

@ -26,6 +26,10 @@ export interface Message {
timestamp: Date;
type: 'question' | 'response' | 'system' | 'highlight';
highlighted?: boolean;
visualAsset?: {
filename: string;
displayReference: string;
};
}
export interface HighlightedTheme {

View file

@ -11,9 +11,10 @@ import {
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { ArrowLeft, Edit, Home, Users, User } from 'lucide-react';
import { ArrowLeft, Edit, Home, Users, User, Download } from 'lucide-react';
import { useNavigation } from '@/contexts/NavigationContext';
import { focusGroupsApi } from '@/lib/api';
import { focusGroupsApi, personasApi } from '@/lib/api';
import { toastService } from '@/lib/toast';
import { PersonaSidebar } from './PersonaSidebar';
import { PersonaCooperProfile } from './PersonaCooperProfile';
@ -37,6 +38,7 @@ export default function PersonaProfile() {
const { navigationState } = useNavigation();
const [focusGroupName, setFocusGroupName] = useState<string>('');
const [isExporting, setIsExporting] = useState(false);
// Fetch focus group name if coming from focus group session
useEffect(() => {
@ -58,6 +60,82 @@ export default function PersonaProfile() {
// Determine if we should show breadcrumbs
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') && navigationState.focusGroupId;
// Handle persona profile export
const handleExportProfile = async () => {
if (!currentPersona) return;
setIsExporting(true);
try {
toastService.info("Generating persona profile...", {
description: "Using GPT-4.1 to create a beautifully formatted markdown profile"
});
// Use the persona's MongoDB _id or fallback to id
const personaId = currentPersona._id || currentPersona.id;
console.log(`🔽 Frontend: Exporting profile for persona ${currentPersona.name} (ID: ${personaId})`);
// Call the export API with GPT-4.1
const response = await personasApi.exportProfile(personaId, {
llm_model: 'gpt-4.1',
temperature: 0.3
});
const { markdown_content, persona_name, model_used, warning } = response.data;
if (markdown_content) {
// Generate filename with current date
const currentDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const safePersonaName = persona_name.replace(/[^a-zA-Z0-9\-\s]/g, '').replace(/\s+/g, '-').toLowerCase();
const filename = `${safePersonaName}-profile-${currentDate}.md`;
// Create and download the file
const element = document.createElement('a');
const file = new Blob([markdown_content], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
// Show success toast
if (warning) {
toastService.success("Profile downloaded with fallback formatting", {
description: `${persona_name} profile saved as ${filename}`
});
} else {
const modelDisplay = model_used === 'gpt-4.1' ? 'GPT-4.1' : model_used;
toastService.success("Profile downloaded successfully", {
description: `${persona_name} profile processed with ${modelDisplay} and saved as ${filename}`
});
}
} else {
throw new Error("No markdown content received");
}
} catch (error) {
console.error("Error exporting persona profile:", error);
// Show detailed error message
if (error.response) {
toastService.error("Failed to export profile", {
description: error.response.data?.error || "Server error occurred"
});
} else if (error.request) {
toastService.error("Network error", {
description: "Unable to connect to the server"
});
} else {
toastService.error("Export failed", {
description: error.message || "An unexpected error occurred"
});
}
} finally {
setIsExporting(false);
}
};
if (isLoading) {
return <PersonaProfileSkeleton />;
}
@ -121,10 +199,21 @@ export default function PersonaProfile() {
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-sf text-3xl font-bold text-slate-900 mx-auto">Persona Profile</h1>
<Button onClick={() => setIsEditing(true)} className="absolute right-0 top-0">
<Edit className="h-4 w-4 mr-2" />
Edit Persona
</Button>
<div className="absolute right-0 top-0 flex items-center gap-3">
<Button
variant="outline"
onClick={handleExportProfile}
disabled={isExporting}
className="hover-transition"
>
<Download className="h-4 w-4 mr-2" />
{isExporting ? 'Generating...' : 'Download Profile'}
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit className="h-4 w-4 mr-2" />
Edit Persona
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-10">

View file

@ -154,7 +154,13 @@ export const personasApi = {
},
createBatch: (personasData: any[]) =>
api.post('/personas/batch', personasData)
api.post('/personas/batch', personasData),
// Export individual persona profile as markdown
exportProfile: (id: string, options?: { llm_model?: string; temperature?: number }) =>
api.post(`/personas/${id}/export-profile`, options || {}, {
timeout: 300000 // 5 minutes for profile export
})
};
// AI Persona Generation endpoints
@ -479,6 +485,11 @@ export const focusGroupsApi = {
getAssetUrl: (focusGroupId: string, filename: string) =>
`${API_BASE_URL}/focus-groups/${focusGroupId}/assets/${filename}`,
updateAssetName: (focusGroupId: string, filename: string, userAssignedName: string) =>
api.patch(`/focus-groups/${focusGroupId}/assets/${filename}`, {
user_assigned_name: userAssignedName
}),
deleteAsset: (focusGroupId: string, filename: string) =>
api.delete(`/focus-groups/${focusGroupId}/assets/${filename}`)
};

View file

@ -99,6 +99,7 @@ const FocusGroupSession = () => {
sectionTitle?: string;
itemTitle?: string;
itemType?: string;
metadata?: Record<string, any>;
isLoading?: boolean;
}>({ isOpen: false });
@ -535,7 +536,7 @@ const FocusGroupSession = () => {
try {
const response = await focusGroupsApi.getMessages(id);
console.log('🔍 [FetchMessages] Raw API response:', response?.data);
// Handle both old (array) and new (object with messages/mode_events) response formats
let messagesData: any[] = [];
@ -564,9 +565,19 @@ const FocusGroupSession = () => {
text: msg.text,
timestamp: new Date(msg.timestamp || msg.created_at || new Date()),
type: msg.type || 'response',
highlighted: msg.highlighted || false
highlighted: msg.highlighted || false,
visualAsset: msg.visualAsset // Include visual asset metadata for image display
}));
console.log('🔍 [FetchMessages] Formatted messages with visual assets:',
formattedMessages.filter(m => m.visualAsset).map(m => ({
id: m.id,
senderId: m.senderId,
hasVisualAsset: !!m.visualAsset,
visualAsset: m.visualAsset
}))
);
// Convert dates and format mode events
const formattedModeEvents = modeEventsData.map((event: any) => ({
id: event._id || event.id || `event-${Date.now()}`,
@ -1187,7 +1198,17 @@ const FocusGroupSession = () => {
// Handler for adding new messages to the conversation
const handleNewMessage = (message: Message) => {
setMessages(prevMessages => [...prevMessages, message]);
setMessages(prevMessages => {
// Check for duplicates
const exists = prevMessages.find(m => m.id === message.id);
if (exists) {
console.log('🔧 [handleNewMessage] Message already exists, skipping:', message.id);
return prevMessages;
}
console.log('🔧 [handleNewMessage] Adding new message:', message.id);
return [...prevMessages, message];
});
};
const toggleHighlight = async (messageId: string) => {
@ -1556,7 +1577,7 @@ const FocusGroupSession = () => {
}, []);
// Handler for setting moderator position
const handleSetPosition = useCallback((sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string) => {
const handleSetPosition = useCallback((sectionId: string, itemId: string, content: string, sectionTitle: string, itemTitle?: string, itemType?: string, metadata?: Record<string, any>) => {
setSetPositionDialog({
isOpen: true,
sectionId,
@ -1564,7 +1585,8 @@ const FocusGroupSession = () => {
content,
sectionTitle,
itemTitle,
itemType
itemType,
metadata
});
}, []);
@ -2111,13 +2133,15 @@ const FocusGroupSession = () => {
let activatesVisualContext = false;
let enhancedMessageText = setPositionDialog.content; // Start with original text
// Extract asset filename from content - this works for any item type
const assetFilename = setPositionDialog.content ? extractAssetFilename(setPositionDialog.content) : null;
const hasImageAttached = !!assetFilename;
// Check for visual asset in metadata instead of parsing content
const visualAsset = setPositionDialog.metadata?.visual_asset;
const hasImageAttached = !!visualAsset?.filename;
const assetFilename = visualAsset?.filename;
console.log('🔍 MANUAL POSITION DEBUG:', {
itemType: setPositionDialog.itemType,
hasImageAttached,
visualAsset,
assetFilename,
content: setPositionDialog.content,
sectionTitle: setPositionDialog.sectionTitle,
@ -2126,9 +2150,11 @@ const FocusGroupSession = () => {
});
if (hasImageAttached && setPositionDialog.content && assetFilename) {
console.log('🔍 ASSET EXTRACTION DEBUG:', {
console.log('🔍 VISUAL ASSET DEBUG:', {
originalContent: setPositionDialog.content,
extractedFilename: assetFilename,
visualAsset,
displayReference: visualAsset?.display_reference,
filename: assetFilename,
contentLength: setPositionDialog.content.length
});
@ -2141,22 +2167,14 @@ const FocusGroupSession = () => {
try {
console.log('🎨 MANUAL MODE: Requesting AI description for', assetFilename);
// First test the routing with a simple endpoint
try {
console.log('🔍 TESTING: Calling test endpoint first...');
const testResponse = await api.post(`/focus-groups/${id}/test-endpoint`, { test: 'data' });
console.log('✅ TEST: Test endpoint response:', testResponse.data);
} catch (testError) {
console.error('❌ TEST: Test endpoint failed:', testError);
}
const descriptionResponse = await focusGroupsApi.describeAsset(id, assetFilename);
if (descriptionResponse.data.description) {
// Enhance the question text with the AI description
// Enhance the question text with the AI description using display reference
const displayRef = visualAsset?.display_reference || 'the asset';
enhancedMessageText = setPositionDialog.content.replace(
`'${assetFilename}'`,
`'${assetFilename}' - ${descriptionResponse.data.description}`
displayRef,
`${displayRef} - ${descriptionResponse.data.description}`
);
console.log('✅ MANUAL MODE: Enhanced question with AI description');
@ -2194,7 +2212,11 @@ const FocusGroupSession = () => {
senderId: 'moderator',
text: enhancedMessageText, // Use enhanced text
timestamp: new Date(),
type: 'question'
type: 'question',
visualAsset: hasImageAttached && visualAsset ? {
filename: assetFilename,
displayReference: visualAsset.display_reference
} : undefined
};
// Send to API first with visual asset information
@ -2210,7 +2232,11 @@ const FocusGroupSession = () => {
text: enhancedMessageText, // Use enhanced text in API call too
type: 'question',
attached_assets: attachedAssets,
activates_visual_context: activatesVisualContext
activates_visual_context: activatesVisualContext,
visualAsset: hasImageAttached && visualAsset ? {
filename: assetFilename,
displayReference: visualAsset.display_reference
} : undefined
});
if (msgResponse?.data?.message_id) {

View file

@ -15,6 +15,10 @@ export interface WebSocketEvents {
highlighted: boolean;
attached_assets?: string[];
activates_visual_context?: boolean;
visualAsset?: {
filename: string;
displayReference: string;
};
};
};
@ -90,7 +94,8 @@ export function convertWebSocketMessage(wsMessage: WebSocketEvents['message_upda
type: wsMessage.type,
highlighted: wsMessage.highlighted,
attached_assets: wsMessage.attached_assets || [],
activates_visual_context: wsMessage.activates_visual_context || false
activates_visual_context: wsMessage.activates_visual_context || false,
visualAsset: wsMessage.visualAsset
};
console.log('🔍 [GPT-5 CONVERTER] Output converted:', JSON.stringify(converted, null, 2));

View file

@ -21,8 +21,8 @@ export function parseMentions(text: string, participants: Persona[]): ParsedMent
const mentions: MentionData[] = [];
const mentionedParticipantIds: string[] = [];
// Regular expression to match @mentions
const mentionRegex = /@(\w+(?:\s+\w+)*)/g;
// Regular expression to match @mentions - stop at conjunctions or non-word boundaries
const mentionRegex = /@(\w+(?:\s+\w+)*?)(?=\s+and\s|\s+or\s|\s*[^\w\s]|\s*$)/g;
let match;
while ((match = mentionRegex.exec(text)) !== null) {