remdb 0.3.172__py3-none-any.whl → 0.3.223__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 (57) hide show
  1. rem/agentic/README.md +262 -2
  2. rem/agentic/context.py +173 -0
  3. rem/agentic/context_builder.py +12 -2
  4. rem/agentic/mcp/tool_wrapper.py +39 -16
  5. rem/agentic/providers/pydantic_ai.py +46 -43
  6. rem/agentic/schema.py +2 -2
  7. rem/agentic/tools/rem_tools.py +11 -0
  8. rem/api/main.py +1 -1
  9. rem/api/mcp_router/resources.py +64 -8
  10. rem/api/mcp_router/server.py +31 -24
  11. rem/api/mcp_router/tools.py +621 -166
  12. rem/api/routers/admin.py +30 -4
  13. rem/api/routers/auth.py +114 -15
  14. rem/api/routers/chat/completions.py +66 -18
  15. rem/api/routers/chat/sse_events.py +7 -3
  16. rem/api/routers/chat/streaming.py +254 -22
  17. rem/api/routers/common.py +18 -0
  18. rem/api/routers/dev.py +7 -1
  19. rem/api/routers/feedback.py +9 -1
  20. rem/api/routers/messages.py +176 -38
  21. rem/api/routers/models.py +9 -1
  22. rem/api/routers/query.py +12 -1
  23. rem/api/routers/shared_sessions.py +16 -0
  24. rem/auth/jwt.py +19 -4
  25. rem/auth/middleware.py +42 -28
  26. rem/cli/README.md +62 -0
  27. rem/cli/commands/ask.py +1 -1
  28. rem/cli/commands/db.py +148 -70
  29. rem/cli/commands/process.py +171 -43
  30. rem/models/entities/ontology.py +91 -101
  31. rem/schemas/agents/rem.yaml +1 -1
  32. rem/services/content/service.py +18 -5
  33. rem/services/email/service.py +11 -2
  34. rem/services/embeddings/worker.py +26 -12
  35. rem/services/postgres/__init__.py +28 -3
  36. rem/services/postgres/diff_service.py +57 -5
  37. rem/services/postgres/programmable_diff_service.py +635 -0
  38. rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
  39. rem/services/postgres/register_type.py +12 -11
  40. rem/services/postgres/repository.py +46 -25
  41. rem/services/postgres/schema_generator.py +5 -5
  42. rem/services/postgres/sql_builder.py +6 -5
  43. rem/services/session/__init__.py +8 -1
  44. rem/services/session/compression.py +40 -2
  45. rem/services/session/pydantic_messages.py +276 -0
  46. rem/settings.py +28 -0
  47. rem/sql/background_indexes.sql +5 -0
  48. rem/sql/migrations/001_install.sql +157 -10
  49. rem/sql/migrations/002_install_models.sql +160 -132
  50. rem/sql/migrations/004_cache_system.sql +7 -275
  51. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  52. rem/utils/model_helpers.py +101 -0
  53. rem/utils/schema_loader.py +6 -6
  54. {remdb-0.3.172.dist-info → remdb-0.3.223.dist-info}/METADATA +1 -1
  55. {remdb-0.3.172.dist-info → remdb-0.3.223.dist-info}/RECORD +57 -53
  56. {remdb-0.3.172.dist-info → remdb-0.3.223.dist-info}/WHEEL +0 -0
  57. {remdb-0.3.172.dist-info → remdb-0.3.223.dist-info}/entry_points.txt +0 -0
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:
rem/api/routers/auth.py CHANGED
@@ -30,14 +30,17 @@ Access Control Flow (send-code):
30
30
  │ ├── Yes → Check user.tier
31
31
  │ │ ├── tier == BLOCKED → Reject "Account is blocked"
32
32
  │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
33
- │ └── No (new user) → Check EMAIL__TRUSTED_EMAIL_DOMAINS
34
- │ ├── Setting configureddomain in trusted list?
35
- │ ├── Yes Create user & send code
36
- │ └── NoReject "Email domain not allowed for signup"
37
- └── Not configured (empty) → Create user & send code (no restrictions)
33
+ │ └── No (new user) → Check subscriber list first
34
+ │ ├── Email in subscribers table? Allow (create user & send code)
35
+ └── Not a subscriber Check EMAIL__TRUSTED_EMAIL_DOMAINS
36
+ ├── Setting configured → domain in trusted list?
37
+ │ ├── Yes → Create user & send code
38
+ │ │ └── No → Reject "Email domain not allowed for signup"
39
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
38
40
 
39
41
  Key Behaviors:
40
42
  - Existing users: Always allowed to login (unless tier=BLOCKED)
43
+ - Subscribers: Always allowed to login (regardless of email domain)
41
44
  - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
42
45
  - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
43
46
 
@@ -98,6 +101,8 @@ from authlib.integrations.starlette_client import OAuth
98
101
  from pydantic import BaseModel, EmailStr
99
102
  from loguru import logger
100
103
 
104
+ from .common import ErrorResponse
105
+
101
106
  from ...settings import settings
102
107
  from ...services.postgres.service import PostgresService
103
108
  from ...services.user_service import UserService
@@ -156,7 +161,14 @@ class EmailVerifyRequest(BaseModel):
156
161
  code: str
157
162
 
158
163
 
159
- @router.post("/email/send-code")
164
+ @router.post(
165
+ "/email/send-code",
166
+ responses={
167
+ 400: {"model": ErrorResponse, "description": "Invalid request or email rejected"},
168
+ 500: {"model": ErrorResponse, "description": "Failed to send login code"},
169
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
170
+ },
171
+ )
160
172
  async def send_email_code(request: Request, body: EmailSendCodeRequest):
161
173
  """
162
174
  Send a login code to an email address.
@@ -218,7 +230,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
218
230
  await db.disconnect()
219
231
 
220
232
 
221
- @router.post("/email/verify")
233
+ @router.post(
234
+ "/email/verify",
235
+ responses={
236
+ 400: {"model": ErrorResponse, "description": "Invalid or expired code"},
237
+ 500: {"model": ErrorResponse, "description": "Failed to verify login code"},
238
+ 501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
239
+ },
240
+ )
222
241
  async def verify_email_code(request: Request, body: EmailVerifyRequest):
223
242
  """
224
243
  Verify login code and create session with JWT tokens.
@@ -316,7 +335,13 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
316
335
  # =============================================================================
317
336
 
318
337
 
319
- @router.get("/{provider}/login")
338
+ @router.get(
339
+ "/{provider}/login",
340
+ responses={
341
+ 400: {"model": ErrorResponse, "description": "Unknown OAuth provider"},
342
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
343
+ },
344
+ )
320
345
  async def login(provider: str, request: Request):
321
346
  """
322
347
  Initiate OAuth flow with provider.
@@ -358,7 +383,13 @@ async def login(provider: str, request: Request):
358
383
  return await client.authorize_redirect(request, redirect_uri)
359
384
 
360
385
 
361
- @router.get("/{provider}/callback")
386
+ @router.get(
387
+ "/{provider}/callback",
388
+ responses={
389
+ 400: {"model": ErrorResponse, "description": "Authentication failed or unknown provider"},
390
+ 501: {"model": ErrorResponse, "description": "Authentication is disabled"},
391
+ },
392
+ )
362
393
  async def callback(provider: str, request: Request):
363
394
  """
364
395
  OAuth callback endpoint.
@@ -495,7 +526,12 @@ async def logout(request: Request):
495
526
  return {"message": "Logged out successfully"}
496
527
 
497
528
 
498
- @router.get("/me")
529
+ @router.get(
530
+ "/me",
531
+ responses={
532
+ 401: {"model": ErrorResponse, "description": "Not authenticated"},
533
+ },
534
+ )
499
535
  async def me(request: Request):
