sessions.py (11113B)
1 """Session routes — proxy to Hermes API with local topic enrichment.""" 2 3 from __future__ import annotations 4 5 import logging 6 from typing import Any 7 8 from fastapi import APIRouter, HTTPException, Request 9 10 from app.config import get_config 11 from app.hermes_client import ( 12 HermesClient, 13 _normalize_message, 14 normalize_messages, 15 _normalize_session, 16 _unwrap_hermes_response, 17 ) 18 from app.models import SessionCreate, SessionUpdate 19 20 logger = logging.getLogger(__name__) 21 router = APIRouter(prefix="/api/sessions", tags=["sessions"]) 22 23 24 def get_hermes_client(): # pragma: no mutate: block 25 """Build a Hermes client from the current config.""" 26 cfg = get_config() 27 return HermesClient(base_url=cfg.hermes_base_url, api_key=cfg.hermes_api_key) 28 29 30 async def _proxy_call(client: HermesClient, coro: Any) -> Any: # pragma: no mutate: block 31 """Await a client coroutine, wrapping errors as 502.""" 32 try: 33 return await coro 34 except Exception as exc: 35 logger.exception("Hermes API error") 36 raise HTTPException(status_code=502, detail=f"Hermes: {exc}") from exc 37 38 39 async def _enrich_sessions( 40 sessions: list[dict], request: Request, 41 ) -> list[dict]: 42 """Attach topic_id, topic_name, and archived status from the local DB.""" 43 db = getattr(request.app.state, "db", None) 44 if db is None: 45 return sessions 46 topic_map = await _load_topic_map(db) 47 archived = await _load_archived_set(db) 48 return [_attach_topic(_attach_archived(s, archived), topic_map) for s in sessions] 49 50 51 async def _load_topic_map(db: Any) -> dict[str, tuple[str, str]]: 52 """Map session_id → (topic_id, topic_name) from topic_sessions.""" 53 try: 54 rows = await db.fetch_all( 55 "SELECT ts.session_id, t.id, t.name " 56 "FROM topic_sessions ts " 57 "JOIN topics t ON t.id = ts.topic_id" 58 ) 59 return {r[0]: (r[1], r[2]) for r in rows} 60 except Exception: 61 return {} 62 63 64 async def _load_archived_set(db: Any) -> set[str]: 65 """Return set of archived session IDs from the DB.""" 66 try: 67 rows = await db.fetch_all("SELECT session_id FROM archived_sessions") 68 return {r[0] for r in rows} 69 except Exception: 70 return set() 71 72 73 def _attach_topic(session: dict, topic_map: dict) -> dict: # pragma: no mutate: block 74 """Add topic fields to a session dict if available.""" 75 sid = session.get("id", "") 76 if sid in topic_map: 77 tid, tname = topic_map[sid] 78 session["topic_id"] = tid 79 session["topic_name"] = tname 80 return session 81 82 83 def _attach_archived(session: dict, archived: set[str]) -> dict: 84 """Add archived: true/false to a session dict.""" 85 session["archived"] = session.get("id", "") in archived 86 return session 87 88 89 def _extract_sessions(data: Any) -> list[dict]: 90 """Extract a list of normalized sessions from a Hermes response. 91 92 Hermes returns ``{"object": "list", "data": [...]}``. 93 Also handles legacy ``{"sessions": [...]}`` and bare ``[...]``. 94 """ 95 if data is None: 96 return [] 97 if isinstance(data, list): 98 return [_normalize_session(s) for s in data] 99 if isinstance(data, dict): 100 raw_list = data.get("data") or data.get("sessions") or [] 101 if isinstance(raw_list, list): 102 return [_normalize_session(s) for s in raw_list] 103 return [] 104 105 106 def _extract_single_session(data: Any) -> dict: 107 """Extract a single normalized session from a Hermes response. 108 109 Hermes returns ``{"object": "hermes.session", "session": {...}}``. 110 """ 111 if data is None: 112 return {} 113 if isinstance(data, dict): 114 inner = data.get("session") or data 115 if isinstance(inner, dict): 116 return _normalize_session(inner) 117 return {} 118 119 120 @router.get("") 121 async def list_sessions(request: Request) -> dict: # pragma: no mutate: block 122 """List all sessions, enriched with local topic info.""" 123 client = get_hermes_client() 124 data = await _proxy_call(client, client.list_sessions()) 125 sessions = _extract_sessions(data) 126 enriched = await _enrich_sessions(sessions, request) 127 return {"sessions": enriched} 128 129 130 @router.post("") 131 async def create_session(payload: SessionCreate, request: Request) -> dict: # pragma: no mutate: block 132 """Create a session via Hermes, optionally assign to a topic.""" 133 client = get_hermes_client() 134 result = await _proxy_call(client, client.create_session(name=payload.name)) 135 if result is None: 136 raise HTTPException(status_code=502, detail="Hermes returned no data") 137 session = _extract_single_session(result) 138 await _assign_topic(request, session.get("id"), payload.topic_id) 139 return session 140 141 142 async def _assign_topic( # pragma: no mutate: block 143 request: Request, session_id: str | None, topic_id: str | None, 144 ) -> None: # pragma: no mutate: block 145 """Insert a row into topic_sessions if both IDs are present.""" 146 if not session_id or not topic_id: 147 return 148 db = getattr(request.app.state, "db", None) 149 if db is None: 150 return 151 try: 152 await db.execute( 153 "INSERT INTO topic_sessions (topic_id, session_id) VALUES (?, ?)", 154 (topic_id, session_id), 155 ) 156 except Exception: 157 logger.warning("Failed to assign topic %s to session %s", topic_id, session_id) 158 159 160 @router.patch("/{session_id}") 161 async def update_session( # pragma: no mutate: block 162 session_id: str, payload: SessionUpdate, request: Request, 163 ) -> dict: # pragma: no mutate: block 164 """Update session fields via Hermes.""" 165 client = get_hermes_client() 166 fields: dict[str, Any] = { 167 k: v for k, v in payload.model_dump().items() if v is not None 168 } 169 result = await _proxy_call( 170 client, client.update_session(session_id, **fields), 171 ) 172 if result is None: 173 raise HTTPException(status_code=502, detail="Hermes returned no data") 174 return _extract_single_session(result) 175 176 177 @router.delete("/{session_id}", status_code=204) 178 async def delete_session(session_id: str) -> None: # pragma: no mutate: block 179 """Delete a session via Hermes.""" 180 client = get_hermes_client() 181 await _proxy_call(client, client.delete_session(session_id)) 182 183 184 @router.get("/{session_id}/messages") 185 async def get_messages(session_id: str, request: Request) -> dict: # pragma: no mutate: block 186 """Get messages for a session, enriched with attachment metadata.""" 187 client = get_hermes_client() 188 data = await _proxy_call(client, client.get_messages(session_id)) 189 if data is None: 190 messages: list = [] 191 else: 192 # Hermes returns {"object": "list", "data": [...]} 193 raw_list = ( 194 data.get("data", []) if isinstance(data, dict) 195 else data if isinstance(data, list) 196 else [] 197 ) 198 messages = normalize_messages(raw_list) if isinstance(raw_list, list) else [] 199 200 # Fetch attachments for this session from local DB 201 attachments = await _load_session_attachments(request, session_id) 202 return {"messages": messages, "attachments": attachments} 203 204 205 async def _load_session_attachments( # pragma: no mutate: block 206 request: Request, session_id: str, 207 ) -> list[dict]: 208 """Return attachment metadata for a session, or empty list if unavailable.""" 209 db = getattr(request.app.state, "db", None) 210 if db is None: 211 return [] 212 try: 213 rows = await db.fetch_all( 214 "SELECT id, filename, content_type, size_bytes, created_at " 215 "FROM attachments WHERE session_id = ? ORDER BY created_at", 216 (session_id,), 217 ) 218 return [ 219 { 220 "id": r[0], 221 "filename": r[1], 222 "content_type": r[2], 223 "size_bytes": r[3], 224 "created_at": r[4], 225 } 226 for r in rows 227 ] 228 except Exception: 229 return [] 230 231 232 @router.post("/{session_id}/fork") 233 async def fork_session(session_id: str, request: Request) -> dict: # pragma: no mutate: block 234 """Fork a session via Hermes and store branch locally.""" 235 client = get_hermes_client() 236 result = await _proxy_call(client, client.fork_session(session_id)) 237 if result is None: 238 raise HTTPException(status_code=502, detail="Hermes returned no data") 239 session = _extract_single_session(result) 240 session["parent_id"] = session_id 241 await _store_branch(request, session.get("id"), session_id) 242 return session 243 244 245 async def _store_branch( # pragma: no mutate: block 246 request: Request, child_id: str | None, parent_id: str, 247 ) -> None: # pragma: no mutate: block 248 """Insert a row into the branches table.""" 249 if not child_id: 250 return 251 db = getattr(request.app.state, "db", None) 252 if db is None: 253 return 254 try: 255 await db.execute( 256 "INSERT INTO branches (session_id, parent_session_id, " 257 "fork_message_index, created_at) VALUES (?, ?, 0, strftime('%s','now'))", 258 (child_id, parent_id), 259 ) 260 except Exception: 261 logger.warning( 262 "Failed to store branch %s for session %s", child_id, parent_id, 263 ) 264 265 266 @router.post("/{session_id}/archive") 267 async def archive_session(session_id: str, request: Request) -> dict: 268 """Mark a session as archived in the local DB.""" 269 db = getattr(request.app.state, "db", None) 270 if db is None: 271 raise HTTPException(status_code=500, detail="Database not available") 272 try: 273 await db.execute( 274 "INSERT OR IGNORE INTO archived_sessions (session_id) VALUES (?)", 275 (session_id,), 276 ) 277 except Exception as exc: 278 raise HTTPException(status_code=500, detail=str(exc)) from exc 279 return {"session_id": session_id, "archived": True} 280 281 282 @router.delete("/{session_id}/archive", status_code=204) 283 async def unarchive_session(session_id: str, request: Request) -> None: 284 """Remove archived status for a session.""" 285 db = getattr(request.app.state, "db", None) 286 if db is None: 287 raise HTTPException(status_code=500, detail="Database not available") 288 try: 289 await db.execute( 290 "DELETE FROM archived_sessions WHERE session_id = ?", 291 (session_id,), 292 ) 293 except Exception as exc: 294 raise HTTPException(status_code=500, detail=str(exc)) from exc 295 296 297 @router.get("/{session_id}/branches") 298 async def list_branches(session_id: str, request: Request) -> dict: # pragma: no mutate: block 299 """List child sessions forked from this session.""" 300 db = getattr(request.app.state, "db", None) 301 if db is None: 302 return {"branches": []} 303 try: 304 rows = await db.fetch_all( 305 "SELECT session_id, parent_session_id, fork_message_index " 306 "FROM branches WHERE parent_session_id = ?", 307 (session_id,), 308 ) 309 branches = [ 310 { 311 "session_id": r[0], 312 "parent_session_id": r[1], 313 "fork_message_index": r[2], 314 } 315 for r in rows 316 ] 317 return {"branches": branches} 318 except Exception: 319 return {"branches": []}