remdb 0.3.200__py3-none-any.whl → 0.3.226__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.

@@ -63,6 +63,8 @@ from fastapi import APIRouter, Header, HTTPException, Request, Response
63
63
  from loguru import logger
64
64
  from pydantic import BaseModel, Field
65
65
 
66
+ from .common import ErrorResponse
67
+
66
68
  from ..deps import get_user_id_from_request
67
69
  from ...models.entities import Feedback
68
70
  from ...services.postgres import Repository
@@ -121,7 +123,13 @@ class FeedbackResponse(BaseModel):
121
123
  # =============================================================================
122
124
 
123
125
 
124
- @router.post("/messages/feedback", response_model=FeedbackResponse)
126
+ @router.post(
127
+ "/messages/feedback",
128
+ response_model=FeedbackResponse,
129
+ responses={
130
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
131
+ },
132
+ )
125
133
  async def submit_feedback(
126
134
  request: Request,
127
135
  response: Response,
@@ -16,6 +16,7 @@ Endpoints:
16
16
  """
17
17
 
18
18
  from datetime import datetime
19
+ from enum import Enum
19
20
  from typing import Literal
20
21
  from uuid import UUID
21
22
 
@@ -23,6 +24,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
23
24
  from loguru import logger
24
25
  from pydantic import BaseModel, Field
25
26
 
27
+ from .common import ErrorResponse
28
+
26
29
  from ..deps import (
27
30
  get_current_user,
28
31
  get_user_filter,
@@ -38,6 +41,18 @@ from ...utils.date_utils import parse_iso, utc_now
38
41
  router = APIRouter(prefix="/api/v1")
39
42
 
40
43
 
44
+ # =============================================================================
45
+ # Enums
46
+ # =============================================================================
47
+
48
+
49
+ class SortOrder(str, Enum):
50
+ """Sort order for list queries."""
51
+
52
+ ASC = "asc"
53
+ DESC = "desc"
54
+
55
+
41
56
  # =============================================================================
42
57
  # Request/Response Models
43
58
  # =============================================================================
@@ -134,7 +149,14 @@ class SessionsQueryResponse(BaseModel):
134
149
  # =============================================================================
135
150
 
136
151
 
137
- @router.get("/messages", response_model=MessageListResponse, tags=["messages"])
152
+ @router.get(
153
+ "/messages",
154
+ response_model=MessageListResponse,
155
+ tags=["messages"],
156
+ responses={
157
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
158
+ },
159
+ )
138
160
  async def list_messages(
139
161
  request: Request,
140
162
  mine: bool = Query(default=False, description="Only show my messages (uses JWT identity)"),
@@ -151,6 +173,7 @@ async def list_messages(
151
173
  ),
152
174
  limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
153
175
  offset: int = Query(default=0, ge=0, description="Offset for pagination"),
176
+ sort: SortOrder = Query(default=SortOrder.DESC, description="Sort order by created_at (asc or desc)"),
154
177
  ) -> MessageListResponse:
155
178
  """
156
179
  List messages with optional filters.
@@ -166,8 +189,9 @@ async def list_messages(
166
189
  - session_id: Filter by conversation session
167
190
  - start_date/end_date: Filter by creation time range (ISO 8601 format)
168
191
  - message_type: Filter by role (user, assistant, system, tool)
192
+ - sort: Sort order by created_at (asc or desc, default: desc)
169
193
 
170
- Returns paginated results ordered by created_at descending.
194
+ Returns paginated results ordered by created_at.
171
195
  """
172
196
  if not settings.postgres.enabled:
173
197
  raise HTTPException(status_code=503, detail="Database not enabled")
@@ -189,6 +213,7 @@ async def list_messages(
189
213
 
190
214
  # Apply optional filters
191
215
  if session_id:
216
+ # session_id is the session UUID - use directly
192
217
  filters["session_id"] = session_id
193
218
  if message_type:
194
219
  filters["message_type"] = message_type
@@ -200,12 +225,15 @@ async def list_messages(
200
225
  f"filters={filters}"
201
226
  )
202
227
 
228
+ # Build order_by clause based on sort parameter
229
+ order_by = f"created_at {sort.value.upper()}"
230
+
203
231
  # For date filtering, we need custom SQL (not supported by basic Repository)
204
232
  # For now, fetch all matching base filters and filter in Python
205
233
  # TODO: Extend Repository to support date range filters
206
234
  messages = await repo.find(
207
235
  filters,
208
- order_by="created_at DESC",
236
+ order_by=order_by,
209
237
  limit=limit + 1, # Fetch one extra to determine has_more
210
238
  offset=offset,
211
239
  )
@@ -241,7 +269,16 @@ async def list_messages(
241
269
  return MessageListResponse(data=messages, total=total, has_more=has_more)
242
270
 
243
271
 
244
- @router.get("/messages/{message_id}", response_model=Message, tags=["messages"])
272
+ @router.get(
273
+ "/messages/{message_id}",
274
+ response_model=Message,
275
+ tags=["messages"],
276
+ responses={
277
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
278
+ 404: {"model": ErrorResponse, "description": "Message not found"},
279
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
280
+ },
281
+ )
245
282
  async def get_message(
246
283
  request: Request,
247
284
  message_id: str,
@@ -287,7 +324,14 @@ async def get_message(
287
324
  # =============================================================================
288
325
 
289
326
 
290
- @router.get("/sessions", response_model=SessionsQueryResponse, tags=["sessions"])
327
+ @router.get(
328
+ "/sessions",
329
+ response_model=SessionsQueryResponse,
330
+ tags=["sessions"],
331
+ responses={
332
+ 503: {"model": ErrorResponse, "description": "Database not enabled or connection failed"},
333
+ },
334
+ )
291
335
  async def list_sessions(
292
336
  request: Request,
293
337
  user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
@@ -400,7 +444,15 @@ async def list_sessions(
400
444
  )
401
445
 
402
446
 
403
- @router.post("/sessions", response_model=Session, status_code=201, tags=["sessions"])
447
+ @router.post(
448
+ "/sessions",
449
+ response_model=Session,
450
+ status_code=201,
451
+ tags=["sessions"],
452
+ responses={
453
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
454
+ },
455
+ )
404
456
  async def create_session(
405
457
  request_body: SessionCreateRequest,
406
458
  user: dict = Depends(require_admin),
@@ -452,7 +504,16 @@ async def create_session(
452
504
  return result # type: ignore
453
505
 
454
506
 
455
- @router.get("/sessions/{session_id}", response_model=Session, tags=["sessions"])
507
+ @router.get(
508
+ "/sessions/{session_id}",
509
+ response_model=Session,
510
+ tags=["sessions"],
511
+ responses={
512
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
513
+ 404: {"model": ErrorResponse, "description": "Session not found"},
514
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
515
+ },
516
+ )
456
517
  async def get_session(
457
518
  request: Request,
458
519
  session_id: str,
@@ -465,7 +526,7 @@ async def get_session(
465
526
  - Admin users: Can access any session
466
527
 
467
528
  Args:
468
- session_id: UUID or name of the session
529
+ session_id: UUID of the session
469
530
 
470
531
  Returns:
471
532
  Session object if found
@@ -481,12 +542,7 @@ async def get_session(
481
542
  session = await repo.get_by_id(session_id)
482
543
 
483
544
  if not session:
484
- # Try finding by name
485
- sessions = await repo.find({"name": session_id}, limit=1)
486
- if sessions:
487
- session = sessions[0]
488
- else:
489
- raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
545
+ raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
490
546
 
491
547
  # Check access: admin or owner
492
548
  current_user = get_current_user(request)
@@ -498,7 +554,16 @@ async def get_session(
498
554
  return session
499
555
 
500
556
 
501
- @router.put("/sessions/{session_id}", response_model=Session, tags=["sessions"])
557
+ @router.put(
558
+ "/sessions/{session_id}",
559
+ response_model=Session,
560
+ tags=["sessions"],
561
+ responses={
562
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
563
+ 404: {"model": ErrorResponse, "description": "Session not found"},
564
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
565
+ },
566
+ )
502
567
  async def update_session(
503
568
  request: Request,
504
569
  session_id: str,
rem/api/routers/models.py CHANGED
@@ -15,6 +15,8 @@ from typing import Literal
15
15
  from fastapi import APIRouter, HTTPException
16
16
  from pydantic import BaseModel, Field
17
17
 
18
+ from .common import ErrorResponse
19
+
18
20
  from rem.agentic.llm_provider_models import (
19
21
  ModelInfo,
20
22
  AVAILABLE_MODELS,
@@ -57,7 +59,13 @@ async def list_models() -> ModelsResponse:
57
59
  return ModelsResponse(data=AVAILABLE_MODELS)
58
60
 
59
61
 
60
- @router.get("/models/{model_id:path}", response_model=ModelInfo)
62
+ @router.get(
63
+ "/models/{model_id:path}",
64
+ response_model=ModelInfo,
65
+ responses={
66
+ 404: {"model": ErrorResponse, "description": "Model not found"},
67
+ },
68
+ )
61
69
  async def get_model(model_id: str) -> ModelInfo:
62
70
  """
63
71
  Get information about a specific model.
rem/api/routers/query.py CHANGED
@@ -86,6 +86,8 @@ from fastapi import APIRouter, Header, HTTPException
86
86
  from loguru import logger
87
87
  from pydantic import BaseModel, Field
88
88
 
89
+ from .common import ErrorResponse
90
+
89
91
  from ...services.postgres import get_postgres_service
90
92
  from ...services.rem.service import RemService
91
93
  from ...services.rem.parser import RemQueryParser
@@ -213,7 +215,16 @@ class QueryResponse(BaseModel):
213
215
  )
214
216
 
215
217
 
216
- @router.post("/query", response_model=QueryResponse)
218
+ @router.post(
219
+ "/query",
220
+ response_model=QueryResponse,
221
+ responses={
222
+ 400: {"model": ErrorResponse, "description": "Invalid query or missing required fields"},
223
+ 500: {"model": ErrorResponse, "description": "Query execution failed"},
224
+ 501: {"model": ErrorResponse, "description": "Feature not yet implemented"},
225
+ 503: {"model": ErrorResponse, "description": "Database not configured or unavailable"},
226
+ },
227
+ )
217
228
  async def execute_query(
218
229
  request: QueryRequest,
219
230
  x_user_id: str | None = Header(default=None, description="User ID for query isolation (optional, uses default if not provided)"),
@@ -18,6 +18,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
18
18
  from loguru import logger
19
19
  from pydantic import BaseModel, Field
20
20
 
21
+ from .common import ErrorResponse
22
+
21
23
  from ..deps import get_current_user, require_auth
22
24
  from ...models.entities import (
23
25
  Message,
@@ -83,6 +85,10 @@ class ShareSessionResponse(BaseModel):
83
85
  response_model=ShareSessionResponse,
84
86
  status_code=201,
85
87
  tags=["sessions"],
88
+ responses={
89
+ 400: {"model": ErrorResponse, "description": "Session already shared with this user"},
90
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
91
+ },
86
92
  )
87
93
  async def share_session(
88
94
  request: Request,
@@ -175,6 +181,10 @@ async def share_session(
175
181
  "/sessions/{session_id}/share/{shared_with_user_id}",
176
182
  status_code=200,
177
183
  tags=["sessions"],
184
+ responses={
185
+ 404: {"model": ErrorResponse, "description": "Share not found"},
186
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
187
+ },
178
188
  )
179
189
  async def remove_session_share(
180
190
  request: Request,
@@ -250,6 +260,9 @@ async def remove_session_share(
250
260
  "/sessions/shared-with-me",
251
261
  response_model=SharedWithMeResponse,
252
262
  tags=["sessions"],
263
+ responses={
264
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
265
+ },
253
266
  )
254
267
  async def get_shared_with_me(
255
268
  request: Request,
@@ -328,6 +341,9 @@ async def get_shared_with_me(
328
341
  "/sessions/shared-with-me/{owner_user_id}/messages",
329
342
  response_model=SharedMessagesResponse,
330
343
  tags=["sessions"],
344
+ responses={
345
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
346
+ },
331
347
  )
332
348
  async def get_shared_messages(
333
349
  request: Request,
rem/auth/jwt.py CHANGED
@@ -260,12 +260,16 @@ class JWTService:
260
260
  "tenant_id": payload.get("tenant_id", "default"),
261
261
  }
262
262
 
263
- def refresh_access_token(self, refresh_token: str) -> dict | None:
263
+ def refresh_access_token(
264
+ self, refresh_token: str, user_override: dict | None = None
265
+ ) -> dict | None:
264
266
  """
265
267
  Create new access token using refresh token.
266
268
 
267
269
  Args:
268
270
  refresh_token: Valid refresh token
271
+ user_override: Optional dict with user fields to override defaults
272
+ (e.g., role, roles, tier, name from database lookup)
269
273
 
270
274
  Returns:
271
275
  New token dict or None if refresh token is invalid
@@ -285,8 +289,7 @@ class JWTService:
285
289
  logger.debug("Refresh token expired")
286
290
  return None
287
291
 
288
- # Create new access token with minimal info from refresh token
289
- # In production, you'd look up the full user from database
292
+ # Build user dict with defaults
290
293
  user = {
291
294
  "id": payload.get("sub"),
292
295
  "email": payload.get("email"),
@@ -294,16 +297,28 @@ class JWTService:
294
297
  "provider": "email",
295
298
  "tenant_id": "default",
296
299
  "tier": "free",
300
+ "role": "user",
297
301
  "roles": ["user"],
298
302
  }
299
303
 
304
+ # Apply overrides from database lookup if provided
305
+ if user_override:
306
+ if user_override.get("role"):
307
+ user["role"] = user_override["role"]
308
+ if user_override.get("roles"):
309
+ user["roles"] = user_override["roles"]
310
+ if user_override.get("tier"):
311
+ user["tier"] = user_override["tier"]
312
+ if user_override.get("name"):
313
+ user["name"] = user_override["name"]
314
+
300
315
  # Only return new access token, keep same refresh token
301
316
  now = int(time.time())
302
317
  access_payload = {
303
318
  "sub": user["id"],
304
319
  "email": user["email"],
305
320
  "name": user["name"],
306
- "role": user.get("role"),
321
+ "role": user["role"],
307
322
  "tier": user["tier"],
308
323
  "roles": user["roles"],
309
324
  "provider": user["provider"],
rem/cli/commands/ask.py CHANGED
@@ -71,16 +71,18 @@ async def run_agent_streaming(
71
71
  max_turns: int = 10,
72
72
  context: AgentContext | None = None,
73
73
  max_iterations: int | None = None,
74
+ user_message: str | None = None,
74
75
  ) -> None:
75
76
  """
76
- Run agent in streaming mode using agent.iter() with usage limits.
77
+ Run agent in streaming mode using the SAME code path as the API.
77
78
 
78
- Design Pattern:
79
- - Use agent.iter() for complete execution with tool call visibility
80
- - run_stream() stops after first output, missing tool calls
81
- - Stream tool call markers: [Calling: tool_name]
82
- - Stream text content deltas as they arrive
83
- - Show final structured result
79
+ This uses stream_openai_response_with_save from the API to ensure:
80
+ 1. Tool calls are saved as separate "tool" messages (not embedded in content)
81
+ 2. Assistant response is clean text only (no [Calling: ...] markers)
82
+ 3. CLI testing is equivalent to API testing
83
+
84
+ The CLI displays tool calls as [Calling: tool_name] for visibility,
85
+ but these are NOT saved to the database.
84
86
 
85
87
  Args:
86
88
  agent: Pydantic AI agent
@@ -88,88 +90,66 @@ async def run_agent_streaming(
88
90
  max_turns: Maximum turns for agent execution (not used in current API)
89
91
  context: Optional AgentContext for session persistence
90
92
  max_iterations: Maximum iterations/requests (from agent schema or settings)
93
+ user_message: The user's original message (for database storage)
91
94
  """
92
- from pydantic_ai import UsageLimits
93
- from rem.utils.date_utils import to_iso_with_z, utc_now
95
+ import json
96
+ from rem.api.routers.chat.streaming import stream_openai_response_with_save, save_user_message
94
97
 
95
98
  logger.info("Running agent in streaming mode...")
96
99
 
97
100
  try:
98
- # Import event types for streaming
99
- from pydantic_ai import Agent as PydanticAgent
100
- from pydantic_ai.messages import PartStartEvent, PartDeltaEvent, TextPartDelta, ToolCallPart
101
-
102
- # Accumulate assistant response for session persistence
103
- assistant_response_parts = []
104
-
105
- # Use agent.iter() to get complete execution with tool calls
106
- usage_limits = UsageLimits(request_limit=max_iterations) if max_iterations else None
107
- async with agent.iter(prompt, usage_limits=usage_limits) as agent_run:
108
- async for node in agent_run:
109
- # Check if this is a model request node (includes tool calls and text)
110
- if PydanticAgent.is_model_request_node(node):
111
- # Stream events from model request
112
- request_stream: Any
113
- async with node.stream(agent_run.ctx) as request_stream:
114
- async for event in request_stream:
115
- # Tool call start event
116
- if isinstance(event, PartStartEvent) and isinstance(
117
- event.part, ToolCallPart
118
- ):
119
- tool_marker = f"\n[Calling: {event.part.tool_name}]"
120
- print(tool_marker, flush=True)
121
- assistant_response_parts.append(tool_marker)
122
-
123
- # Text content delta
124
- elif isinstance(event, PartDeltaEvent) and isinstance(
125
- event.delta, TextPartDelta
126
- ):
127
- print(event.delta.content_delta, end="", flush=True)
128
- assistant_response_parts.append(event.delta.content_delta)
129
-
130
- print("\n") # Final newline after streaming
131
-
132
- # Get final result from agent_run
133
- result = agent_run.result
134
- if hasattr(result, "output"):
135
- logger.info("Final structured result:")
136
- output = result.output
137
- from rem.agentic.serialization import serialize_agent_result
138
- output_json = json.dumps(serialize_agent_result(output), indent=2)
139
- print(output_json)
140
- assistant_response_parts.append(f"\n{output_json}")
141
-
142
- # Save session messages (if session_id provided and postgres enabled)
143
- if context and context.session_id and settings.postgres.enabled:
144
- from ...services.session.compression import SessionMessageStore
145
-
146
- # Extract just the user query from prompt
147
- # Prompt format from ContextBuilder: system + history + user message
148
- # We need to extract the last user message
149
- user_message_content = prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt
150
-
151
- user_message = {
152
- "role": "user",
153
- "content": user_message_content,
154
- "timestamp": to_iso_with_z(utc_now()),
155
- }
156
-
157
- assistant_message = {
158
- "role": "assistant",
159
- "content": "".join(assistant_response_parts),
160
- "timestamp": to_iso_with_z(utc_now()),
161
- }
162
-
163
- # Store messages with compression
164
- store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
165
- await store.store_session_messages(
101
+ # Save user message BEFORE streaming (same as API, using shared utility)
102
+ if context and context.session_id and user_message:
103
+ await save_user_message(
166
104
  session_id=context.session_id,
167
- messages=[user_message, assistant_message],
168
105
  user_id=context.user_id,
169
- compress=True,
106
+ content=user_message,
170
107
  )
171
108
 
172
- logger.debug(f"Saved conversation to session {context.session_id}")
109
+ # Use the API streaming code path for consistency
110
+ # This properly handles tool calls and message persistence
111
+ model_name = getattr(agent, 'model', 'unknown')
112
+ if hasattr(model_name, 'model_name'):
113
+ model_name = model_name.model_name
114
+ elif hasattr(model_name, 'name'):
115
+ model_name = model_name.name
116
+ else:
117
+ model_name = str(model_name)
118
+
119
+ async for chunk in stream_openai_response_with_save(
120
+ agent=agent.agent if hasattr(agent, 'agent') else agent,
121
+ prompt=prompt,
122
+ model=model_name,
123
+ session_id=context.session_id if context else None,
124
+ user_id=context.user_id if context else None,
125
+ agent_context=context,
126
+ ):
127
+ # Parse SSE chunks for CLI display
128
+ if chunk.startswith("event: tool_call"):
129
+ # Extract tool call info from next data line
130
+ continue
131
+ elif chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
132
+ try:
133
+ data_str = chunk[6:].strip()
134
+ if data_str:
135
+ data = json.loads(data_str)
136
+ # Check for tool_call event
137
+ if data.get("type") == "tool_call":
138
+ tool_name = data.get("tool_name", "tool")
139
+ status = data.get("status", "")
140
+ if status == "started":
141
+ print(f"\n[Calling: {tool_name}]", flush=True)
142
+ # Check for text content (OpenAI format)
143
+ elif "choices" in data and data["choices"]:
144
+ delta = data["choices"][0].get("delta", {})
145
+ content = delta.get("content")
146
+ if content:
147
+ print(content, end="", flush=True)
148
+ except (json.JSONDecodeError, KeyError, IndexError):
149
+ pass
150
+
151
+ print("\n") # Final newline after streaming
152
+ logger.info("Final structured result:")
173
153
 
174
154
  except Exception as e:
175
155
  logger.error(f"Agent execution failed: {e}")
@@ -549,7 +529,7 @@ async def _ask_async(
549
529
 
550
530
  # Run agent with session persistence
551
531
  if stream:
552
- await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context)
532
+ await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context, user_message=query)
553
533
  else:
554
534
  await run_agent_non_streaming(
555
535
  agent,
@@ -206,9 +206,9 @@ def process_ingest(
206
206
  if category:
207
207
  entity_data["category"] = category
208
208
 
209
- # Scoping: user_id for private data, None for public/shared
210
- # tenant_id=None and user_id=None means PUBLIC data (visible to all)
211
- entity_data["tenant_id"] = user_id # None = public/shared
209
+ # Scoping: user_id for private data, "public" for shared
210
+ # tenant_id="public" is the default for shared knowledge bases
211
+ entity_data["tenant_id"] = user_id or "public"
212
212
  entity_data["user_id"] = user_id # None = public/shared
213
213
 
214
214
  # For ontologies, add URI
@@ -103,32 +103,30 @@ class Ontology(CoreModel):
103
103
  tags=["cv", "engineering"]
104
104
  )
105
105
 
106
- # Direct-loaded: Medical knowledge base from git
107
- disorder_ontology = Ontology(
108
- name="panic-disorder",
109
- uri="git://bwolfson-siggie/Siggy-MVP/ontology/disorders/anxiety/panic-disorder.md",
110
- content="# Panic Disorder\\n\\nPanic disorder is characterized by...",
106
+ # Direct-loaded: Knowledge base from git
107
+ api_docs = Ontology(
108
+ name="rest-api-guide",
109
+ uri="git://example-org/docs/api/rest-api-guide.md",
110
+ content="# REST API Guide\\n\\nThis guide covers RESTful API design...",
111
111
  extracted_data={
112
- "type": "disorder",
113
- "category": "anxiety",
114
- "icd10": "F41.0",
115
- "dsm5_criteria": ["A", "B", "C", "D"],
112
+ "type": "documentation",
113
+ "category": "api",
114
+ "version": "2.0",
116
115
  },
117
- tags=["disorder", "anxiety", "dsm5"]
116
+ tags=["api", "rest", "documentation"]
118
117
  )
119
118
 
120
- # Direct-loaded: Clinical procedure from git
121
- scid_node = Ontology(
122
- name="scid-5-f1",
123
- uri="git://bwolfson-siggie/Siggy-MVP/ontology/procedures/scid-5/module-f/scid-5-f1.md",
124
- content="# scid-5-f1: Panic Attack Screening\\n\\n...",
119
+ # Direct-loaded: Technical spec from git
120
+ config_spec = Ontology(
121
+ name="config-schema",
122
+ uri="git://example-org/docs/specs/config-schema.md",
123
+ content="# Configuration Schema\\n\\nThis document defines...",
125
124
  extracted_data={
126
- "type": "procedure",
127
- "module": "F",
128
- "section": "Panic Disorder",
129
- "dsm5_criterion": "Panic Attack Specifier",
125
+ "type": "specification",
126
+ "format": "yaml",
127
+ "version": "1.0",
130
128
  },
131
- tags=["scid-5", "procedure", "anxiety"]
129
+ tags=["config", "schema", "specification"]
132
130
  )
133
131
  """
134
132
 
@@ -124,7 +124,7 @@ json_schema_extra:
124
124
 
125
125
  # Explicit resource declarations for reference data
126
126
  resources:
127
- - uri: rem://schemas
127
+ - uri: rem://agents
128
128
  name: Agent Schemas List
129
129
  description: List all available agent schemas in the system
130
130
  - uri: rem://status
@@ -31,17 +31,27 @@ if TYPE_CHECKING:
31
31
  from .service import PostgresService
32
32
 
33
33
 
34
+ # Singleton instance for connection pool reuse
35
+ _postgres_instance: "PostgresService | None" = None
36
+
37
+
34
38
  def get_postgres_service() -> "PostgresService | None":
35
39
  """
36
- Get PostgresService instance with connection string from settings.
40
+ Get PostgresService singleton instance.
37
41
 
38
42
  Returns None if Postgres is disabled.
43
+ Uses singleton pattern to prevent connection pool exhaustion.
39
44
  """
45
+ global _postgres_instance
46
+
40
47
  if not settings.postgres.enabled:
41
48
  return None
42
-
43
- from .service import PostgresService
44
- return PostgresService()
49
+
50
+ if _postgres_instance is None:
51
+ from .service import PostgresService
52
+ _postgres_instance = PostgresService()
53
+
54
+ return _postgres_instance
45
55
 
46
56
  T = TypeVar("T", bound=BaseModel)
47
57