500
536
  """
501
537
  Get current user information from session or JWT.
@@ -533,11 +569,19 @@ class TokenRefreshRequest(BaseModel):
533
569
  refresh_token: str
534
570
 
535
571
 
536
- @router.post("/token/refresh")
572
+ @router.post(
573
+ "/token/refresh",
574
+ responses={
575
+ 401: {"model": ErrorResponse, "description": "Invalid or expired refresh token"},
576
+ },
577
+ )
537
578
  async def refresh_token(body: TokenRefreshRequest):
538
579
  """
539
580
  Refresh access token using refresh token.
540
581
 
582
+ Fetches the user's current role/tier from the database to ensure
583
+ the new access token reflects their actual permissions.
584
+
541
585
  Args:
542
586
  body: TokenRefreshRequest with refresh_token
543
587
 
@@ -545,7 +589,46 @@ async def refresh_token(body: TokenRefreshRequest):
545
589
  New access token or 401 if refresh token is invalid
546
590
  """
547
591
  jwt_service = get_jwt_service()
548
- result = jwt_service.refresh_access_token(body.refresh_token)
592
+
593
+ # First decode the refresh token to get user_id (without full verification yet)
594
+ payload = jwt_service.decode_without_verification(body.refresh_token)
595
+ if not payload:
596
+ raise HTTPException(
597
+ status_code=401,
598
+ detail="Invalid refresh token format"
599
+ )
600
+
601
+ user_id = payload.get("sub")
602
+ if not user_id:
603
+ raise HTTPException(
604
+ status_code=401,
605
+ detail="Invalid refresh token: missing user ID"
606
+ )
607
+
608
+ # Fetch user from database to get current role/tier
609
+ user_override = None
610
+ if settings.postgres.enabled:
611
+ db = PostgresService()
612
+ try:
613
+ await db.connect()
614
+ user_service = UserService(db)
615
+ user_entity = await user_service.get_user_by_id(user_id)
616
+ if user_entity:
617
+ user_override = {
618
+ "role": user_entity.role or "user",
619
+ "roles": [user_entity.role] if user_entity.role else ["user"],
620
+ "tier": user_entity.tier.value if user_entity.tier else "free",
621
+ "name": user_entity.name,
622
+ }
623
+ logger.debug(f"Refresh token: fetched user {user_id} with role={user_override['role']}, tier={user_override['tier']}")
624
+ except Exception as e:
625
+ logger.warning(f"Could not fetch user for token refresh: {e}")
626
+ # Continue without override - will use defaults
627
+ finally:
628
+ await db.disconnect()
629
+
630
+ # Now do the actual refresh with proper verification
631
+ result = jwt_service.refresh_access_token(body.refresh_token, user_override=user_override)
549
632
 
550
633
  if not result:
551
634
  raise HTTPException(
@@ -556,7 +639,12 @@ async def refresh_token(body: TokenRefreshRequest):
556
639
  return result
557
640
 
558
641
 
559
- @router.post("/token/verify")
642
+ @router.post(
643
+ "/token/verify",
644
+ responses={
645
+ 401: {"model": ErrorResponse, "description": "Missing, invalid, or expired token"},
646
+ },
647
+ )
560
648
  async def verify_token(request: Request):
561
649
  """
562
650
  Verify an access token is valid.
@@ -620,7 +708,12 @@ def verify_dev_token(token: str) -> bool:
620
708
  return token == expected
621
709
 
622
710
 
623
- @router.get("/dev/token")
711
+ @router.get(
712
+ "/dev/token",
713
+ responses={
714
+ 401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
715
+ },
716
+ )
624
717
  async def get_dev_token(request: Request):
625
718
  """
626
719
  Get a development token for testing (non-production only).
@@ -656,7 +749,13 @@ async def get_dev_token(request: Request):
656
749
  }
657
750
 
658
751
 
659
- @router.get("/dev/mock-code/{email}")
752
+ @router.get(
753
+ "/dev/mock-code/{email}",
754
+ responses={
755
+ 401: {"model": ErrorResponse, "description": "Mock codes not available in production"},
756
+ 404: {"model": ErrorResponse, "description": "No code found for email"},
757
+ },
758
+ )
660
759
  async def get_mock_code(email: str, request: Request):
661
760
  """
