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:
parent
d5dc0bc3af
commit
8a5c50cacb
39 changed files with 2381 additions and 1091 deletions
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
171
backend/app/services/persona_export_service.py
Normal file
171
backend/app/services/persona_export_service.py
Normal 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```"
|
||||
Binary file not shown.
|
|
@ -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]]:
|
||||
"""
|
||||
|
|
|
|||
84
backend/prompts/persona-profile-export.md
Normal file
84
backend/prompts/persona-profile-export.md
Normal 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
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
715
dist/assets/index-BgDz3VL9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-BttT7ZR2.css
vendored
1
dist/assets/index-BttT7ZR2.css
vendored
File diff suppressed because one or more lines are too long
710
dist/assets/index-C4rrBVCh.js
vendored
710
dist/assets/index-C4rrBVCh.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -7,8 +7,8 @@
|
|||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<script type="module" crossorigin src="/semblance/assets/index-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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export interface Message {
|
|||
timestamp: Date;
|
||||
type: 'question' | 'response' | 'system' | 'highlight';
|
||||
highlighted?: boolean;
|
||||
visualAsset?: {
|
||||
filename: string;
|
||||
displayReference: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HighlightedTheme {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue