cohorta/backend/app/routes/focus_groups.py
2025-12-19 19:26:16 +00:00

1763 lines
No EOL
74 KiB
Python
Executable file

from quart import Blueprint, request, jsonify, Response, send_file
from app.auth.quart_jwt import jwt_required, get_jwt_identity
from app.models.focus_group import FocusGroup
from app.models.persona import Persona
from app.services.focus_group_service import FocusGroupService
from app.services.image_description_service import ImageDescriptionService, ImageDescriptionError
from app.services.task_manager import CancellableTask
from bson import ObjectId
import datetime
import json
import os
import asyncio
import uuid
import tempfile
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
# Helper function to make MongoDB documents JSON serializable
def make_serializable(obj):
if isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [make_serializable(item) for item in obj]
elif isinstance(obj, ObjectId):
return str(obj)
elif isinstance(obj, datetime.datetime):
return obj.isoformat()
else:
return obj
# Direct file processing utility for temp directory issues
def process_files_directly_from_request_stream(request, logger):
"""
Process uploaded files directly from request stream before it gets consumed.
This is the primary fallback when temp directories are unavailable.
"""
try:
from io import BytesIO
from werkzeug.datastructures import FileStorage
import re
# Get the raw input stream - try cached data first
raw_data = None
# Try to get cached data from before_request hook
try:
from flask import g
if hasattr(g, 'cached_request_data'):
raw_data = g.cached_request_data
logger.info("Using cached request data from before_request hook")
except Exception:
pass
# If no cached data, try to read from stream
if not raw_data:
try:
raw_data = request.stream.read()
if not raw_data:
raw_data = request.get_data(cache=False)
except Exception as stream_error:
logger.warning(f"Could not read from request stream: {stream_error}")
try:
raw_data = request.get_data(cache=False)
except Exception as get_data_error:
logger.warning(f"Could not get data from request: {get_data_error}")
raw_data = None
if not raw_data:
logger.warning("No raw data available in request stream")
return []
logger.info(f"Processing {len(raw_data)} bytes from request stream")
# Look for multipart boundary in Content-Type header
content_type = request.headers.get('Content-Type', '')
boundary_match = re.search(r'boundary=([^;,\s]+)', content_type)
if not boundary_match:
logger.warning("No multipart boundary found in Content-Type")
return []
boundary = boundary_match.group(1).strip('"')
logger.info(f"Using boundary: {boundary}")
# Split by boundary
parts = raw_data.split(f'--{boundary}'.encode())
files = []
for i, part in enumerate(parts):
if b'Content-Disposition: form-data' in part and b'filename=' in part:
try:
# Extract filename
filename_match = re.search(rb'filename="([^"]+)"', part)
if not filename_match:
continue
filename = filename_match.group(1).decode('utf-8')
logger.info(f"Processing file: {filename}")
# Find the file data (after the headers, marked by \r\n\r\n)
headers_end = part.find(b'\r\n\r\n')
if headers_end == -1:
logger.warning(f"No header boundary found for {filename}")
continue
# Extract file data
file_data = part[headers_end + 4:]
# Clean up trailing boundary markers and CRLF
file_data = file_data.rstrip(b'\r\n-')
if len(file_data) > 0:
# Determine content type from headers if available
content_type_match = re.search(rb'Content-Type:\s*([^\r\n]+)', part)
detected_content_type = 'image/jpeg' # default
if content_type_match:
detected_content_type = content_type_match.group(1).decode('utf-8').strip()
# Create a FileStorage-like object from the extracted data
file_stream = BytesIO(file_data)
file_obj = FileStorage(
stream=file_stream,
filename=filename,
content_type=detected_content_type
)
files.append(file_obj)
logger.info(f"Successfully extracted: {filename} ({len(file_data)} bytes, {detected_content_type})")
else:
logger.warning(f"No data found for file: {filename}")
except Exception as part_error:
logger.warning(f"Failed to process file part: {part_error}")
continue
logger.info(f"Direct stream processing completed: {len(files)} files extracted")
return files
except Exception as e:
logger.error(f"Direct stream processing failed: {e}")
return []
def process_files_directly_from_request(request, logger):
"""
Process uploaded files directly from request data without using temp files.
This is a fallback when the system can't access temp directories.
"""
try:
from io import BytesIO
from werkzeug.datastructures import FileStorage
import re
# Try to extract files from the raw request data
# This is a simplified approach for small files
raw_data = request.get_data()
if not raw_data:
logger.warning("No raw data in request")
return []
# Look for multipart boundary
content_type = request.headers.get('Content-Type', '')
boundary_match = re.search(r'boundary=([^;]+)', content_type)
if not boundary_match:
logger.warning("No multipart boundary found")
return []
boundary = boundary_match.group(1).strip('"')
# Split by boundary
parts = raw_data.split(f'--{boundary}'.encode())
files = []
for part in parts:
if b'Content-Disposition: form-data' in part and b'filename=' in part:
try:
# Extract filename
filename_match = re.search(rb'filename="([^"]+)"', part)
if not filename_match:
continue
filename = filename_match.group(1).decode('utf-8')
# Find the file data (after the headers)
data_start = part.find(b'\r\n\r\n')
if data_start == -1:
continue
file_data = part[data_start + 4:]
# Remove trailing boundary markers
file_data = file_data.rstrip(b'\r\n-')
if len(file_data) > 0:
# Create a FileStorage-like object from the extracted data
file_obj = FileStorage(
stream=BytesIO(file_data),
filename=filename,
content_type='image/jpeg' # Default, will be validated later
)
files.append(file_obj)
logger.info(f"Extracted file: {filename} ({len(file_data)} bytes)")
except Exception as part_error:
logger.warning(f"Failed to process part: {part_error}")
continue
return files
except Exception as e:
logger.error(f"Direct file processing failed: {e}")
return []
# Asset upload utility functions - defined early for initialization
def setup_temp_directory():
"""Set up a temporary directory for file processing."""
try:
# Try to use the backend directory as temp space if system temp is not available
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up to backend/
temp_dir = os.path.join(base_dir, 'temp')
# Only try to create and use temp directory if we have write permissions
try:
os.makedirs(temp_dir, exist_ok=True)
# Test write permissions
test_file = os.path.join(temp_dir, 'test_write')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
# Set the TMPDIR environment variable to use our custom temp directory
os.environ['TMPDIR'] = temp_dir
tempfile.tempdir = temp_dir
return temp_dir
except (OSError, PermissionError):
# If we can't write to temp directory, return None to skip temp directory usage
print(f"Warning: Cannot write to temp directory {temp_dir}, will process files directly")
return None
except Exception as e:
print(f"Warning: Could not set up temp directory: {e}")
return None
focus_groups_bp = Blueprint('focus_groups', __name__)
# Initialize temp directory when the module is imported
try:
setup_temp_directory()
except Exception as e:
print(f"Warning: Could not initialize temp directory during module import: {e}")
# Request data cache for direct processing
request_data_cache = {}
# Temporarily disable this before_request handler due to Quart ASGI context issues
# @focus_groups_bp.before_request
def cache_multipart_data():
"""Cache multipart request data only when temp directories are unavailable."""
try:
from quart import request, g
# Safely check if we have an active request context
if not request:
return
# Safely check request properties - handle Quart/Flask differences
endpoint = getattr(request, 'endpoint', None)
method = getattr(request, 'method', None)
content_type = getattr(request, 'content_type', None)
# Only cache for asset upload endpoints when temp directory issues are expected
if (endpoint and 'upload_assets' in str(endpoint) and
method == 'POST' and
content_type and 'multipart/form-data' in content_type):
# Check if temp directory is available - if so, let Quart handle normally
temp_dir = setup_temp_directory()
if temp_dir:
# Temp directory is available, skip caching to allow normal processing
return
# Enable the rest of the caching logic if needed
# For now, just return to prevent context errors
return
else:
# Not an upload endpoint, skip processing
return
except (RuntimeError, AttributeError, Exception) as e:
# Handle "Working outside of request context" gracefully
# This can happen during startup or shutdown with ASGI
return
@focus_groups_bp.route('', methods=['GET'])
@focus_groups_bp.route('/', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_focus_groups():
import logging
logger = logging.getLogger('app.focus_groups')
try:
logger.debug("=== GET focus groups API called ===")
user_id = get_jwt_identity()
logger.debug(f"User ID from JWT: {user_id}")
# Always return all focus groups for now
logger.debug("Calling FocusGroup.get_all() to show all focus groups")
focus_groups = await FocusGroup.get_all()
logger.debug(f"Found {len(focus_groups)} total focus groups")
# Make focus groups serializable
logger.debug("Converting focus groups to serializable format")
serializable_groups = make_serializable(focus_groups)
logger.debug(f"Returning {len(serializable_groups)} serialized focus groups")
logger.debug(f"Sample focus group data: {serializable_groups[:1] if serializable_groups else 'None'}")
return jsonify(serializable_groups), 200
except Exception as e:
logger.error(f"Error in get_focus_groups: {e}")
logger.exception("Full exception traceback:")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/all', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_all_focus_groups():
try:
focus_groups = await FocusGroup.get_all()
# Make focus groups serializable
serializable_groups = make_serializable(focus_groups)
return jsonify(serializable_groups), 200
except Exception as e:
print(f"Error in get_all_focus_groups: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_focus_group(focus_group_id):
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Process participants count if needed
if 'participants' in focus_group and focus_group['participants'] and isinstance(focus_group['participants'], list):
focus_group['participants_count'] = len(focus_group['participants'])
# Expand participants data
if 'participants' in focus_group and focus_group['participants']:
participants_data = []
for persona_id in focus_group['participants']:
try:
persona = await Persona.find_by_id(persona_id)
if persona:
participants_data.append(persona)
except Exception as e:
print(f"Error fetching participant {persona_id}: {e}")
focus_group['participants_data'] = participants_data
# Make focus group serializable
serializable_group = make_serializable(focus_group)
return jsonify(serializable_group), 200
except Exception as e:
print(f"Error in get_focus_group: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('', methods=['POST'])
@focus_groups_bp.route('/', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def create_focus_group():
try:
user_id = get_jwt_identity()
# Use default user ID if not authenticated
if not user_id:
user_id = 'default_id'
data = await request.get_json()
if not data or not data.get('name'):
return jsonify({"message": "Missing required fields"}), 400
# Remove _id fields if present to avoid conflicts
if '_id' in data:
del data['_id']
if 'id' in data:
del data['id']
# Process participants if they're just IDs
if 'participants' in data and isinstance(data['participants'], list):
# Store participants count as a number
if 'participants_count' not in data:
data['participants_count'] = len(data['participants'])
focus_group_id = await FocusGroup.create(data, user_id)
# Get the created focus group to return
focus_group = await FocusGroup.find_by_id(focus_group_id)
return jsonify({
"message": "Focus group created successfully",
"focus_group_id": focus_group_id,
"focus_group": make_serializable(focus_group)
}), 201
except Exception as e:
print(f"Error creating focus group: {e}")
return jsonify({"message": f"Failed to create focus group: {str(e)}"}), 500
@focus_groups_bp.route('/<focus_group_id>/test-logging', methods=['GET'])
@jwt_required(optional=True)
def test_logging_endpoint(focus_group_id):
"""Test endpoint to verify Python logging is working"""
print(f"🧪 TEST ENDPOINT HIT: focus_group_id={focus_group_id}")
print(f"🧪 TEST: This should appear in server logs!")
return jsonify({"message": "Test endpoint reached", "focus_group_id": focus_group_id})
@focus_groups_bp.route('/<focus_group_id>', methods=['PUT'])
@jwt_required()
async def update_focus_group(focus_group_id):
import datetime
import os
# Force logging to a file to bypass any log redirection
try:
log_msg = f"🚀 [{datetime.datetime.now()}] FOCUS GROUP UPDATE: focus_group_id={focus_group_id}\n"
with open('/tmp/focus_group_debug.log', 'a') as f:
f.write(log_msg)
f.flush()
print(f"🚀 FOCUS GROUP UPDATE ENDPOINT HIT: focus_group_id={focus_group_id}")
except:
pass # Don't let logging errors break the endpoint
data = await request.get_json()
try:
log_msg = f"🔧 [{datetime.datetime.now()}] UPDATE DATA: {data}\n"
with open('/tmp/focus_group_debug.log', 'a') as f:
f.write(log_msg)
f.flush()
# Removed verbose data logging to reduce log noise
# print(f"🔧 FOCUS GROUP UPDATE DATA: {data}")
except:
pass
# Debug logging for model updates
if data and 'llm_model' in data:
try:
log_msg = f"🔧 [{datetime.datetime.now()}] LLM MODEL UPDATE: {data['llm_model']} for {focus_group_id}\n"
with open('/tmp/focus_group_debug.log', 'a') as f:
f.write(log_msg)
f.flush()
print(f"🔧 FOCUS GROUP API UPDATE: Received llm_model='{data['llm_model']}' for focus group {focus_group_id}")
except:
pass
if not data:
return jsonify({"message": "No data provided"}), 400
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
success = await FocusGroup.update(focus_group_id, data)
if success:
return jsonify({"message": "Focus group updated successfully"}), 200
else:
return jsonify({"message": "Failed to update focus group"}), 500
@focus_groups_bp.route('/<focus_group_id>', methods=['DELETE'])
@jwt_required()
async def delete_focus_group(focus_group_id):
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
success = await FocusGroup.delete(focus_group_id)
if success:
return jsonify({"message": "Focus group deleted successfully"}), 200
else:
return jsonify({"message": "Failed to delete focus group"}), 500
@focus_groups_bp.route('/<focus_group_id>/participants', methods=['POST'])
@jwt_required()
async def add_participant(focus_group_id):
data = await request.get_json()
if not data or not data.get('persona_id'):
return jsonify({"message": "Missing persona_id"}), 400
persona_id = data.get('persona_id')
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Verify persona exists
persona = await Persona.find_by_id(persona_id)
if not persona:
return jsonify({"message": "Persona not found"}), 404
success = await FocusGroup.add_participant(focus_group_id, persona_id)
if success:
return jsonify({"message": "Participant added successfully"}), 200
else:
return jsonify({"message": "Failed to add participant"}), 500
@focus_groups_bp.route('/<focus_group_id>/participants/<persona_id>', methods=['DELETE'])
@jwt_required()
async def remove_participant(focus_group_id, persona_id):
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
success = await FocusGroup.remove_participant(focus_group_id, persona_id)
if success:
return jsonify({"message": "Participant removed successfully"}), 200
else:
return jsonify({"message": "Failed to remove participant"}), 500
@focus_groups_bp.route('/<focus_group_id>/messages', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_focus_group_messages(focus_group_id):
"""Get all messages for a focus group, including mode events."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Get messages and mode events
messages = await FocusGroup.get_messages(focus_group_id)
mode_events = await FocusGroup.get_mode_events(focus_group_id)
# 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({
"messages": serializable_messages,
"mode_events": serializable_mode_events
}), 200
except Exception as e:
print(f"Error in get_focus_group_messages: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/messages', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def add_focus_group_message(focus_group_id):
"""Add a new message to a focus group."""
try:
# Get message data from request
data = await request.get_json()
if not data or not data.get('text'):
return jsonify({"message": "Missing required fields"}), 400
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# 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 = await 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
# Extract asset filename from message text
message_text = data.get('text', '')
asset_filename = extract_asset_filename_from_content(message_text)
if asset_filename:
# Add visual context information to the message data
data['attached_assets'] = [asset_filename]
data['activates_visual_context'] = True
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
try:
success = await FocusGroup._activate_visual_assets(focus_group_id, [asset_filename], None)
if success:
print(f"✅ VISUAL CONTEXT ACTIVATED: {asset_filename}")
else:
print(f"⚠️ Failed to activate visual context for: {asset_filename}")
except Exception as activation_error:
print(f"⚠️ Error activating visual context: {activation_error}")
except Exception as e:
print(f"⚠️ Error checking for facilitator creative asset: {e}")
# Debug: Log all message data for manual position setting
if data.get('senderId') == 'moderator' and data.get('type') == 'question':
print(f"🔍 MODERATOR MESSAGE DEBUG:")
print(f" - Message text: {data.get('text', '')}")
print(f" - Attached assets: {data.get('attached_assets', [])}")
print(f" - Activates visual context: {data.get('activates_visual_context', False)}")
# Add message
message_id = await FocusGroup.add_message(focus_group_id, data)
if not message_id:
return jsonify({"message": "Failed to add message"}), 500
return jsonify({
"message": "Message added successfully",
"message_id": message_id
}), 201
except Exception as e:
print(f"Error in add_focus_group_message: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/messages/<message_id>', methods=['PATCH'])
@jwt_required(optional=True) # Make JWT optional for development
async def update_focus_group_message(focus_group_id, message_id):
"""Update a message in a focus group, currently only for highlighted status."""
try:
# Get message data from request
data = await request.get_json()
if data is None or 'highlighted' not in data:
return jsonify({"message": "Missing highlighted field"}), 400
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Update message highlight status
success = await FocusGroup.update_message_highlight(
focus_group_id,
message_id,
data['highlighted']
)
if not success:
return jsonify({"message": "Failed to update message highlight status"}), 500
return jsonify({
"message": "Message highlight status updated successfully"
}), 200
except Exception as e:
print(f"Error in update_focus_group_message: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/notes', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_focus_group_notes(focus_group_id):
"""Get all notes for a focus group."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Get notes
notes = await FocusGroup.get_notes(focus_group_id)
# Make notes serializable
serializable_notes = make_serializable(notes)
return jsonify(serializable_notes), 200
except Exception as e:
print(f"Error in get_focus_group_notes: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/notes', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def add_focus_group_note(focus_group_id):
"""Add a new note to a focus group."""
try:
# Get note data from request
data = await request.get_json()
if not data or not data.get('content'):
return jsonify({"message": "Missing required fields"}), 400
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Add note
note_id = await FocusGroup.add_note(focus_group_id, data)
if not note_id:
return jsonify({"message": "Failed to add note"}), 500
# Get the created note to return
notes = await FocusGroup.get_notes(focus_group_id)
created_note = None
for note in notes:
if str(note.get('_id', '')) == str(note_id):
created_note = note
break
return jsonify({
"message": "Note added successfully",
"note_id": note_id,
"note": make_serializable(created_note) if created_note else None
}), 201
except Exception as e:
print(f"Error in add_focus_group_note: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/notes/<note_id>', methods=['DELETE'])
@jwt_required(optional=True) # Make JWT optional for development
async def delete_focus_group_note(focus_group_id, note_id):
"""Delete a note from a focus group."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"message": "Focus group not found"}), 404
# Delete note
success = await FocusGroup.delete_note(focus_group_id, note_id)
if not success:
return jsonify({"message": "Failed to delete note"}), 500
return jsonify({
"message": "Note deleted successfully"
}), 200
except Exception as e:
print(f"Error in delete_focus_group_note: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/generate-discussion-guide', methods=['POST'])
@focus_groups_bp.route('/<focus_group_id>/generate-discussion-guide', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def generate_discussion_guide(focus_group_id=None):
"""Generate a discussion guide for a focus group using the LLM service."""
import logging
logger = logging.getLogger(__name__)
# Log the start of the request
logger.info("Discussion guide generation request received")
try:
# Get request data
data = await request.get_json()
if not data:
logger.warning("Discussion guide generation failed: Missing request data")
return jsonify({
"error": "Missing request data",
"details": "Request body is required",
"can_retry": False
}), 400
# Check for required fields
required_fields = ['name', 'description', 'objective', 'topic']
missing_fields = [field for field in required_fields if field not in data or not data[field]]
if missing_fields:
error_msg = f"Missing required fields: {', '.join(missing_fields)}"
logger.warning(f"Discussion guide generation failed: {error_msg}")
return jsonify({
"error": error_msg,
"details": "Please fill in all required fields",
"missing_fields": missing_fields,
"can_retry": False
}), 400
# Extract data for guide generation
focus_group_name = data['name']
research_brief = f"{data['description']}\n\nResearch Objective: {data['objective']}"
discussion_topics = data['topic']
duration = data.get('duration', 60)
logger.info(f"Generating discussion guide for: '{focus_group_name}' (duration: {duration}min)")
# Get user_id for task tracking (optional for development mode)
user_id = None
try:
user_id = get_jwt_identity()
except:
pass # JWT is optional in development
# Register current task for cancellation
async with CancellableTask("discussion_guide_generation", user_id, {"focus_group_name": focus_group_name, "focus_group_id": focus_group_id}) as task_id:
# Emit task_started event via WebSocket for immediate frontend tracking
from app.websocket_manager_async import get_async_websocket_manager
websocket_manager = get_async_websocket_manager()
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_started',
{
'task_id': task_id,
'task_type': 'discussion_guide_generation',
'message': f'Started generating discussion guide for {focus_group_name}'
}
)
# Add topic as a discussion topic if not already there
if discussion_topics and isinstance(discussion_topics, str):
# Convert to a specific topic if it's from the selection dropdown
topic_mapping = {
'product-feedback': 'Product Feedback',
'creative-testing': 'Creative Testing',
'messaging-evaluation': 'Messaging Evaluation',
'user-experience': 'User Experience',
'market-research': 'Market Research'
}
formatted_topic = topic_mapping.get(discussion_topics, discussion_topics)
else:
formatted_topic = 'General Discussion'
# Get the LLM model for this focus group if it exists
llm_model = None
if focus_group_id:
try:
focus_group = await FocusGroup.find_by_id(focus_group_id)
if focus_group:
llm_model = focus_group.get('llm_model')
logger.info(f"Using LLM model for focus group {focus_group_id}: {llm_model}")
except Exception as e:
logger.warning(f"Could not retrieve LLM model for focus group {focus_group_id}: {e}")
# Use default model from request data if provided
if not llm_model:
llm_model = data.get('llm_model')
# Generate the discussion guide
discussion_guide = await FocusGroupService.generate_discussion_guide(
focus_group_name=focus_group_name,
research_brief=research_brief,
discussion_topics=formatted_topic,
duration=duration,
temperature=0.7,
focus_group_id=focus_group_id,
llm_model=llm_model
)
# Generate one-line summary for list view display
summary = None
try:
from app.services.focus_group_summary_service import generate_focus_group_summary
summary_data = {
'name': focus_group_name,
'topic': formatted_topic,
'duration': duration,
'description': data.get('description', ''),
'discussionGuide': discussion_guide
}
summary = await generate_focus_group_summary(
summary_data,
llm_model=llm_model
)
# Save summary to focus group if we have an ID
if focus_group_id and summary:
await FocusGroup.update(focus_group_id, {'summary': summary})
logger.info(f"Saved summary for focus group {focus_group_id}: {summary}")
except Exception as summary_error:
logger.warning(f"Failed to generate summary (non-critical): {summary_error}")
# Don't fail the request - summary is optional
# Emit completion event via WebSocket
if user_id:
await websocket_manager.emit_to_user(
user_id,
'task_completed',
{
'task_id': task_id,
'task_type': 'discussion_guide_generation',
'message': f'Successfully generated discussion guide for {focus_group_name}'
}
)
logger.info(f"Discussion guide successfully generated for '{focus_group_name}'")
return jsonify({
"message": "Discussion guide generated successfully",
"discussionGuide": discussion_guide,
"summary": summary, # Include the generated summary
"success": True,
"task_id": task_id
}), 200
except asyncio.CancelledError:
logger.info(f"Discussion guide generation cancelled for focus group: {data.get('name', 'Unknown') if 'data' in locals() else 'Unknown'}")
return jsonify({
"error": "Generation cancelled",
"details": "Discussion guide generation was cancelled by user",
"can_retry": True,
"error_type": "cancelled"
}), 499
except Exception as e:
error_msg = str(e)
logger.error(f"Discussion guide generation failed with error: {error_msg}")
# Categorize errors for better user experience
if "prompt" in error_msg.lower() or "template" in error_msg.lower():
# Prompt/template related error
return jsonify({
"error": "System configuration error",
"details": "Discussion guide template is not configured properly. Please contact support.",
"technical_details": error_msg,
"can_retry": False,
"error_type": "configuration"
}), 500
elif "failed after" in error_msg and "attempts" in error_msg:
# All retry attempts exhausted
return jsonify({
"error": "AI generation temporarily unavailable",
"details": "The discussion guide generator failed after multiple attempts. This is usually temporary - please try again in a few minutes.",
"technical_details": error_msg,
"can_retry": True,
"error_type": "generation_failed",
"suggestion": "Wait a few minutes and try again"
}), 500
elif "timeout" in error_msg.lower() or "connection" in error_msg.lower():
# Network/timeout related error
return jsonify({
"error": "Service temporarily unavailable",
"details": "Connection to the AI service timed out. Please try again.",
"technical_details": error_msg,
"can_retry": True,
"error_type": "timeout",
"suggestion": "Try again immediately"
}), 500
else:
# Generic error
return jsonify({
"error": "Discussion guide generation failed",
"details": "An unexpected error occurred during generation. Please try again.",
"technical_details": error_msg,
"can_retry": True,
"error_type": "unknown",
"suggestion": "Try again or contact support if the problem persists"
}), 500
def convert_discussion_guide_to_markdown(discussion_guide, focus_group_name=None):
"""
Convert a discussion guide to markdown format.
Handles both structured (JSON) and legacy (string) formats.
"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Handle legacy string format
if isinstance(discussion_guide, str):
title = f"Discussion Guide: {focus_group_name}" if focus_group_name else "Discussion Guide"
return f"""# {title}
**Generated:** {timestamp}
**Format:** Legacy Text Format
---
{discussion_guide}
---
*Exported from Semblance Synthetic Society*"""
# Handle structured format
if isinstance(discussion_guide, dict):
title = f"Discussion Guide: {focus_group_name}" if focus_group_name else discussion_guide.get('title', 'Discussion Guide')
total_duration = discussion_guide.get('total_duration', 'Unknown')
markdown = f"""# {title}
**Duration:** {total_duration} minutes
**Generated:** {timestamp}
---
"""
# Process sections
sections = discussion_guide.get('sections', [])
for section_index, section in enumerate(sections):
section_title = section.get('title', f'Section {section_index + 1}')
markdown += f"## Section {section_index + 1}: {section_title}\n\n"
# Add section content/description
if section.get('content'):
markdown += f"*{section['content']}*\n\n"
# Add activities
activities = section.get('activities', [])
if activities:
markdown += "### Activities\n\n"
for activity_index, activity in enumerate(activities):
markdown += format_discussion_item_markdown(activity, activity_index + 1, 'Activity')
markdown += "\n"
# Add questions
questions = section.get('questions', [])
if questions:
markdown += "### Questions\n\n"
for question_index, question in enumerate(questions):
markdown += format_discussion_item_markdown(question, question_index + 1, 'Question')
markdown += "\n"
# Add subsections
subsections = section.get('subsections', [])
if subsections:
for subsection_index, subsection in enumerate(subsections):
subsection_title = subsection.get('title', f'Subsection {subsection_index + 1}')
markdown += f"### Subsection {subsection_index + 1}: {subsection_title}\n\n"
# Subsection activities
sub_activities = subsection.get('activities', [])
if sub_activities:
markdown += "#### Activities\n\n"
for activity_index, activity in enumerate(sub_activities):
markdown += format_discussion_item_markdown(activity, activity_index + 1, 'Activity')
markdown += "\n"
# Subsection questions
sub_questions = subsection.get('questions', [])
if sub_questions:
markdown += "#### Questions\n\n"
for question_index, question in enumerate(sub_questions):
markdown += format_discussion_item_markdown(question, question_index + 1, 'Question')
markdown += "\n"
markdown += "---\n\n"
markdown += "*Exported from Semblance Synthetic Society*"
return markdown
# Fallback for unknown format
return f"""# Discussion Guide
**Generated:** {timestamp}
---
Unable to parse discussion guide format.
Raw content:
{str(discussion_guide)}
---
*Exported from Semblance Synthetic Society*"""
def format_discussion_item_markdown(item, index, item_type):
"""Format a discussion item (question or activity) as markdown."""
item_type_display = item.get('type', 'unknown').replace('_', ' ').title()
content = item.get('content', '')
time_limit = item.get('time_limit')
markdown = f"{index}. **{item_type_display}**"
if time_limit:
markdown += f" *({time_limit} min)*"
markdown += f"\n {content}\n"
# Add probe questions for questions
if item_type == 'Question' and item.get('probes'):
markdown += "\n **Probe Questions:**\n"
for probe in item['probes']:
markdown += f" - {probe}\n"
markdown += "\n"
return markdown
def generate_discussion_guide_filename(focus_group_name=None, guide_title=None):
"""Generate a filename for the discussion guide download."""
date = datetime.datetime.now().strftime("%Y-%m-%d")
base_name = 'discussion-guide'
if focus_group_name:
# Sanitize focus group name for filename
sanitized_name = ''.join(c for c in focus_group_name if c.isalnum() or c in (' ', '-', '_')).rstrip()
sanitized_name = sanitized_name.replace(' ', '-').lower()
base_name = f"discussion-guide-{sanitized_name}"
elif guide_title:
# Fallback to guide title
sanitized_title = ''.join(c for c in guide_title if c.isalnum() or c in (' ', '-', '_')).rstrip()
sanitized_title = sanitized_title.replace(' ', '-').lower()
base_name = f"discussion-guide-{sanitized_title}"
return f"{base_name}-{date}.md"
@focus_groups_bp.route('/<focus_group_id>/discussion-guide/download', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def download_discussion_guide(focus_group_id):
"""
Download the discussion guide for a focus group as a markdown file.
Returns:
A markdown file download of the discussion guide
"""
import logging
logger = logging.getLogger('app.focus_groups')
try:
logger.debug(f"=== DOWNLOAD DISCUSSION GUIDE API called for focus group {focus_group_id} ===")
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
logger.warning(f"Focus group not found: {focus_group_id}")
return jsonify({"error": "Focus group not found"}), 404
focus_group_name = focus_group.get('name', 'Unnamed Focus Group')
logger.info(f"Focus group found: {focus_group_name}")
# Get discussion guide
discussion_guide = focus_group.get('discussionGuide')
if not discussion_guide:
logger.warning(f"No discussion guide found for focus group {focus_group_id}")
return jsonify({"error": "No discussion guide found for this focus group"}), 404
logger.info(f"Discussion guide found, type: {type(discussion_guide)}")
# Convert to markdown
try:
markdown_content = convert_discussion_guide_to_markdown(discussion_guide, focus_group_name)
logger.info(f"Discussion guide converted to markdown, length: {len(markdown_content)} characters")
except Exception as e:
logger.error(f"Failed to convert discussion guide to markdown: {str(e)}")
return jsonify({"error": "Failed to convert discussion guide to markdown"}), 500
# Generate filename
filename = generate_discussion_guide_filename(focus_group_name)
logger.info(f"Generated filename: {filename}")
# Create response with markdown content
response = Response(
markdown_content,
mimetype='text/markdown',
headers={
'Content-Disposition': f'attachment; filename="{filename}"',
'Content-Type': 'text/markdown; charset=utf-8'
}
)
logger.info(f"✅ DOWNLOAD DISCUSSION GUIDE API completed successfully")
return response
except Exception as e:
logger.error(f"Error in download_discussion_guide: {str(e)}")
logger.exception("Full exception traceback:")
return jsonify({
"error": "Failed to download discussion guide",
"message": str(e)
}), 500
# Additional asset upload utility functions
def get_upload_folder(focus_group_id):
"""Get the upload folder path for a focus group."""
# Use absolute path to avoid working directory issues
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) # Go up to backend/
upload_dir = os.path.join(base_dir, 'uploads', f'focus-group-{focus_group_id}')
return upload_dir
def ensure_upload_folder(focus_group_id):
"""Ensure the upload folder exists for a focus group."""
upload_dir = get_upload_folder(focus_group_id)
# Try to create subdirectory, but fall back to flat storage if filesystem is read-only
try:
os.makedirs(upload_dir, exist_ok=True)
return upload_dir
except (OSError, PermissionError) as e:
print(f"Warning: Cannot create subdirectory {upload_dir}: {e}")
print("Falling back to flat file storage in main uploads directory")
# Use main uploads directory instead
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
main_upload_dir = os.path.join(base_dir, 'uploads')
# Test if main directory is writable
if os.path.isdir(main_upload_dir) and os.access(main_upload_dir, os.W_OK):
return main_upload_dir
else:
raise OSError(f"Main uploads directory is not writable: {main_upload_dir}")
def is_allowed_file(filename, allowed_extensions={'jpg', 'jpeg', 'png'}):
"""Check if file has an allowed extension."""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
def validate_image_file(file):
"""Validate uploaded image file."""
if not file:
return False, "No file provided"
if file.filename == '':
return False, "No file selected"
if not is_allowed_file(file.filename):
return False, "File type not allowed. Only JPG, JPEG, and PNG files are permitted"
# Check file size (10MB limit) by reading the content length from the file stream
try:
# Store current position
current_pos = file.tell()
# Seek to end to get size
file.seek(0, os.SEEK_END)
file_size = file.tell()
# Reset to original position
file.seek(current_pos)
if file_size > 10 * 1024 * 1024: # 10MB in bytes
return False, "File size exceeds 10MB limit"
except Exception as e:
# If we can't check size, allow it to proceed but log the issue
print(f"Warning: Could not validate file size: {e}")
return True, "Valid file"
def save_uploaded_file_directly(file, file_path):
"""Save uploaded file directly to avoid temporary file issues."""
try:
# Create the directory if it doesn't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# Read file content in chunks and write directly
with open(file_path, 'wb') as f:
file.seek(0) # Ensure we're at the beginning
while True:
chunk = file.read(8192) # Read in 8KB chunks
if not chunk:
break
f.write(chunk)
return True
except Exception as e:
print(f"Error saving file directly: {e}")
return False
@focus_groups_bp.route('/<focus_group_id>/assets', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def upload_assets(focus_group_id):
"""Upload creative assets for a focus group."""
import logging
logger = logging.getLogger('app.focus_groups')
try:
logger.debug(f"=== UPLOAD ASSETS API called for focus group {focus_group_id} ===")
# Check for replace flag (Quart async form access)
form_data = await request.form
replace_existing = form_data.get('replace', '').lower() == 'true'
logger.info(f"Replace existing assets flag: {replace_existing}")
# Set up temporary directory for file processing (optional)
temp_dir = setup_temp_directory()
if temp_dir:
logger.info(f"Using custom temporary directory: {temp_dir}")
else:
logger.info("No temp directory available, processing files directly")
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
logger.warning(f"Focus group not found: {focus_group_id}")
return jsonify({"error": "Focus group not found"}), 404
# If replace flag is set, clear existing assets first
if replace_existing:
logger.info("Replace flag set - clearing existing assets")
existing_assets = focus_group.get('uploaded_assets', [])
if existing_assets:
logger.info(f"Deleting {len(existing_assets)} existing assets")
# Delete existing files from disk
for existing_asset in existing_assets:
filename = existing_asset.get('filename')
if filename:
# Try to delete from both possible locations
file_deleted = False
# Try subdirectory location first
upload_dir = get_upload_folder(focus_group_id)
subdirectory_path = os.path.join(upload_dir, filename)
if os.path.exists(subdirectory_path):
try:
os.remove(subdirectory_path)
file_deleted = True
logger.info(f"Deleted existing asset from subdirectory: {filename}")
except Exception as e:
logger.warning(f"Failed to delete {filename} from subdirectory: {e}")
# Try flat storage location
if not file_deleted:
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
main_upload_dir = os.path.join(base_dir, 'uploads')
flat_path = os.path.join(main_upload_dir, filename)
if os.path.exists(flat_path):
try:
os.remove(flat_path)
file_deleted = True
logger.info(f"Deleted existing asset from main uploads: {filename}")
except Exception as e:
logger.warning(f"Failed to delete {filename} from main uploads: {e}")
if not file_deleted:
logger.warning(f"Could not find or delete existing asset file: {filename}")
# Clear assets from database
success = await FocusGroup.clear_uploaded_assets(focus_group_id)
if success:
logger.info("Successfully cleared existing assets from database")
else:
logger.error("Failed to clear existing assets from database")
return jsonify({"error": "Failed to clear existing assets"}), 500
# Try standard Flask file processing first (since temp directories are now working)
files = None
flask_processing_failed = False
# Debug request information
logger.info(f"Request content type: {request.content_type}")
logger.info(f"Request content length: {request.content_length}")
logger.info(f"Request method: {request.method}")
# Get files using Quart async pattern
files_data = await request.files
logger.info(f"Request files keys: {list(files_data.keys())}")
try:
if 'assets' not in files_data:
logger.warning(f"No 'assets' key in request.files. Available keys: {list(files_data.keys())}")
return jsonify({"error": "No files provided"}), 400
files = files_data.getlist('assets')
if not files or all(f.filename == '' for f in files):
logger.warning("No files selected")
return jsonify({"error": "No files selected"}), 400
logger.info(f"Successfully got {len(files)} files via standard Flask processing")
except Exception as file_access_error:
logger.warning(f"Standard Flask file processing failed: {file_access_error}")
flask_processing_failed = True
files = None
# Fallback to direct processing if Flask processing failed
if files is None and flask_processing_failed:
logger.info("Attempting direct file processing as fallback...")
try:
if request.content_type and 'multipart/form-data' in request.content_type:
files = process_files_directly_from_request_stream(request, logger)
if files:
logger.info(f"Successfully extracted {len(files)} files via direct processing")
else:
logger.warning("Direct processing found no files")
except Exception as direct_error:
logger.error(f"Direct file processing also failed: {direct_error}")
return jsonify({
"error": "File upload temporarily unavailable",
"details": "Server configuration issue with file processing. Please try uploading smaller files or contact support.",
"code": "TEMP_DIR_ERROR",
"can_retry": True
}), 503
# Validate that we have files to process
if not files:
return jsonify({"error": "No valid files to process"}), 400
# Ensure upload directory exists
upload_dir = ensure_upload_folder(focus_group_id)
logger.info(f"Upload directory: {upload_dir}")
uploaded_assets = []
errors = []
for file in files:
try:
# Validate file
is_valid, error_message = validate_image_file(file)
if not is_valid:
errors.append(f"{file.filename}: {error_message}")
continue
# Generate unique filename with focus group prefix
original_filename = secure_filename(file.filename)
file_extension = original_filename.rsplit('.', 1)[1].lower()
unique_filename = f"fg-{focus_group_id}-{uuid.uuid4().hex}.{file_extension}"
# Save file using direct method to avoid temp directory issues
file_path = os.path.join(upload_dir, unique_filename)
# Try direct save first
if not save_uploaded_file_directly(file, file_path):
# Fallback to standard save method (Quart async version)
try:
await file.save(file_path)
except Exception as save_error:
logger.error(f"Both direct and standard file save failed: {save_error}")
errors.append(f"{file.filename}: Save failed - {str(save_error)}")
continue
# Get file info
file_size = os.path.getsize(file_path)
# Create asset metadata
asset_metadata = {
"filename": unique_filename,
"original_name": original_filename,
"size": file_size,
"mime_type": file.mimetype or f"image/{file_extension}",
"upload_date": datetime.datetime.utcnow(),
"file_path": file_path
}
uploaded_assets.append(asset_metadata)
logger.info(f"Successfully uploaded: {original_filename} -> {unique_filename}")
except Exception as e:
error_msg = f"{file.filename}: Upload failed - {str(e)}"
errors.append(error_msg)
logger.error(error_msg)
if not uploaded_assets and errors:
return jsonify({
"error": "All file uploads failed",
"details": errors
}), 400
# Update focus group with asset metadata
if uploaded_assets:
logger.info(f"Updating focus group {focus_group_id} with {len(uploaded_assets)} assets")
logger.info(f"Asset metadata to save: {uploaded_assets}")
success = await FocusGroup.add_uploaded_assets(focus_group_id, uploaded_assets)
logger.info(f"Database update success: {success}")
if not success:
logger.error(f"Failed to save asset metadata to database for focus group {focus_group_id}")
# Cleanup uploaded files if database update failed
for asset in uploaded_assets:
try:
if os.path.exists(asset["file_path"]):
os.remove(asset["file_path"])
except:
pass
return jsonify({"error": "Failed to update focus group with asset metadata"}), 500
else:
logger.info(f"Successfully saved asset metadata to database")
# DEBUG: Verify the data was saved by reading it back
try:
verification_assets = await FocusGroup.get_uploaded_assets(focus_group_id)
logger.info(f"Verification: Found {len(verification_assets)} assets after save")
logger.info(f"Verification asset data: {verification_assets}")
except Exception as verify_error:
logger.error(f"Verification failed: {verify_error}")
response_data = {
"message": f"Successfully uploaded {len(uploaded_assets)} asset(s)",
"uploaded_assets": len(uploaded_assets),
"assets": [
{
"filename": asset["filename"],
"original_name": asset["original_name"],
"size": asset["size"],
"mime_type": asset["mime_type"]
}
for asset in uploaded_assets
]
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" ({len(errors)} failed)"
logger.info(f"✅ UPLOAD ASSETS API completed successfully - {len(uploaded_assets)} assets uploaded")
return jsonify(response_data), 201
except Exception as e:
logger.error(f"Error in upload_assets: {str(e)}")
logger.exception("Full exception traceback:")
return jsonify({
"error": "Failed to upload assets",
"message": str(e)
}), 500
@focus_groups_bp.route('/<focus_group_id>/assets', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def get_assets(focus_group_id):
"""Get list of uploaded assets for a focus group."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Get assets from focus group data
assets = focus_group.get('uploaded_assets', [])
# Return serializable asset data (exclude file_path for security)
asset_list = []
for asset in assets:
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")
}
asset_list.append(make_serializable(asset_info))
return jsonify({
"assets": asset_list,
"count": len(asset_list)
}), 200
except Exception as e:
print(f"Error in get_assets: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/assets/<filename>', methods=['GET'])
@jwt_required(optional=True) # Make JWT optional for development
async def serve_asset(focus_group_id, filename):
"""Serve an uploaded asset file."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Verify asset exists in focus group metadata
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
# Get file path - check both subdirectory and flat storage locations
file_path = None
# First try subdirectory location
upload_dir = get_upload_folder(focus_group_id)
subdirectory_path = os.path.join(upload_dir, filename)
if os.path.isfile(subdirectory_path):
file_path = subdirectory_path
else:
# Try flat storage location (main uploads directory)
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
main_upload_dir = os.path.join(base_dir, 'uploads')
flat_path = os.path.join(main_upload_dir, filename)
if os.path.isfile(flat_path):
file_path = flat_path
# Check if file exists
if not file_path or not os.path.exists(file_path):
return jsonify({"error": "Asset file not found on disk"}), 404
# Serve the file (Quart uses attachment_filename instead of download_name)
return await send_file(
file_path,
mimetype=asset.get('mime_type', 'image/jpeg'),
as_attachment=False,
attachment_filename=asset.get('original_name', filename)
)
except Exception as e:
print(f"Error in serve_asset: {e}")
return jsonify({"error": str(e)}), 500
@focus_groups_bp.route('/<focus_group_id>/assets/<filename>', methods=['DELETE'])
@jwt_required(optional=True) # Make JWT optional for development
async def delete_asset(focus_group_id, filename):
"""Delete an uploaded asset."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Remove asset from focus group metadata
success = await FocusGroup.remove_uploaded_asset(focus_group_id, filename)
if not success:
return jsonify({"error": "Failed to update focus group metadata"}), 500
# Remove file from disk - check both locations
file_deleted = False
# Try subdirectory location first
upload_dir = get_upload_folder(focus_group_id)
subdirectory_path = os.path.join(upload_dir, filename)
if os.path.exists(subdirectory_path):
os.remove(subdirectory_path)
file_deleted = True
# Try flat storage location
if not file_deleted:
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
main_upload_dir = os.path.join(base_dir, 'uploads')
flat_path = os.path.join(main_upload_dir, filename)
if os.path.exists(flat_path):
os.remove(flat_path)
file_deleted = True
return jsonify({"message": "Asset deleted successfully"}), 200
except Exception as e:
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
async def update_asset_name(focus_group_id, filename):
"""Update the user assigned name for an uploaded asset."""
try:
# Verify focus group exists
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
return jsonify({"error": "Focus group not found"}), 404
# Get request data
data = await 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 = await 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):
"""Test endpoint to verify routing is working."""
print(f"🔍 TEST ENDPOINT: Called for focus group {focus_group_id}")
return jsonify({"message": "Test endpoint reached", "focus_group_id": focus_group_id}), 200
@focus_groups_bp.route('/<focus_group_id>/test-websocket', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
def test_websocket_emission(focus_group_id):
"""GPT-5 Sanity Check: Test WebSocket emission end-to-end."""
from app.models.focus_group import emit_websocket_event
print(f"🔧 GPT-5 TEST: Testing WebSocket emission for focus group {focus_group_id}")
# Test simple message emission as GPT-5 suggested
emit_websocket_event("message_update", focus_group_id, {
"id": "test-ping-" + str(uuid.uuid4())[:8],
"text": "🔧 GPT-5 Test Ping",
"sender": {"name": "Test System"},
"timestamp": datetime.datetime.utcnow().isoformat()
})
return jsonify({
"message": "GPT-5 WebSocket test emission sent",
"focus_group_id": focus_group_id,
"event": "message_update"
}), 200
@focus_groups_bp.route('/<focus_group_id>/describe-asset', methods=['POST'])
@jwt_required(optional=True) # Make JWT optional for development
async def describe_asset(focus_group_id):
"""Generate AI description of an asset for enhanced creative review questions."""
print(f"🔍 API ENDPOINT: describe-asset called for focus group {focus_group_id}")
try:
# Verify focus group exists
print(f"🔍 API: Looking up focus group {focus_group_id}")
focus_group = await FocusGroup.find_by_id(focus_group_id)
if not focus_group:
print(f"❌ API: Focus group {focus_group_id} not found")
return jsonify({"error": "Focus group not found"}), 404
print(f"✅ API: Focus group {focus_group_id} found")
# Get asset filename from request
data = await request.get_json()
print(f"🔍 API: Request data: {data}")
if not data or 'asset_filename' not in data:
print(f"❌ API: Missing asset_filename in request")
return jsonify({"error": "Missing asset_filename in request"}), 400
asset_filename = data['asset_filename']
print(f"🔍 API: Asset filename: {asset_filename}")
print(f"🎨 API: Generating description for asset {asset_filename} in focus group {focus_group_id}")
# Generate AI description
try:
description = await ImageDescriptionService.generate_description(focus_group_id, asset_filename)
return jsonify({
"message": "Asset description generated successfully",
"asset_filename": asset_filename,
"description": description
}), 200
except ImageDescriptionError as e:
error_msg = f"Failed to generate description: {str(e)}"
print(f"❌ API: {error_msg}")
return jsonify({
"error": error_msg,
"asset_filename": asset_filename,
"fallback": True
}), 422 # Unprocessable Entity - client should fallback to original text
except Exception as e:
print(f"Error in describe_asset: {e}")
return jsonify({"error": str(e)}), 500