662
761
  Get the mock login code for testing (non-production only).
@@ -215,7 +215,7 @@ async def ensure_session_with_metadata(
215
215
  Merges request metadata with existing session metadata.
216
216
 
217
217
  Args:
218
- session_id: Session identifier (maps to Session.name)
218
+ session_id: Session UUID from X-Session-Id header
219
219
  user_id: User identifier
220
220
  tenant_id: Tenant identifier
221
221
  is_eval: Whether this is an evaluation session
@@ -228,12 +228,8 @@ async def ensure_session_with_metadata(
228
228
  try:
229
229
  repo = Repository(Session, table_name="sessions")
230
230
 
231
- # Try to load existing session by name (session_id is the name field)
232
- existing_list = await repo.find(
233
- filters={"name": session_id, "tenant_id": tenant_id},
234
- limit=1,
235
- )
236
- existing = existing_list[0] if existing_list else None
231
+ # Look up session by UUID (id field)
232
+ existing = await repo.get_by_id(session_id)
237
233
 
238
234
  if existing:
239
235
  # Merge metadata if provided
@@ -254,9 +250,10 @@ async def ensure_session_with_metadata(
254
250
  await repo.upsert(existing)
255
251
  logger.debug(f"Updated session {session_id} (eval={is_eval}, metadata keys={list(merged_metadata.keys())})")
256
252
  else:
257
- # Create new session
253
+ # Create new session with the provided UUID as the id
258
254
  session = Session(
259
- name=session_id,
255
+ id=session_id, # Use the provided UUID as session id
256
+ name=session_id, # Default name to UUID, can be updated later with LLM-generated name
260
257
  mode=SessionMode.EVALUATION if is_eval else SessionMode.NORMAL,
261
258
  user_id=user_id,
262
259
  tenant_id=tenant_id,
@@ -503,16 +500,51 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
503
500
  logger.error(f"Failed to transcribe audio: {e}")
504
501
  # Fall through with original content (will likely fail at agent)
505
502
 
506
- # Use ContextBuilder to construct complete message list with:
507
- # 1. System context hint (date + user profile)
508
- # 2. Session history (if session_id provided)
509
- # 3. New messages from request body (transcribed if audio)
503
+ # Use ContextBuilder to construct context and basic messages
504
+ # Note: We load session history separately for proper pydantic-ai message_history
510
505
  context, messages = await ContextBuilder.build_from_headers(
511
506
  headers=dict(request.headers),
512
507
  new_messages=new_messages,
513
508
  user_id=temp_context.user_id, # From JWT token (source of truth)
514
509
  )
515
510
 
511
+ # Load raw session history for proper pydantic-ai message_history format
512
+ # This enables proper tool call/return pairing for LLM API compatibility
513
+ from ....services.session import SessionMessageStore, session_to_pydantic_messages, audit_session_history
514
+ from ....agentic.schema import get_system_prompt
515
+
516
+ pydantic_message_history = None
517
+ if context.session_id and settings.postgres.enabled:
518
+ try:
519
+ store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
520
+ raw_session_history = await store.load_session_messages(
521
+ session_id=context.session_id,
522
+ user_id=context.user_id,
523
+ compress_on_load=False, # Don't compress - we need full data for reconstruction
524
+ )
525
+ if raw_session_history:
526
+ # CRITICAL: Extract and pass the agent's system prompt
527
+ # pydantic-ai only auto-adds system prompts when message_history is empty
528
+ # When we pass message_history, we must include the system prompt ourselves
529
+ agent_system_prompt = get_system_prompt(agent_schema) if agent_schema else None
530
+ pydantic_message_history = session_to_pydantic_messages(
531
+ raw_session_history,
532
+ system_prompt=agent_system_prompt,
533
+ )
534
+ logger.debug(f"Converted {len(raw_session_history)} session messages to {len(pydantic_message_history)} pydantic-ai messages (with system prompt)")
535
+
536
+ # Audit session history if enabled (for debugging)
537
+ audit_session_history(
538
+ session_id=context.session_id,
539
+ agent_name=schema_name or "default",
540
+ prompt=body.messages[-1].content if body.messages else "",
541
+ raw_session_history=raw_session_history,
542
+ pydantic_messages_count=len(pydantic_message_history),
543
+ )
544
+ except Exception as e:
545
+ logger.warning(f"Failed to load session history for message_history: {e}")
546
+ # Fall back to old behavior (concatenated prompt)
547
+
516
548
  logger.info(f"Built context with {len(messages)} total messages (includes history + user context)")
517
549
 
518
550
  # Ensure session exists with metadata and eval mode if applicable
@@ -533,9 +565,17 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
533
565
  model_override=body.model, # type: ignore[arg-type]
534
566
  )
535
567
 
536
- # Combine all messages into single prompt for agent
537
- # ContextBuilder already assembled: system context + history + new messages
538
- prompt = "\n".join(msg.content for msg in messages)
568
+ # Build the prompt for the agent
569
+ # If we have proper message_history, use just the latest user message as prompt
570
+ # Otherwise, fall back to concatenating all messages (legacy behavior)
571
+ if pydantic_message_history:
572
+ # Use the latest user message as the prompt, with history passed separately
573
+ user_prompt = body.messages[-1].content if body.messages else ""
574
+ prompt = user_prompt
575
+ logger.debug(f"Using message_history with {len(pydantic_message_history)} messages")
576
+ else:
577
+ # Legacy: Combine all messages into single prompt for agent
578
+ prompt = "\n".join(msg.content for msg in messages)
539
579
 
540
580
  # Generate OpenAI-compatible request ID
541
581
  request_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
@@ -570,6 +610,8 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
570
610
  agent_schema=schema_name,
571
611
  session_id=context.session_id,
572
612
  user_id=context.user_id,
613
+ agent_context=context, # Pass context for multi-agent support
614
+ message_history=pydantic_message_history, # Native pydantic-ai message history
573
615
  ),
574
616
  media_type="text/event-stream",
575
617
  headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
@@ -592,10 +634,16 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
592
634
  ) as span:
593
635
  # Capture trace context from the span we just created
594
636
  trace_id, span_id = get_current_trace_context()
595
- result = await agent.run(prompt)
637
+ if pydantic_message_history:
638
+ result = await agent.run(prompt, message_history=pydantic_message_history)
639
+ else:
640
+ result = await agent.run(prompt)
596
641
  else:
597
642
  # No tracer available, run without tracing
598
- result = await agent.run(prompt)
643
+ if pydantic_message_history:
644
+ result = await agent.run(prompt, message_history=pydantic_message_history)
645
+ else:
646
+ result = await agent.run(prompt)
599
647
 
600
648
  # Determine content format based on response_format request
601
649
  if body.response_format and body.response_format.type == "json_object":
@@ -321,7 +321,11 @@ class MetadataEvent(BaseModel):
321
321
  # Agent info
322
322
  agent_schema: str | None = Field(
323
323
  default=None,
324
- description="Name of the agent schema used for this response (e.g., 'rem', 'query-assistant')"
324
+ description="Name of the top-level agent schema (e.g., 'siggy', 'rem')"
325
+ )
326
+ responding_agent: str | None = Field(
327
+ default=None,
328
+ description="Name of the agent that produced this response (may differ from agent_schema if delegated via ask_agent)"
325
329
  )
326
330
 
327
331
  # Session info
@@ -409,9 +413,9 @@ class ToolCallEvent(BaseModel):
409
413
  default=None,
410
414
  description="Tool arguments (for 'started' status)"
411
415
  )
412
- result: str | None = Field(
416
+ result: str | dict[str, Any] | None = Field(
413
417
  default=None,
414
- description="Tool result summary (for 'completed' status)"
418
+ description="Tool result - full dict for finalize_intake, summary string for others"
415
419
  )
416
420
  error: str | None = Field(
417
421
  default=None,