semblance/backend/app/models/focus_group.py
Vadym Samoilenko 3e1865edbd Apply Jintech security audit remediation (sprint 3) — 87/92 findings fixed
- Fix missing await on FocusGroup.get_messages() (N-L1)
- Replace time.sleep with asyncio.sleep in key_theme_service and focus_group_service (N-P10)
- Replace flask import with quart in focus_groups.py (N-S3)
- Add logger.error before all 500 returns in focus_groups.py (N-P6)
- Add logging to silent except blocks across routes (N-M10, N-M11)
- Add @rate_limit to 6 remaining AI endpoints (N-H4)
- Add --confirm flag to populate scripts before delete_many (S-H2)
- Remove hardcoded Azure ID fallbacks from msal_service.py and msalConfig.ts (A-M2, F-H4)
- Centralize make_serializable() in utils.py, remove duplicates from 3 route files (N-P7)
- Replace all datetime.utcnow() with datetime.now(timezone.utc) across entire backend (M-L2)
- AuthContext.tsx: only mark token validated on 200 success, not on non-401 errors (F-H2)
- Rename authType → auth_type in auth.py (N-S4)
- Add security_report.md and security_report.pdf with full 92-finding status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 12:51:18 +00:00

1022 lines
No EOL
43 KiB
Python
Executable file

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