remdb 0.3.202__py3-none-any.whl → 0.3.245__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (44) hide show
  1. rem/agentic/README.md +36 -2
  2. rem/agentic/context.py +86 -3
  3. rem/agentic/context_builder.py +39 -33
  4. rem/agentic/mcp/tool_wrapper.py +2 -2
  5. rem/agentic/providers/pydantic_ai.py +68 -51
  6. rem/agentic/schema.py +2 -2
  7. rem/api/mcp_router/resources.py +223 -0
  8. rem/api/mcp_router/tools.py +170 -18
  9. rem/api/routers/admin.py +30 -4
  10. rem/api/routers/auth.py +175 -18
  11. rem/api/routers/chat/child_streaming.py +394 -0
  12. rem/api/routers/chat/completions.py +24 -29
  13. rem/api/routers/chat/sse_events.py +5 -1
  14. rem/api/routers/chat/streaming.py +242 -272
  15. rem/api/routers/chat/streaming_utils.py +327 -0
  16. rem/api/routers/common.py +18 -0
  17. rem/api/routers/dev.py +7 -1
  18. rem/api/routers/feedback.py +9 -1
  19. rem/api/routers/messages.py +80 -15
  20. rem/api/routers/models.py +9 -1
  21. rem/api/routers/query.py +17 -15
  22. rem/api/routers/shared_sessions.py +16 -0
  23. rem/cli/commands/ask.py +205 -114
  24. rem/cli/commands/process.py +12 -4
  25. rem/cli/commands/query.py +109 -0
  26. rem/cli/commands/session.py +117 -0
  27. rem/cli/main.py +2 -0
  28. rem/models/entities/session.py +1 -0
  29. rem/schemas/agents/rem.yaml +1 -1
  30. rem/services/postgres/repository.py +7 -7
  31. rem/services/rem/service.py +47 -0
  32. rem/services/session/__init__.py +2 -1
  33. rem/services/session/compression.py +14 -12
  34. rem/services/session/pydantic_messages.py +111 -11
  35. rem/services/session/reload.py +2 -1
  36. rem/settings.py +71 -0
  37. rem/sql/migrations/001_install.sql +4 -4
  38. rem/sql/migrations/004_cache_system.sql +3 -1
  39. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  40. rem/utils/schema_loader.py +139 -111
  41. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/METADATA +2 -2
  42. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/RECORD +44 -39
  43. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/entry_points.txt +0 -0
