from bson import ObjectId from app.db import get_db from datetime import datetime, timezone import uuid import os import logging # Set up logger for this module logger = logging.getLogger(__name__) async def emit_websocket_event(event_name: str, focus_group_id: str, data: dict): """Helper function to emit WebSocket events using async WebSocket manager.""" from app.websocket_manager_async import emit_websocket_event as async_emit try: if event_name == 'message_update': event_data = { 'focus_group_id': focus_group_id, 'timestamp': datetime.now(timezone.utc).isoformat(), 'message': data } elif event_name == 'ai_status_update': event_data = { 'focus_group_id': focus_group_id, 'timestamp': datetime.now(timezone.utc).isoformat(), 'status': data } else: event_data = { 'focus_group_id': focus_group_id, 'timestamp': datetime.now(timezone.utc).isoformat(), **data } await async_emit(event_name, event_data, focus_group_id) except Exception as e: logger.error(f"Error emitting WebSocket event {event_name}: {e}") class FocusGroup: @staticmethod async def create(focus_group_data, user_id): db = await get_db() # Add metadata focus_group_data["created_at"] = datetime.now(timezone.utc) focus_group_data["created_by"] = user_id # Only set default status if not provided if "status" not in focus_group_data: focus_group_data["status"] = "new" # Set default LLM model if not provided if "llm_model" not in focus_group_data: focus_group_data["llm_model"] = "gemini-3-pro-preview" # Set default GPT-5 parameters if not provided if "reasoning_effort" not in focus_group_data: focus_group_data["reasoning_effort"] = "medium" if "verbosity" not in focus_group_data: focus_group_data["verbosity"] = "medium" result = await db.focus_groups.insert_one(focus_group_data) return str(result.inserted_id) @staticmethod async def find_by_id(focus_group_id): db = await get_db() try: focus_group = await db.focus_groups.find_one({"_id": ObjectId(focus_group_id)}) if focus_group: focus_group["_id"] = str(focus_group["_id"]) return focus_group except Exception as e: import logging as _logging _logging.getLogger(__name__).error(f"Error in find_by_id: {e}") return None @staticmethod async def find_by_user(user_id, limit=50): db = await get_db() cursor = db.focus_groups.find({"created_by": user_id}).sort("created_at", -1).limit(limit) focus_groups = await cursor.to_list(length=limit) result = [] for group in focus_groups: group["_id"] = str(group["_id"]) result.append(group) return result @staticmethod async def get_all(user_id=None, limit=50): try: db = await get_db() query = {"created_by": user_id} if user_id else {} cursor = db.focus_groups.find(query).sort("created_at", -1).limit(limit) focus_groups = await cursor.to_list(length=limit) result = [] for group in focus_groups: group["_id"] = str(group["_id"]) result.append(group) return result except Exception as e: logger.error(f"Error in FocusGroup.get_all: {e}") return [] @staticmethod async def update(focus_group_id, data, user_id=None): db = await get_db() # Remove fields that shouldn't be updated filtered_data = {k: v for k, v in data.items() if k not in ('_id', 'id', 'created_at', 'created_by')} # Set the updated timestamp filtered_data["updated_at"] = datetime.now(timezone.utc) # Build ownership-aware query query = {"_id": ObjectId(focus_group_id)} if user_id: query["created_by"] = user_id result = await db.focus_groups.update_one( query, {"$set": filtered_data} ) # Emit WebSocket events for relevant updates if result.modified_count > 0: # Emit status change event if status was updated if 'status' in filtered_data: await emit_websocket_event('ai_status_update', focus_group_id, { 'status': { 'status': filtered_data['status'], # Frontend expects nested structure 'updated_at': filtered_data["updated_at"].isoformat() } }) # Emit model change event if LLM model was updated if 'llm_model' in filtered_data: await emit_websocket_event('focus_group_update', focus_group_id, { 'llm_model': filtered_data['llm_model'], 'reasoning_effort': filtered_data.get('reasoning_effort'), 'verbosity': filtered_data.get('verbosity'), 'updated_at': filtered_data["updated_at"].isoformat() }) return result.modified_count > 0 @staticmethod def _cleanup_focus_group_assets(focus_group_id, uploaded_assets): """Clean up all creative asset files for a focus group.""" cleaned_files = [] failed_files = [] if not uploaded_assets: return cleaned_files, failed_files # Get upload folder paths (reuse existing logic from routes) base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # Go up to backend/ upload_dir = os.path.join(base_dir, 'uploads', f'focus-group-{focus_group_id}') main_upload_dir = os.path.join(base_dir, 'uploads') for asset in uploaded_assets: raw_filename = asset.get('filename') if not raw_filename: continue # M-H5: Prevent path traversal — use only the basename filename = os.path.basename(raw_filename) if not filename: continue file_deleted = False try: # Validate file is within expected upload directories subdirectory_path = os.path.realpath(os.path.join(upload_dir, filename)) if subdirectory_path.startswith(os.path.realpath(upload_dir)) and os.path.exists(subdirectory_path): os.remove(subdirectory_path) file_deleted = True cleaned_files.append(filename) logger.debug(f"Deleted asset file: {subdirectory_path}") if not file_deleted: flat_path = os.path.realpath(os.path.join(main_upload_dir, filename)) if flat_path.startswith(os.path.realpath(main_upload_dir)) and os.path.exists(flat_path): os.remove(flat_path) file_deleted = True cleaned_files.append(filename) logger.debug(f"Deleted asset file: {flat_path}") if not file_deleted: logger.warning(f"Asset file not found for deletion: {filename}") failed_files.append(filename) except Exception as e: logger.error(f"Error deleting asset file {filename}: {e}") failed_files.append(filename) # Try to remove empty subdirectory try: if os.path.exists(upload_dir) and not os.listdir(upload_dir): os.rmdir(upload_dir) logger.debug(f"Removed empty upload directory: {upload_dir}") except Exception as e: logger.warning(f"Could not remove upload directory {upload_dir}: {e}") return cleaned_files, failed_files @staticmethod async def _cleanup_focus_group_collections(focus_group_id): """Clean up all related collection documents for a focus group.""" db = await get_db() cleaned_collections = [] failed_collections = [] # Collections to clean up collections_to_clean = [ ('focus_group_messages', 'focus_group_id'), ('focus_group_themes', 'focus_group_id'), ('focus_group_notes', 'focus_group_id'), ('focus_group_reasoning', 'focus_group_id'), ('focus_group_mode_events', 'focus_group_id') ] for collection_name, field_name in collections_to_clean: try: collection = getattr(db, collection_name) result = await collection.delete_many({field_name: focus_group_id}) if result.deleted_count > 0: cleaned_collections.append(f"{collection_name}: {result.deleted_count} documents") logger.debug(f"Cleaned up {result.deleted_count} documents from {collection_name}") except Exception as e: logger.error(f"Error cleaning up {collection_name}: {e}") failed_collections.append(collection_name) return cleaned_collections, failed_collections @staticmethod async def delete(focus_group_id, user_id=None): """Delete a focus group and all its associated data including creative assets.""" db = await get_db() try: # First, get the focus group data to access uploaded assets focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: logger.warning(f"Focus group {focus_group_id} not found") return False # Ownership check (M-H3) if user_id and focus_group.get('created_by') != user_id: logger.warning(f"User {user_id} attempted to delete focus group {focus_group_id} owned by {focus_group.get('created_by')}") return False uploaded_assets = focus_group.get('uploaded_assets', []) # Clean up creative asset files cleaned_files, failed_files = FocusGroup._cleanup_focus_group_assets(focus_group_id, uploaded_assets) # Clean up related collections cleaned_collections, failed_collections = await FocusGroup._cleanup_focus_group_collections(focus_group_id) # Finally, delete the main focus group document result = await db.focus_groups.delete_one({"_id": ObjectId(focus_group_id)}) if result.deleted_count > 0: logger.info(f"Deleted focus group {focus_group_id}: {len(cleaned_files)} asset files, {len(cleaned_collections)} collections cleaned") if failed_files: logger.warning(f"Failed to delete some asset files: {failed_files}") if failed_collections: logger.warning(f"Failed to clean some collections: {failed_collections}") return True else: logger.error(f"Failed to delete focus group {focus_group_id} from database") return False except Exception as e: logger.error(f"Error during focus group deletion: {e}") return False @staticmethod async def add_participant(focus_group_id, persona_id): db = await get_db() result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, {"$addToSet": {"participants": persona_id}} ) return result.modified_count > 0 @staticmethod async def remove_participant(focus_group_id, persona_id): db = await get_db() result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, {"$pull": {"participants": persona_id}} ) return result.modified_count > 0 @staticmethod async def get_messages(focus_group_id, limit=100): """Get all messages for a focus group.""" db = await get_db() try: # Get all messages and sort chronologically cursor = db.focus_group_messages.find( {"focus_group_id": focus_group_id} ).sort("created_at", 1) messages = await cursor.to_list(length=None) # Convert ObjectId to strings for message in messages: if "_id" in message: message["_id"] = str(message["_id"]) return messages except Exception as e: logger.error(f"Error getting messages for focus group {focus_group_id}: {e}") return [] @staticmethod async def add_message(focus_group_id, message_data): """Add a new message to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the message message = { "focus_group_id": focus_group_id, "text": message_data.get("text", ""), "type": message_data.get("type", "response"), "senderId": message_data.get("senderId", ""), "created_at": datetime.now(timezone.utc), "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 "visual_asset": message_data.get("visual_asset") # Visual asset metadata {filename, displayReference} } # Insert the message result = await db.focus_group_messages.insert_one(message) if result.inserted_id: message_id = str(result.inserted_id) message["_id"] = message_id # If this message activates visual context, update the focus group's active visual context if message.get("activates_visual_context") and message.get("attached_assets"): await FocusGroup._activate_visual_assets(focus_group_id, message.get("attached_assets"), message_id) # Emit WebSocket event for new message message_for_websocket = { 'id': message_id, 'senderId': message["senderId"], 'text': message["text"], 'timestamp': message["created_at"].isoformat(), 'type': message["type"], 'highlighted': message["highlighted"], 'attached_assets': message.get("attached_assets", []), 'activates_visual_context': message.get("activates_visual_context", False), 'visualAsset': message.get("visual_asset") # Include visual asset metadata } logger.debug(f"EMITTING WEBSOCKET EVENT: message_update for focus group {focus_group_id}") logger.debug(f"Message data: sender={message_for_websocket['senderId']}, type={message_for_websocket['type']}") await emit_websocket_event('message_update', focus_group_id, message_for_websocket) return message_id else: return None except Exception as e: logger.error(f"Error adding message to focus group {focus_group_id}: {e}") return None @staticmethod async def update_message_highlight(focus_group_id, message_id, highlighted): """Update the highlighted status of a message.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return False # Update the message result = await db.focus_group_messages.update_one( {"_id": ObjectId(message_id), "focus_group_id": focus_group_id}, {"$set": {"highlighted": highlighted, "updated_at": datetime.now(timezone.utc)}} ) return result.modified_count > 0 except Exception as e: logger.error(f"Error updating message highlight in focus group {focus_group_id}: {e}") return False @staticmethod async def get_generated_themes(focus_group_id): """Get all generated themes for a focus group.""" db = await get_db() try: # Get themes associated with this focus group cursor = db.focus_group_themes.find( {"focus_group_id": focus_group_id} ).sort("created_at", -1) themes = await cursor.to_list(length=None) # Convert ObjectId to strings for theme in themes: if "_id" in theme: theme["_id"] = str(theme["_id"]) return themes except Exception as e: logger.error(f"Error getting themes for focus group {focus_group_id}: {e}") return [] @staticmethod async def add_generated_theme(focus_group_id, theme_data): """Add a new generated theme to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the theme theme = { "focus_group_id": focus_group_id, "id": theme_data.get("id", f"theme-{str(uuid.uuid4())}"), "title": theme_data.get("title", ""), "description": theme_data.get("description", ""), "quotes": theme_data.get("quotes", []), "created_at": datetime.now(timezone.utc), "source": "generated" } # Insert the theme result = await db.focus_group_themes.insert_one(theme) if result.inserted_id: theme["_id"] = str(result.inserted_id) # Emit WebSocket event for new theme await emit_websocket_event('theme_update', focus_group_id, { 'theme': { 'id': theme["id"], 'title': theme["title"], 'description': theme["description"], 'quotes': theme["quotes"], 'source': theme["source"], 'created_at': theme["created_at"].isoformat() }, 'action': 'added' }) # Return the id of the new theme return str(result.inserted_id) except Exception as e: logger.error(f"Error adding theme to focus group {focus_group_id}: {e}") return None @staticmethod async def add_generated_themes(focus_group_id, themes_data): """Add multiple generated themes to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the themes themes = [] theme_ids = [] for theme_data in themes_data: theme_id = f"theme-{str(uuid.uuid4())}" theme = { "focus_group_id": focus_group_id, "id": theme_id, "title": theme_data.get("title", ""), "description": theme_data.get("description", ""), "quotes": theme_data.get("quotes", []), "created_at": datetime.now(timezone.utc), "source": "generated" } themes.append(theme) theme_ids.append(theme_id) # Insert the themes if themes: result = await db.focus_group_themes.insert_many(themes) # Emit WebSocket events for all new themes for theme in themes: await emit_websocket_event('theme_update', focus_group_id, { 'theme': { 'id': theme["id"], 'title': theme["title"], 'description': theme["description"], 'quotes': theme["quotes"], 'source': theme["source"], 'created_at': theme["created_at"].isoformat() }, 'action': 'added' }) # Return the ids of the new themes return theme_ids return [] except Exception as e: logger.error(f"Error adding themes to focus group {focus_group_id}: {e}") return [] @staticmethod async def delete_generated_theme(focus_group_id, theme_id): """Delete a generated theme from a focus group.""" db = await get_db() try: # Delete the theme result = await db.focus_group_themes.delete_one( {"focus_group_id": focus_group_id, "id": theme_id} ) return result.deleted_count > 0 except Exception as e: logger.error(f"Error deleting theme {theme_id} from focus group {focus_group_id}: {e}") return False @staticmethod async def get_reasoning_history(focus_group_id, limit=50): """Get reasoning history for a focus group.""" db = await get_db() try: # Get reasoning entries associated with this focus group cursor = db.focus_group_reasoning.find( {"focus_group_id": focus_group_id} ).sort("timestamp", -1).limit(limit) reasoning_entries = await cursor.to_list(length=limit) # Convert ObjectId to strings and format timestamps for entry in reasoning_entries: if "_id" in entry: entry["_id"] = str(entry["_id"]) # Ensure timestamp is in the expected format if "timestamp" in entry and isinstance(entry["timestamp"], datetime): entry["timestamp"] = entry["timestamp"].isoformat() return reasoning_entries except Exception as e: logger.error(f"Error getting reasoning history for focus group {focus_group_id}: {e}") return [] @staticmethod async def add_reasoning_entry(focus_group_id, reasoning_data): """Add a reasoning entry to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the reasoning entry reasoning_entry = { "focus_group_id": focus_group_id, "timestamp": reasoning_data.get("timestamp", datetime.now(timezone.utc)), "action": reasoning_data.get("action", "unknown"), "reasoning": reasoning_data.get("reasoning", ""), "details": reasoning_data.get("details", {}), "execution_status": reasoning_data.get("execution_status", "pending"), "execution_result": reasoning_data.get("execution_result", None), "created_at": datetime.now(timezone.utc) } # Convert timestamp string to datetime if needed if isinstance(reasoning_entry["timestamp"], str): try: reasoning_entry["timestamp"] = datetime.fromisoformat(reasoning_entry["timestamp"].replace('Z', '+00:00')) except Exception: reasoning_entry["timestamp"] = datetime.now(timezone.utc) # Insert the reasoning entry result = await db.focus_group_reasoning.insert_one(reasoning_entry) # Return the id of the new entry return str(result.inserted_id) except Exception as e: logger.error(f"Error adding reasoning entry to focus group {focus_group_id}: {e}") return None @staticmethod async def update_reasoning_execution(focus_group_id, reasoning_id, execution_result): """Update the execution result of a reasoning entry.""" db = await get_db() try: # Update the reasoning entry result = await db.focus_group_reasoning.update_one( {"_id": ObjectId(reasoning_id), "focus_group_id": focus_group_id}, {"$set": { "execution_status": "success" if not execution_result.get("error") else "error", "execution_result": execution_result, "updated_at": datetime.now(timezone.utc) }} ) return result.modified_count > 0 except Exception as e: logger.error(f"Error updating reasoning execution in focus group {focus_group_id}: {e}") return False @staticmethod async def get_notes(focus_group_id, limit=100): """Get all notes for a focus group.""" db = await get_db() try: # Look for a notes collection associated with this focus group cursor = db.focus_group_notes.find( {"focus_group_id": focus_group_id} ).sort("created_at", -1).limit(limit) notes = await cursor.to_list(length=limit) # Convert ObjectId to strings for note in notes: if "_id" in note: note["_id"] = str(note["_id"]) # Set id field to match frontend expectations note["id"] = note["_id"] return notes except Exception as e: logger.error(f"Error getting notes for focus group {focus_group_id}: {e}") return [] @staticmethod async def add_note(focus_group_id, note_data): """Add a new note to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the note note = { "focus_group_id": focus_group_id, "content": note_data.get("content", ""), "associatedMessageId": note_data.get("associatedMessageId"), "sectionInfo": note_data.get("sectionInfo", {}), "elapsedTime": note_data.get("elapsedTime", 0), "timestamp": note_data.get("timestamp", datetime.now(timezone.utc).isoformat()), "created_at": datetime.now(timezone.utc), "createdAt": datetime.now(timezone.utc) } # Convert timestamp string to datetime if needed if isinstance(note["timestamp"], str): try: note["timestamp"] = datetime.fromisoformat(note["timestamp"].replace('Z', '+00:00')) except Exception: note["timestamp"] = datetime.now(timezone.utc) # Insert the note result = await db.focus_group_notes.insert_one(note) # Return the id of the new note return str(result.inserted_id) except Exception as e: logger.error(f"Error adding note to focus group {focus_group_id}: {e}") return None @staticmethod async def delete_note(focus_group_id, note_id): """Delete a note from a focus group.""" db = await get_db() try: # Delete the note result = await db.focus_group_notes.delete_one( {"_id": ObjectId(note_id), "focus_group_id": focus_group_id} ) return result.deleted_count > 0 except Exception as e: logger.error(f"Error deleting note {note_id} from focus group {focus_group_id}: {e}") return False @staticmethod async def add_mode_event(focus_group_id, event_type, user_id=None): """Add a mode switch event to a focus group.""" db = await get_db() try: # Ensure the focus group exists focus_group = await FocusGroup.find_by_id(focus_group_id) if not focus_group: return None # Prepare the mode event mode_event = { "focus_group_id": focus_group_id, "event_type": event_type, # 'ai_mode_started', 'manual_mode_started', or 'ai_session_concluded' "timestamp": datetime.now(timezone.utc), "user_id": user_id, # None for system-initiated changes "created_at": datetime.now(timezone.utc) } # Insert the mode event result = await db.focus_group_mode_events.insert_one(mode_event) if result.inserted_id: mode_event_id = str(result.inserted_id) mode_event["_id"] = mode_event_id # Emit WebSocket event for new mode event mode_event_for_websocket = { 'id': mode_event_id, 'focus_group_id': focus_group_id, 'event_type': event_type, 'timestamp': mode_event["timestamp"].isoformat(), 'user_id': user_id, 'created_at': mode_event["created_at"].isoformat() } logger.debug(f"EMITTING WEBSOCKET EVENT: mode_event_update for focus group {focus_group_id}") logger.debug(f"Mode event data: event_type={event_type}, timestamp={mode_event['timestamp'].isoformat()}") await emit_websocket_event('mode_event_update', focus_group_id, mode_event_for_websocket) return mode_event_id # Return the id of the new mode event return None except Exception as e: logger.error(f"Error adding mode event to focus group {focus_group_id}: {e}") return None @staticmethod async def get_mode_events(focus_group_id, limit=100): """Get all mode events for a focus group.""" db = await get_db() try: # Look for mode events associated with this focus group cursor = db.focus_group_mode_events.find( {"focus_group_id": focus_group_id} ).sort("timestamp", 1).limit(limit) mode_events = await cursor.to_list(length=limit) # Convert ObjectId to strings for event in mode_events: if "_id" in event: event["_id"] = str(event["_id"]) # Set id field to match frontend expectations event["id"] = event["_id"] return mode_events except Exception as e: logger.error(f"Error getting mode events for focus group {focus_group_id}: {e}") return [] @staticmethod async def add_uploaded_assets(focus_group_id, assets_metadata): """Add uploaded asset metadata to a focus group.""" db = await get_db() try: # Clean the metadata to remove file_path before storing in DB cleaned_assets = [] for asset in assets_metadata: 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"] } cleaned_assets.append(cleaned_asset) # Add assets to the focus group result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, { "$push": {"uploaded_assets": {"$each": cleaned_assets}}, "$set": {"updated_at": datetime.now(timezone.utc)} } ) return result.modified_count > 0 except Exception as e: logger.error(f"Error adding uploaded assets to focus group {focus_group_id}: {e}") return False @staticmethod async def remove_uploaded_asset(focus_group_id, filename): """Remove an uploaded asset metadata from a focus group.""" db = await get_db() try: # Remove asset from the focus group result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, { "$pull": {"uploaded_assets": {"filename": filename}}, "$set": {"updated_at": datetime.now(timezone.utc)} } ) return result.modified_count > 0 except Exception as e: logger.error(f"Error removing uploaded asset from focus group {focus_group_id}: {e}") return False @staticmethod async def get_uploaded_assets(focus_group_id): """Get uploaded assets for a focus group.""" db = await get_db() try: focus_group = await db.focus_groups.find_one({"_id": ObjectId(focus_group_id)}) if focus_group: return focus_group.get('uploaded_assets', []) return [] except Exception as e: logger.error(f"Error getting uploaded assets for focus group {focus_group_id}: {e}") return [] @staticmethod async def update_asset_name(focus_group_id, filename, user_assigned_name): """Update the user assigned name for an uploaded asset.""" db = await get_db() try: result = await 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.now(timezone.utc) } } ) return result.modified_count > 0 except Exception as e: logger.error(f"Error updating asset name for focus group {focus_group_id}: {e}") return False @staticmethod async def clear_uploaded_assets(focus_group_id): """Clear all uploaded assets for a focus group from database.""" db = await get_db() try: result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, { "$unset": {"uploaded_assets": ""}, "$set": {"updated_at": datetime.now(timezone.utc)} } ) return result.modified_count > 0 except Exception as e: logger.error(f"Error clearing uploaded assets for focus group {focus_group_id}: {e}") return False @staticmethod async def _activate_visual_assets(focus_group_id, asset_filenames, message_id): """Internal method to activate visual assets in conversation context.""" db = await get_db() try: # Get current message count to determine sequence number message_count = await db.focus_group_messages.count_documents({"focus_group_id": focus_group_id}) # Get existing visual context to check for duplicate assets focus_group = await db.focus_groups.find_one({"_id": ObjectId(focus_group_id)}) existing_context = focus_group.get("active_visual_context", []) if focus_group else [] # Track which assets are new vs existing 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) logger.debug(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.now(timezone.utc) }) logger.debug(f"Activating new visual asset: {filename} ({display_reference}) at sequence {message_count}") # First, update existing assets to current sequence for filename in updated_filenames: await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id), "active_visual_context.filename": filename}, { "$set": { "active_visual_context.$.activated_at_message_id": message_id, "active_visual_context.$.activated_at_sequence": message_count, "active_visual_context.$.activation_timestamp": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc) } } ) # Then, add any new assets result = None if new_records: result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, { "$push": {"active_visual_context": {"$each": new_records}}, "$set": {"updated_at": datetime.now(timezone.utc)} }, upsert=True ) else: # If we only updated existing assets, just set the updated_at timestamp result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, {"$set": {"updated_at": datetime.now(timezone.utc)}} ) logger.debug(f"Activated visual assets for focus group {focus_group_id}: {asset_filenames}") return True except Exception as e: logger.error(f"Error activating visual assets for focus group {focus_group_id}: {e}") return False @staticmethod async def get_active_visual_context(focus_group_id): """Get all images that are active in conversation context for this focus group.""" db = await get_db() try: focus_group = await db.focus_groups.find_one({"_id": ObjectId(focus_group_id)}) if focus_group: return focus_group.get('active_visual_context', []) return [] except Exception as e: logger.error(f"Error getting active visual context for focus group {focus_group_id}: {e}") return [] @staticmethod async def get_messages_with_visual_context(focus_group_id, limit=100): """Get messages with enhanced visual context information.""" db = await get_db() try: # Get all messages cursor = db.focus_group_messages.find( {"focus_group_id": focus_group_id} ).sort("created_at", 1) messages = await cursor.to_list(length=None) # Convert ObjectId to strings and add sequence numbers for i, message in enumerate(messages): if "_id" in message: message["_id"] = str(message["_id"]) message["sequence"] = i + 1 # Add flag indicating if this message has visual context available active_context = await FocusGroup.get_active_visual_context(focus_group_id) message["has_visual_context"] = any( asset["activated_at_sequence"] <= message["sequence"] for asset in active_context ) return messages except Exception as e: logger.error(f"Error getting messages with visual context for focus group {focus_group_id}: {e}") return [] @staticmethod async def clear_visual_context(focus_group_id): """Clear all active visual context for a focus group (useful for testing).""" db = await get_db() try: result = await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, { "$unset": {"active_visual_context": ""}, "$set": {"updated_at": datetime.now(timezone.utc)} } ) logger.debug(f"Cleared visual context for focus group {focus_group_id}") return result.modified_count > 0 except Exception as e: logger.error(f"Error clearing visual context for focus group {focus_group_id}: {e}") return False