import json import time import uuid from typing import Any, Optional from rexpro_ai.internal.db import Base, get_async_db_context from rexpro_ai.utils.response import normalize_usage from pydantic import BaseModel, ConfigDict from sqlalchemy import ( JSON, BigInteger, Boolean, Column, ForeignKey, Index, Integer, Text, cast, delete, func, select, ) from sqlalchemy.ext.asyncio import AsyncSession #################### # Helpers #################### def _normalize_timestamp(timestamp: int) -> float: """Normalize and validate timestamp. Returns current time if invalid.""" now = time.time() # Convert milliseconds to seconds if needed if timestamp > 10_000_000_000: timestamp = timestamp / 1000 # Validate: must be after 2020 and not in the future (with 1 day tolerance) min_valid = 1577836800 # 2020-01-01 00:00:00 UTC max_valid = now + 86400 # 1 day in the future (clock skew tolerance) if timestamp < min_valid or timestamp > max_valid: return now return timestamp def get_usage(data: dict) -> Optional[dict]: """Extract and normalize usage from message data.""" usage = data.get('usage') or (data.get('info') or {}).get('usage') return normalize_usage(usage) if usage else None def _token_columns(dialect: str): """Return (input_tokens, output_tokens) SQL column expressions. Falls back to OpenAI-style keys (prompt_tokens / completion_tokens) when the normalized keys are absent. """ if dialect == 'sqlite': extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer) elif dialect == 'postgresql': extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer) else: raise NotImplementedError(f'Unsupported dialect: {dialect}') return ( func.coalesce(extract('input_tokens'), extract('prompt_tokens')), func.coalesce(extract('output_tokens'), extract('completion_tokens')), ) #################### # ChatMessage DB Schema #################### class ChatMessage(Base): __tablename__ = 'chat_message' # Identity id = Column(Text, primary_key=True) chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False, index=True) user_id = Column(Text, index=True) # Structure role = Column(Text, nullable=False) # user, assistant, system parent_id = Column(Text, nullable=True) # Content content = Column(JSON, nullable=True) # Can be str or list of blocks output = Column(JSON, nullable=True) # Model (for assistant messages) model_id = Column(Text, nullable=True, index=True) # Attachments files = Column(JSON, nullable=True) sources = Column(JSON, nullable=True) embeds = Column(JSON, nullable=True) # Status done = Column(Boolean, default=True) status_history = Column(JSON, nullable=True) error = Column(JSON, nullable=True) # Usage (tokens, timing, etc.) usage = Column(JSON, nullable=True) # Timestamps created_at = Column(BigInteger, index=True) updated_at = Column(BigInteger) __table_args__ = ( Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'), Index('chat_message_model_created_idx', 'model_id', 'created_at'), Index('chat_message_user_created_idx', 'user_id', 'created_at'), ) #################### # Pydantic Models #################### class ChatMessageModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: str chat_id: str user_id: str role: str parent_id: Optional[str] = None content: Optional[Any] = None # str or list of blocks output: Optional[list] = None model_id: Optional[str] = None files: Optional[list] = None sources: Optional[list] = None embeds: Optional[list] = None done: bool = True status_history: Optional[list] = None error: Optional[dict | str] = None usage: Optional[dict] = None created_at: int updated_at: int #################### # Table Operations #################### class ChatMessageTable: async def upsert_message( self, message_id: str, chat_id: str, user_id: str, data: dict, db: Optional[AsyncSession] = None, ) -> Optional[ChatMessageModel]: """Insert or update a chat message.""" async with get_async_db_context(db) as db: now = int(time.time()) timestamp = data.get('timestamp', now) # Use composite ID: {chat_id}-{message_id} composite_id = f'{chat_id}-{message_id}' existing = await db.get(ChatMessage, composite_id) if existing: # Update existing if 'role' in data: existing.role = data['role'] if 'parent_id' in data or 'parentId' in data: existing.parent_id = data.get('parent_id') or data.get('parentId') if 'content' in data: existing.content = data.get('content') if 'output' in data: existing.output = data.get('output') if 'model_id' in data or 'model' in data: existing.model_id = data.get('model_id') or data.get('model') if 'files' in data: existing.files = data.get('files') if 'sources' in data: existing.sources = data.get('sources') if 'embeds' in data: existing.embeds = data.get('embeds') if 'done' in data: existing.done = data.get('done', True) if 'status_history' in data or 'statusHistory' in data: existing.status_history = data.get('status_history') or data.get('statusHistory') if 'error' in data: existing.error = data.get('error') # Extract and normalize usage usage = get_usage(data) if usage: # Deep-merge: preserve existing keys not present in new data # This prevents background tasks (follow-ups, title, tags) # from accidentally clearing the primary response's token counts existing.usage = {**(existing.usage or {}), **usage} existing.updated_at = now await db.commit() await db.refresh(existing) return ChatMessageModel.model_validate(existing) else: # Insert new # Extract and normalize usage usage = get_usage(data) message = ChatMessage( id=composite_id, chat_id=chat_id, user_id=user_id, role=data.get('role', 'user'), parent_id=data.get('parent_id') or data.get('parentId'), content=data.get('content'), output=data.get('output'), model_id=data.get('model_id') or data.get('model'), files=data.get('files'), sources=data.get('sources'), embeds=data.get('embeds'), done=data.get('done', True), status_history=data.get('status_history') or data.get('statusHistory'), error=data.get('error'), usage=usage, created_at=timestamp, updated_at=now, ) db.add(message) await db.commit() await db.refresh(message) return ChatMessageModel.model_validate(message) async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]: async with get_async_db_context(db) as db: message = await db.get(ChatMessage, id) return ChatMessageModel.model_validate(message) if message else None async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]: async with get_async_db_context(db) as db: result = await db.execute( select(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc()) ) messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] # DB column names that differ from the JSON message keys. DB_TO_JSON_KEY_MAP = { 'parent_id': 'parentId', 'model_id': 'model', 'status_history': 'statusHistory', 'created_at': 'timestamp', } # DB-internal columns excluded from the reconstructed message dict. EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'}) async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]: """Build a {message_id: message_dict} map from chat_message rows. Returns the same shape as chat.history.messages so callers (get_message_list, middleware) work unchanged. Returns None if no rows exist for the chat (caller should fall back to the embedded JSON blob for legacy chats). """ async with get_async_db_context(db) as db: result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id)) rows = result.scalars().all() if not rows: return None # Strip the composite-id prefix ("{chat_id}-") to recover the # original message_id used as map key. prefix = f'{chat_id}-' prefix_len = len(prefix) col_keys = [c.key for c in ChatMessage.__table__.columns] messages_map: dict[str, dict] = {} for row in rows: msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id msg: dict = {'id': msg_id} for key in col_keys: if key in self.EXCLUDED_COLUMNS: continue val = getattr(row, key) if val is None: continue json_key = self.DB_TO_JSON_KEY_MAP.get(key, key) msg[json_key] = val # Ensure content always has a value msg.setdefault('content', '') # Mirror usage into info.usage for callers that read it there if 'usage' in msg: msg['info'] = {'usage': msg['usage']} messages_map[msg_id] = msg # Reconstruct childrenIds from parentId links so that the map # is fully navigable (callers like the frontend rely on this). for msg_id, msg in messages_map.items(): parent_id = msg.get('parentId') if parent_id and parent_id in messages_map: parent = messages_map[parent_id] children = parent.get('childrenIds') if children is None: parent['childrenIds'] = [msg_id] elif msg_id not in children: children.append(msg_id) # Ensure every message has a childrenIds list (leaf nodes get []) for msg in messages_map.values(): if 'childrenIds' not in msg: msg['childrenIds'] = [] return messages_map async def get_messages_by_user_id( self, user_id: str, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None, ) -> list[ChatMessageModel]: async with get_async_db_context(db) as db: result = await db.execute( select(ChatMessage) .filter_by(user_id=user_id) .order_by(ChatMessage.created_at.desc()) .offset(skip) .limit(limit) ) messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] async def get_messages_by_model_id( self, model_id: str, start_date: Optional[int] = None, end_date: Optional[int] = None, skip: int = 0, limit: int = 100, db: Optional[AsyncSession] = None, ) -> list[ChatMessageModel]: async with get_async_db_context(db) as db: stmt = select(ChatMessage).filter_by(model_id=model_id) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) stmt = stmt.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit) result = await db.execute(stmt) messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] async def get_chat_ids_by_model_id( self, model_id: str, start_date: Optional[int] = None, end_date: Optional[int] = None, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None, ) -> list[str]: """Get distinct chat_ids that used a specific model.""" async with get_async_db_context(db) as db: stmt = select( ChatMessage.chat_id, func.max(ChatMessage.created_at).label('last_message_at'), ).filter(ChatMessage.model_id == model_id) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) # Group by chat_id and order by most recent message in each chat # Secondary sort on chat_id ensures deterministic pagination stmt = ( stmt.group_by(ChatMessage.chat_id) .order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id) .offset(skip) .limit(limit) ) result = await db.execute(stmt) chat_ids = result.all() return [chat_id for chat_id, _ in chat_ids] async def delete_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool: async with get_async_db_context(db) as db: await db.execute(delete(ChatMessage).filter_by(chat_id=chat_id)) await db.commit() return True async def delete_message_ids_by_chat_id( self, chat_id: str, message_ids: set[str], db: Optional[AsyncSession] = None, ) -> bool: """Delete specific ``chat_message`` rows by their original message IDs.""" if not message_ids: return True async with get_async_db_context(db) as db: await db.execute( delete(ChatMessage) .where(ChatMessage.chat_id == chat_id) .where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids})) ) await db.commit() return True # Analytics methods async def get_message_count_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, int]: async with get_async_db_context(db) as db: from rexpro_ai.models.groups import GroupMember stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) stmt = stmt.group_by(ChatMessage.model_id) result = await db.execute(stmt) return {row.model_id: row.count for row in result.all()} async def get_token_usage_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict]: """Aggregate token usage by model using database-level aggregation.""" async with get_async_db_context(db) as db: from rexpro_ai.models.groups import GroupMember # We need the dialect to determine JSON extraction syntax # For async sessions, access via get_bind() bind = await db.connection() dialect = bind.dialect.name input_tokens, output_tokens = _token_columns(dialect) stmt = select( ChatMessage.model_id, func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), func.count(ChatMessage.id).label('message_count'), ).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ChatMessage.usage.isnot(None), ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) stmt = stmt.group_by(ChatMessage.model_id) result = await db.execute(stmt) return { row.model_id: { 'input_tokens': row.input_tokens, 'output_tokens': row.output_tokens, 'total_tokens': row.input_tokens + row.output_tokens, 'message_count': row.message_count, } for row in result.all() } async def get_token_usage_by_user( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict]: """Aggregate token usage by user using database-level aggregation.""" async with get_async_db_context(db) as db: from rexpro_ai.models.groups import GroupMember bind = await db.connection() dialect = bind.dialect.name input_tokens, output_tokens = _token_columns(dialect) stmt = select( ChatMessage.user_id, func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), func.count(ChatMessage.id).label('message_count'), ).filter( ChatMessage.role == 'assistant', ChatMessage.user_id.isnot(None), ChatMessage.usage.isnot(None), ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) stmt = stmt.group_by(ChatMessage.user_id) result = await db.execute(stmt) return { row.user_id: { 'input_tokens': row.input_tokens, 'output_tokens': row.output_tokens, 'total_tokens': row.input_tokens + row.output_tokens, 'message_count': row.message_count, } for row in result.all() } async def get_message_count_by_user( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, int]: async with get_async_db_context(db) as db: from rexpro_ai.models.groups import GroupMember stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter( ChatMessage.role == 'assistant', ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) stmt = stmt.group_by(ChatMessage.user_id) result = await db.execute(stmt) return {row.user_id: row.count for row in result.all()} async def get_message_count_by_chat( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, int]: async with get_async_db_context(db) as db: from rexpro_ai.models.groups import GroupMember stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter( ChatMessage.role == 'assistant', ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) stmt = stmt.group_by(ChatMessage.chat_id) result = await db.execute(stmt) return {row.chat_id: row.count for row in result.all()} async def get_daily_message_counts_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict[str, int]]: """Get message counts grouped by day and model.""" async with get_async_db_context(db) as db: from datetime import datetime, timedelta from rexpro_ai.models.groups import GroupMember stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) if group_id: group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery() stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) result = await db.execute(stmt) results = result.all() # Group by date -> model -> count daily_counts: dict[str, dict[str, int]] = {} for timestamp, model_id in results: date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d') if date_str not in daily_counts: daily_counts[date_str] = {} daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 # Fill in missing days if start_date and end_date: current = datetime.fromtimestamp(_normalize_timestamp(start_date)) end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) while current <= end_dt: date_str = current.strftime('%Y-%m-%d') if date_str not in daily_counts: daily_counts[date_str] = {} current += timedelta(days=1) return daily_counts async def get_hourly_message_counts_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, db: Optional[AsyncSession] = None, ) -> dict[str, dict[str, int]]: """Get message counts grouped by hour and model.""" async with get_async_db_context(db) as db: from datetime import datetime, timedelta stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ) if start_date: stmt = stmt.filter(ChatMessage.created_at >= start_date) if end_date: stmt = stmt.filter(ChatMessage.created_at <= end_date) result = await db.execute(stmt) results = result.all() # Group by hour -> model -> count hourly_counts: dict[str, dict[str, int]] = {} for timestamp, model_id in results: hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00') if hour_str not in hourly_counts: hourly_counts[hour_str] = {} hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 # Fill in missing hours if start_date and end_date: current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace( minute=0, second=0, microsecond=0 ) end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) while current <= end_dt: hour_str = current.strftime('%Y-%m-%d %H:00') if hour_str not in hourly_counts: hourly_counts[hour_str] = {} current += timedelta(hours=1) return hourly_counts ChatMessages = ChatMessageTable()