@@ -542,6 +542,227 @@ def register_status_resources(mcp: FastMCP):
542
542
  """
543
543
 
544
544
 
545
+ def register_session_resources(mcp: FastMCP):
546
+ """
547
+ Register session resources for loading conversation history.
548
+
549
+ Args:
550
+ mcp: FastMCP server instance
551
+ """
552
+
553
+ @mcp.resource("rem://sessions/{session_id}")
554
+ async def get_session_messages(session_id: str) -> str:
555
+ """
556
+ Load a conversation session by ID.
557
+
558
+ Returns the full message history including user messages, assistant responses,
559
+ and tool calls. Useful for evaluators and analysis agents.
560
+
561
+ Args:
562
+ session_id: Session UUID or identifier
563
+
564
+ Returns:
565
+ Formatted conversation history as markdown string with:
566
+ - Message type (user/assistant/tool)
567
+ - Content
568
+ - Timestamps
569
+ - Tool call details (if any)
570
+ """
571
+ from ...services.postgres import get_postgres_service
572
+
573
+ pg = get_postgres_service()
574
+ await pg.connect()
575
+
576
+ try:
577
+ # Query messages for session
578
+ query = """
579
+ SELECT id, message_type, content, metadata, created_at
580
+ FROM messages
581
+ WHERE session_id = $1
582
+ ORDER BY created_at ASC
583
+ """
584
+ messages = await pg.fetch(query, session_id)
585
+
586
+ if not messages:
587
+ return f"# Session Not Found\n\nNo messages found for session_id: {session_id}"
588
+
589
+ # Format output
590
+ output = [f"# Session: {session_id}\n"]
591
+ output.append(f"**Total messages:** {len(messages)}\n")
592
+
593
+ for i, msg in enumerate(messages, 1):
594
+ msg_type = msg['message_type']
595
+ content = msg['content'] or "(empty)"
596
+ created = msg['created_at']
597
+ metadata = msg.get('metadata') or {}
598
+
599
+ # Format based on message type
600
+ if msg_type == 'user':
601
+ output.append(f"\n## [{i}] USER ({created})")
602
+ output.append(f"```\n{content[:1000]}{'...' if len(content) > 1000 else ''}\n```")
603
+ elif msg_type == 'assistant':
604
+ output.append(f"\n## [{i}] ASSISTANT ({created})")
605
+ output.append(f"```\n{content[:1000]}{'...' if len(content) > 1000 else ''}\n```")
606
+ elif msg_type == 'tool':
607
+ tool_name = metadata.get('tool_name', 'unknown')
608
+ output.append(f"\n## [{i}] TOOL: {tool_name} ({created})")
609
+ # Truncate tool results more aggressively
610
+ output.append(f"```json\n{content[:500]}{'...' if len(content) > 500 else ''}\n```")
611
+ else:
612
+ output.append(f"\n## [{i}] {msg_type.upper()} ({created})")
613
+ output.append(f"```\n{content[:500]}{'...' if len(content) > 500 else ''}\n```")
614
+
615
+ return "\n".join(output)
616
+
617
+ finally:
618
+ await pg.disconnect()
619
+
620
+ @mcp.resource("rem://sessions")
621
+ async def list_recent_sessions() -> str:
622
+ """
623
+ List recent sessions with basic info.
624
+
625
+ Returns the most recent 20 sessions with:
626
+ - Session ID
627
+ - First user message (preview)
628
+ - Message count
629
+ - Timestamp
630
+ """
631
+ from ...services.postgres import get_postgres_service
632
+
633
+ pg = get_postgres_service()
634
+ await pg.connect()
635
+
636
+ try:
637
+ # Query recent sessions
638
+ query = """
639
+ SELECT
640
+ session_id,
641
+ MIN(created_at) as started_at,
642
+ COUNT(*) as message_count,
643
+ MIN(CASE WHEN message_type = 'user' THEN content END) as first_message
644
+ FROM messages
645
+ WHERE session_id IS NOT NULL
646
+ GROUP BY session_id
647
+ ORDER BY MIN(created_at) DESC
648
+ LIMIT 20
649
+ """
650
+ sessions = await pg.fetch(query)
651
+
652
+ if not sessions:
653
+ return "# Recent Sessions\n\nNo sessions found."
654
+
655
+ output = ["# Recent Sessions\n"]
656
+ output.append(f"Showing {len(sessions)} most recent sessions:\n")
657
+
658
+ for session in sessions:
659
+ session_id = session['session_id']
660
+ started = session['started_at']
661
+ count = session['message_count']
662
+ first_msg = session['first_message'] or "(no user message)"
663
+ preview = first_msg[:80] + "..." if len(first_msg) > 80 else first_msg
664
+
665
+ output.append(f"\n## {session_id}")
666
+ output.append(f"- **Started:** {started}")
667
+ output.append(f"- **Messages:** {count}")
668
+ output.append(f"- **First message:** {preview}")
669
+ output.append(f"- **Load:** `rem://sessions/{session_id}`")
670
+
671
+ return "\n".join(output)
672
+
673
+ finally:
674
+ await pg.disconnect()
675
+
676
+
677
+ def register_user_resources(mcp: FastMCP):
678
+ """
679
+ Register user profile resources for on-demand profile loading.
680
+
681
+ Args:
682
+ mcp: FastMCP server instance
683
+ """
684
+
685
+ @mcp.resource("user://profile/{user_id}")
686
+ async def get_user_profile(user_id: str) -> str:
687
+ """
688
+ Load a user's profile by ID.
689
+
690
+ Returns the user's profile information including:
691
+ - Email and name
692
+ - Summary (AI-generated profile summary)
693
+ - Interests and preferred topics
694
+ - Activity level
695
+
696
+ This resource is protected - each user can only access their own profile.
697
+ The user_id should match the authenticated user's ID from the JWT token.
698
+
699
+ Args:
700
+ user_id: User UUID from authentication
701
+
702
+ Returns:
703
+ Formatted user profile as markdown string, or error if not found
704
+ """
705
+ from ...services.postgres import get_postgres_service
706
+ from ...services.postgres.repository import Repository
707
+ from ...models.entities.user import User
708
+
709
+ pg = get_postgres_service()
710
+ await pg.connect()
711
+
712
+ try:
713
+ user_repo = Repository(User, "users", db=pg)
714
+ # Look up user by ID (user_id from JWT is the primary key)
715
+ user = await user_repo.get_by_id(user_id, tenant_id=None)
716
+
717
+ if not user:
718
+ return f"# User Profile Not Found\n\nNo user found with ID: {user_id}"
719
+
720
+ # Build profile output
721
+ output = [f"# User Profile: {user.name or user.email or 'Unknown'}"]
722
+ output.append("")
723
+
724
+ if user.email:
725
+ output.append(f"**Email:** {user.email}")
726
+
727
+ if user.role:
728
+ output.append(f"**Role:** {user.role}")
729
+
730
+ if user.tier:
731
+ output.append(f"**Tier:** {user.tier.value if hasattr(user.tier, 'value') else user.tier}")
732
+
733
+ if user.summary:
734
+ output.append(f"\n## Summary\n{user.summary}")
735
+
736
+ if user.interests:
737
+ output.append(f"\n## Interests\n- " + "\n- ".join(user.interests[:10]))
738
+
739
+ if user.preferred_topics:
740
+ output.append(f"\n## Preferred Topics\n- " + "\n- ".join(user.preferred_topics[:10]))
741
+
742
+ if user.activity_level:
743
+ output.append(f"\n**Activity Level:** {user.activity_level}")
744
+
745
+ if user.last_active_at:
746
+ output.append(f"**Last Active:** {user.last_active_at}")
747
+
748
+ # Add metadata if present (but redact sensitive fields)
749
+ if user.metadata:
750
+ safe_metadata = {k: v for k, v in user.metadata.items()
751
+ if k not in ('login_code', 'password', 'token', 'secret')}
752
+ if safe_metadata:
753
+ output.append(f"\n## Additional Info")
754
+ for key, value in list(safe_metadata.items())[:5]:
755
+ output.append(f"- **{key}:** {value}")
756
+
757
+ return "\n".join(output)
758
+
759
+ except Exception as e:
760
+ return f"# Error Loading Profile\n\nFailed to load user profile: {e}"
761
+
762
+ finally:
763
+ await pg.disconnect()
764
+
765
+
545
766
  # Resource dispatcher for read_resource tool
546
767
  async def load_resource(uri: str) -> dict | str:
547
768
  """
