All checks were successful
Deploy to Production / deploy (push) Successful in 2m23s
Includes frontend redesign (Navigation, billingApi), backend updates (auth routes, admin routes, LLM service refactor), MSAL removal, and dependency updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1022 lines
No EOL
43 KiB
Python
Executable file
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"] = "gpt-5.4"
|
|
|
|
# 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.matched_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 |