@@ -571,6 +792,8 @@ async def load_resource(uri: str) -> dict | str:
571
792
  register_agent_resources(mcp)
572
793
  register_file_resources(mcp)
573
794
  register_status_resources(mcp)
795
+ register_session_resources(mcp)
796
+ register_user_resources(mcp)
574
797
 
575
798
  # 1. Try exact match in regular resources
576
799
  resources = await mcp.get_resources()
@@ -594,15 +594,18 @@ async def read_resource(uri: str) -> dict[str, Any]:
594
594
  **Available Resources:**
595
595
 
596
596
  Agent Schemas:
597
- • rem://schemas - List all agent schemas
598
- • rem://schema/{name} - Get specific schema definition
599
- • rem://schema/{name}/{version} - Get specific version
597
+ • rem://agents - List all available agent schemas
598
+ • rem://agents/{agent_name} - Get specific agent schema
599
+
600
+ Documentation:
601
+ • rem://schema/entities - Entity schemas (Resource, Message, User, File, Moment)
602
+ • rem://schema/query-types - REM query type documentation
600
603
 
601
604
  System Status:
602
605
  • rem://status - System health and statistics
603
606
 
604
607
  Args:
605
- uri: Resource URI (e.g., "rem://schemas", "rem://schema/ask_rem")
608
+ uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem")
606
609
 
607
610
  Returns:
608
611
  Dict with:
@@ -611,14 +614,11 @@ async def read_resource(uri: str) -> dict[str, Any]:
611
614
  - data: Resource data (format depends on resource type)
612
615
 
613
616
  Examples:
614
- # List all schemas
615
- read_resource(uri="rem://schemas")
616
-
617
- # Get specific schema
618
- read_resource(uri="rem://schema/ask_rem")
617
+ # List all agents
618
+ read_resource(uri="rem://agents")
619
619
 
620
- # Get schema version
621
- read_resource(uri="rem://schema/ask_rem/v1.0.0")
620
+ # Get specific agent
621
+ read_resource(uri="rem://agents/ask_rem")
622
622
 
623
623
  # Check system status
624
624
  read_resource(uri="rem://status")
@@ -1265,7 +1265,7 @@ async def ask_agent(
1265
1265
  """
1266
1266
  import asyncio
1267
1267
  from ...agentic import create_agent
1268
- from ...agentic.context import get_current_context, agent_context_scope
1268
+ from ...agentic.context import get_current_context, agent_context_scope, get_event_sink, push_event
1269
1269
  from ...agentic.agents.agent_manager import get_agent
1270
1270
  from ...utils.schema_loader import load_agent_schema
1271
1271
 
@@ -1342,16 +1342,162 @@ async def ask_agent(
1342
1342
  if input_data:
1343
1343
  prompt = f"{input_text}\n\nInput data: {json.dumps(input_data)}"
1344
1344
 
1345
+ # Load session history for the sub-agent (CRITICAL for multi-turn conversations)
1346
+ # Sub-agents need to see the full conversation context, not just the summary
1347
+ pydantic_message_history = None
1348
+ if child_context.session_id and settings.postgres.enabled:
1349
+ try:
1350
+ from ...services.session import SessionMessageStore, session_to_pydantic_messages
1351
+ from ...agentic.schema import get_system_prompt
1352
+
1353
+ store = SessionMessageStore(user_id=child_context.user_id or "default")
1354
+ raw_session_history = await store.load_session_messages(
1355
+ session_id=child_context.session_id,
1356
+ user_id=child_context.user_id,
1357
+ compress_on_load=False, # Need full data for reconstruction
1358
+ )
1359
+ if raw_session_history:
1360
+ # Extract agent's system prompt from schema
1361
+ agent_system_prompt = get_system_prompt(schema) if schema else None
1362
+ pydantic_message_history = session_to_pydantic_messages(
1363
+ raw_session_history,
1364
+ system_prompt=agent_system_prompt,
1365
+ )
1366
+ logger.debug(
1367
+ f"ask_agent '{agent_name}': loaded {len(raw_session_history)} session messages "
1368
+ f"-> {len(pydantic_message_history)} pydantic-ai messages"
1369
+ )
1370
+
1371
+ # Audit session history if enabled
1372
+ from ...services.session import audit_session_history
1373
+ audit_session_history(
1374
+ session_id=child_context.session_id,
1375
+ agent_name=agent_name,
1376
+ prompt=prompt,
1377
+ raw_session_history=raw_session_history,
1378
+ pydantic_messages_count=len(pydantic_message_history),
1379
+ )
1380
+ except Exception as e:
1381
+ logger.warning(f"ask_agent '{agent_name}': failed to load session history: {e}")
1382
+ # Fall back to running without history
1383
+
1345
1384
  # Run agent with timeout and context propagation
1346
1385
  logger.info(f"Invoking agent '{agent_name}' with prompt: {prompt[:100]}...")
1347
1386
 
1387
+ # Check if we have an event sink for streaming
1388
+ push_event = get_event_sink()
1389
+ use_streaming = push_event is not None
1390
+
1391
+ streamed_content = "" # Track if content was streamed
1392
+
1348
1393
  try:
1349
1394
  # Set child context for nested tool calls
1350
1395
  with agent_context_scope(child_context):
1351
- result = await asyncio.wait_for(
1352
- agent_runtime.run(prompt),
1353
- timeout=timeout_seconds
1354
- )
1396
+ if use_streaming:
1397
+ # STREAMING MODE: Use iter() and proxy events to parent
1398
+ logger.debug(f"ask_agent '{agent_name}': using streaming mode with event proxying")
1399
+
1400
+ async def run_with_streaming():
1401
+ from pydantic_ai.messages import (
1402
+ PartStartEvent, PartDeltaEvent, PartEndEvent,
1403
+ FunctionToolResultEvent, FunctionToolCallEvent,
1404
+ )
1405
+ from pydantic_ai.agent import Agent
1406
+
1407
+ accumulated_content = []
1408
+ child_tool_calls = []
1409
+
1410
+ # iter() returns an async context manager, not an awaitable
1411
+ iter_kwargs = {"message_history": pydantic_message_history} if pydantic_message_history else {}
1412
+ async with agent_runtime.iter(prompt, **iter_kwargs) as agent_run:
1413
+ async for node in agent_run:
1414
+ if Agent.is_model_request_node(node):
1415
+ async with node.stream(agent_run.ctx) as request_stream:
1416
+ async for event in request_stream:
1417
+ # Proxy part starts (text content only - tool calls handled in is_call_tools_node)
1418
+ if isinstance(event, PartStartEvent):
1419
+ from pydantic_ai.messages import ToolCallPart, TextPart
1420
+ if isinstance(event.part, ToolCallPart):
1421
+ # Track tool call for later (args are incomplete at PartStartEvent)
1422
+ # Full args come via FunctionToolCallEvent in is_call_tools_node
1423
+ child_tool_calls.append({
1424
+ "tool_name": event.part.tool_name,
1425
+ "index": event.index,
1426
+ })
1427
+ elif isinstance(event.part, TextPart):
1428
+ # TextPart may have initial content
1429
+ if event.part.content:
1430
+ accumulated_content.append(event.part.content)
1431
+ await push_event.put({
1432
+ "type": "child_content",
1433
+ "agent_name": agent_name,
1434
+ "content": event.part.content,
1435
+ })
1436
+ # Proxy text content deltas to parent for real-time streaming
1437
+ elif isinstance(event, PartDeltaEvent):
1438
+ if hasattr(event, 'delta') and hasattr(event.delta, 'content_delta'):
1439
+ content = event.delta.content_delta
1440
+ if content:
1441
+ accumulated_content.append(content)
1442
+ # Push content chunk to parent for streaming
1443
+ await push_event.put({
1444
+ "type": "child_content",
1445
+ "agent_name": agent_name,
1446
+ "content": content,
1447
+ })
1448
+
1449
+ elif Agent.is_call_tools_node(node):
1450
+ async with node.stream(agent_run.ctx) as tools_stream:
1451
+ async for tool_event in tools_stream:
1452
+ # FunctionToolCallEvent fires when tool call is parsed
1453
+ # with complete arguments (before execution)
1454
+ if isinstance(tool_event, FunctionToolCallEvent):
1455
+ # Get full arguments from completed tool call
1456
+ tool_args = None
1457
+ if hasattr(tool_event, 'part') and hasattr(tool_event.part, 'args'):
1458
+ raw_args = tool_event.part.args
1459
+ if isinstance(raw_args, str):
1460
+ try:
1461
+ tool_args = json.loads(raw_args)
1462
+ except json.JSONDecodeError:
1463
+ tool_args = {"raw": raw_args}
1464
+ elif isinstance(raw_args, dict):
1465
+ tool_args = raw_args
1466
+ # Push tool start with full arguments
1467
+ await push_event.put({
1468
+ "type": "child_tool_start",
1469
+ "agent_name": agent_name,
1470
+ "tool_name": tool_event.part.tool_name if hasattr(tool_event, 'part') else "unknown",
1471
+ "arguments": tool_args,
1472
+ })
1473
+ elif isinstance(tool_event, FunctionToolResultEvent):
1474
+ result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
1475
+ # Push tool result to parent
1476
+ await push_event.put({
1477
+ "type": "child_tool_result",
1478
+ "agent_name": agent_name,
1479
+ "result": result_content,
1480
+ })
1481
+
1482
+ # Get final result (inside context manager)
1483
+ return agent_run.result, "".join(accumulated_content), child_tool_calls
1484
+
1485
+ result, streamed_content, tool_calls = await asyncio.wait_for(
1486
+ run_with_streaming(),
1487
+ timeout=timeout_seconds
1488
+ )
1489
+ else:
1490
+ # NON-STREAMING MODE: Use run() for backwards compatibility
1491
+ if pydantic_message_history:
1492
+ result = await asyncio.wait_for(
1493
+ agent_runtime.run(prompt, message_history=pydantic_message_history),
1494
+ timeout=timeout_seconds
1495
+ )
1496
+ else:
1497
+ result = await asyncio.wait_for(
1498
+ agent_runtime.run(prompt),
1499
+ timeout=timeout_seconds
1500
+ )
1355
1501
  except asyncio.TimeoutError:
1356
1502
  return {
1357
1503
  "status": "error",
@@ -1365,14 +1511,20 @@ async def ask_agent(
1365
1511
 
1366
1512
  logger.info(f"Agent '{agent_name}' completed successfully")
1367
1513
 
1368
- return {
1514
+ response = {
1369
1515
  "status": "success",
1370
1516
  "output": output,
1371
- "text_response": str(result.output),
1372
1517
  "agent_schema": agent_name,
1373
1518
  "input_text": input_text,
1374
1519
  }
1375
1520
 
1521
+ # Only include text_response if content was NOT streamed
1522
+ # When streaming, child_content events already delivered the content
1523
+ if not use_streaming or not streamed_content:
1524
+ response["text_response"] = str(result.output)
1525
+
1526
+ return response
1527
+
1376
1528
 
1377
1529
  # =============================================================================
1378
1530
  # Test/Debug Tools (for development only)
rem/api/routers/admin.py CHANGED
@@ -31,6 +31,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Background
31
31
  from loguru import logger
32
32
  from pydantic import BaseModel
33
33
 
34
+ from .common import ErrorResponse
35
+
34
36
  from ..deps import require_admin
35
37
  from ...models.entities import Message, Session, SessionMode
36
38
  from ...services.postgres import Repository
@@ -103,7 +105,13 @@ class SystemStats(BaseModel):
103
105
  # =============================================================================
104
106
 
105
107
 
106
- @router.get("/users", response_model=UserListResponse)
108
+ @router.get(
109
+ "/users",
110
+ response_model=UserListResponse,
111
+ responses={
112
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
113
+ },
114
+ )
107
115
  async def list_all_users(
108
116
  user: dict = Depends(require_admin),
109
117
  limit: int = Query(default=50, ge=1, le=100),
@@ -155,7 +163,13 @@ async def list_all_users(
155
163
  return UserListResponse(data=summaries, total=total, has_more=has_more)
156
164
 
157
165
 
158
- @router.get("/sessions", response_model=SessionListResponse)
166
+ @router.get(
167
+ "/sessions",
168
+ response_model=SessionListResponse,
169
+ responses={
170
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
171
+ },
172
+ )
159
173
  async def list_all_sessions(
160
174
  user: dict = Depends(require_admin),
161
175
  user_id: str | None = Query(default=None, description="Filter by user ID"),
@@ -202,7 +216,13 @@ async def list_all_sessions(
202
216
  return SessionListResponse(data=sessions, total=total, has_more=has_more)
203
217
 
204
218
 
205
- @router.get("/messages", response_model=MessageListResponse)
219
+ @router.get(
220
+ "/messages",
221
+ response_model=MessageListResponse,
222
+ responses={
223
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
224
+ },
225
+ )
206
226
  async def list_all_messages(
207
227
  user: dict = Depends(require_admin),
208
228
  user_id: str | None = Query(default=None, description="Filter by user ID"),
@@ -252,7 +272,13 @@ async def list_all_messages(
252
272
  return MessageListResponse(data=messages, total=total, has_more=has_more)
253
273
 
254
274
 
255
- @router.get("/stats", response_model=SystemStats)
275
+ @router.get(
276
+ "/stats",
277
+ response_model=SystemStats,
278
+ responses={
279
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
280
+ },
281
+ )
256
282
  async def get_system_stats(
257
283
  user: dict = Depends(require_admin),
258
284
  ) -> SystemStats: