remdb 0.3.242__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 (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. remdb-0.3.242.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,172 @@
1
+ """
2
+ Anonymous User Tracking & Rate Limiting Middleware.
3
+
4
+ Handles:
5
+ 1. Anonymous Identity: Generates/Validates 'rem_anon_id' cookie.
6
+ 2. Context Injection: Sets request.state.anon_id.
7
+ 3. Rate Limiting: Enforces tenant-aware tiered limits via RateLimitService.
8
+ """
9
+
10
+ import hmac
11
+ import hashlib
12
+ import uuid
13
+ import secrets
14
+ from typing import Optional
15
+
16
+ from fastapi import Request, Response
17
+ from fastapi.responses import JSONResponse
18
+ from starlette.middleware.base import BaseHTTPMiddleware
19
+ from starlette.types import ASGIApp
20
+
21
+ from ...services.postgres.service import PostgresService
22
+ from ...services.rate_limit import RateLimitService
23
+ from ...models.entities.user import UserTier
24
+ from ...settings import settings
25
+
26
+
27
+ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
28
+ """
29
+ Middleware for anonymous user tracking and rate limiting.
30
+
31
+ Design Pattern:
32
+ - Uses a secure, signed cookie for anonymous ID.
33
+ - Enforces rate limits before request processing.
34
+ - Injects anon_id into request state.
35
+ """
36
+
37
+ def __init__(self, app: ASGIApp):
38
+ super().__init__(app)
39
+ # Secret for signing cookies (should be in settings, fallback for safety)
40
+ self.secret_key = settings.auth.session_secret or "fallback-secret-change-me"
41
+ self.cookie_name = "rem_anon_id"
42
+
43
+ # Dedicated DB service for this middleware (one pool per app instance)
44
+ self.db = PostgresService()
45
+ self.rate_limiter = RateLimitService(self.db)
46
+
47
+ # Excluded paths (health checks, static assets, auth callbacks)
48
+ self.excluded_paths = {
49
+ "/health",
50
+ "/docs",
51
+ "/openapi.json",
52
+ "/favicon.ico",
53
+ "/api/auth", # Don't rate limit auth flow heavily
54
+ }
55
+
56
+ async def dispatch(self, request: Request, call_next):
57
+ # 0. Skip excluded paths
58
+ if any(request.url.path.startswith(p) for p in self.excluded_paths):
59
+ return await call_next(request)
60
+
61
+ # 1. Lazy DB Connection
62
+ if not self.db.pool:
63
+ # Note: simple lazy init. In high concurrency startup, might trigger multiple connects
64
+ # followed by disconnects, but asyncpg pool handles this gracefully usually.
65
+ # Ideally hook into lifespan, but middleware is separate.
66
+ if settings.postgres.enabled:
67
+ await self.db.connect()
68
+
69
+ # 2. Identification (Cookie Strategy)
70
+ anon_id = request.cookies.get(self.cookie_name)
71
+ is_new_anon = False
72
+
73
+ if not anon_id or not self._validate_signature(anon_id):
74
+ anon_id = self._generate_signed_id()
75
+ is_new_anon = True
76
+
77
+ # Strip signature for internal use
78
+ raw_anon_id = anon_id.split(".")[0]
79
+ request.state.anon_id = raw_anon_id
80
+
81
+ # 3. Determine User Tier & ID for Rate Limiting
82
+ # Check if user is authenticated (set by AuthMiddleware usually, but that runs AFTER?)
83
+ # Actually middleware runs in reverse order of addition.
84
+ # If AuthMiddleware adds user to request.session, we might need to access session directly.
85
+ # request.user is standard.
86
+
87
+ user = getattr(request.state, "user", None)
88
+ if user:
89
+ # Authenticated User
90
+ identifier = user.get("id") # Assuming user dict or object
91
+ # Determine tier from user object
92
+ tier_str = user.get("tier", UserTier.FREE.value)
93
+ try:
94
+ tier = UserTier(tier_str)
95
+ except ValueError:
96
+ tier = UserTier.FREE
97
+ tenant_id = user.get("tenant_id", "default")
98
+ else:
99
+ # Anonymous User
100
+ identifier = raw_anon_id
101
+ tier = UserTier.ANONYMOUS
102
+ # Tenant ID from header or default
103
+ tenant_id = request.headers.get("X-Tenant-Id", "default")
104
+
105
+ # 4. Rate Limiting (skip if disabled via settings)
106
+ if settings.postgres.enabled and settings.api.rate_limit_enabled:
107
+ is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
108
+ tenant_id=tenant_id,
109
+ identifier=identifier,
110
+ tier=tier
111
+ )
112
+
113
+ if not is_allowed:
114
+ return JSONResponse(
115
+ status_code=429,
116
+ content={
117
+ "error": {
118
+ "code": "rate_limit_exceeded",
119
+ "message": "You have exceeded your rate limit. Please sign in or upgrade to continue.",
120
+ "details": {
121
+ "limit": limit,
122
+ "tier": tier.value,
123
+ "retry_after": 60
124
+ }
125
+ }
126
+ },
127
+ headers={"Retry-After": "60"}
128
+ )
129
+
130
+ # 5. Process Request
131
+ response = await call_next(request)
132
+
133
+ # 6. Set Cookie if new
134
+ if is_new_anon:
135
+ response.set_cookie(
136
+ key=self.cookie_name,
137
+ value=anon_id,
138
+ max_age=31536000, # 1 year
139
+ httponly=True,
140
+ samesite="lax",
141
+ secure=settings.environment == "production"
142
+ )
143
+
144
+ # Add Rate Limit headers (only if rate limiting is enabled)
145
+ if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
146
+ response.headers["X-RateLimit-Limit"] = str(limit)
147
+ response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
148
+
149
+ return response
150
+
151
+ def _generate_signed_id(self) -> str:
152
+ """Generate a UUID4 signed with HMAC."""
153
+ val = str(uuid.uuid4())
154
+ sig = hmac.new(
155
+ self.secret_key.encode(),
156
+ val.encode(),
157
+ hashlib.sha256
158
+ ).hexdigest()[:12] # Short signature
159
+ return f"{val}.{sig}"
160
+
161
+ def _validate_signature(self, signed_val: str) -> bool:
162
+ """Validate the HMAC signature."""
163
+ try:
164
+ val, sig = signed_val.split(".")
165
+ expected_sig = hmac.new(
166
+ self.secret_key.encode(),
167
+ val.encode(),
168
+ hashlib.sha256
169
+ ).hexdigest()[:12]
170
+ return secrets.compare_digest(sig, expected_sig)
171
+ except ValueError:
172
+ return False
@@ -0,0 +1,520 @@
1
+ """
2
+ Admin API Router.
3
+
4
+ Protected endpoints requiring admin role for system management tasks.
5
+
6
+ Endpoints:
7
+ GET /api/admin/users - List all users (admin only)
8
+ GET /api/admin/sessions - List all sessions across users (admin only)
9
+ GET /api/admin/messages - List all messages across users (admin only)
10
+ GET /api/admin/stats - System statistics (admin only)
11
+
12
+ Internal Endpoints (hidden from Swagger, secret-protected):
13
+ POST /api/admin/internal/rebuild-kv - Trigger kv_store rebuild (called by pg_net)
14
+
15
+ All endpoints require:
16
+ 1. Authentication (valid session)
17
+ 2. Admin role in user's roles list
18
+
19
+ Design Pattern:
20
+ - Uses require_admin dependency for role enforcement
21
+ - Cross-tenant queries (no user_id filtering)
22
+ - Audit logging for admin actions
23
+ - Internal endpoints use X-Internal-Secret header for authentication
24
+ """
25
+
26
+ import asyncio
27
+ import threading
28
+ from typing import Literal
29
+
30
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, BackgroundTasks
31
+ from loguru import logger
32
+ from pydantic import BaseModel
33
+
34
+ from .common import ErrorResponse
35
+
36
+ from ..deps import require_admin
37
+ from ...models.entities import Message, Session, SessionMode
38
+ from ...services.postgres import Repository
39
+ from ...settings import settings
40
+
41
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
42
+
43
+ # =============================================================================
44
+ # Internal Router (hidden from Swagger)
45
+ # =============================================================================
46
+
47
+ internal_router = APIRouter(prefix="/internal", include_in_schema=False)
48
+
49
+
50
+ # =============================================================================
51
+ # Response Models
52
+ # =============================================================================
53
+
54
+
55
+ class UserSummary(BaseModel):
56
+ """User summary for admin listing."""
57
+
58
+ id: str
59
+ email: str | None
60
+ name: str | None
61
+ tier: str
62
+ role: str | None
63
+ created_at: str | None
64
+
65
+
66
+ class UserListResponse(BaseModel):
67
+ """Response for user list endpoint."""
68
+
69
+ object: Literal["list"] = "list"
70
+ data: list[UserSummary]
71
+ total: int
72
+ has_more: bool
73
+
74
+
75
+ class SessionListResponse(BaseModel):
76
+ """Response for session list endpoint."""
77
+
78
+ object: Literal["list"] = "list"
79
+ data: list[Session]
80
+ total: int
81
+ has_more: bool
82
+
83
+
84
+ class MessageListResponse(BaseModel):
85
+ """Response for message list endpoint."""
86
+
87
+ object: Literal["list"] = "list"
88
+ data: list[Message]
89
+ total: int
90
+ has_more: bool
91
+
92
+
93
+ class SystemStats(BaseModel):
94
+ """System statistics for admin dashboard."""
95
+
96
+ total_users: int
97
+ total_sessions: int
98
+ total_messages: int
99
+ active_sessions_24h: int
100
+ messages_24h: int
101
+
102
+
103
+ # =============================================================================
104
+ # Admin Endpoints
105
+ # =============================================================================
106
+
107
+
108
+ @router.get(
109
+ "/users",
110
+ response_model=UserListResponse,
111
+ responses={
112
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
113
+ },
114
+ )
115
+ async def list_all_users(
116
+ user: dict = Depends(require_admin),
117
+ limit: int = Query(default=50, ge=1, le=100),
118
+ offset: int = Query(default=0, ge=0),
119
+ ) -> UserListResponse:
120
+ """
121
+ List all users in the system.
122
+
123
+ Admin-only endpoint for user management.
124
+ Returns users across all tenants.
125
+ """
126
+ if not settings.postgres.enabled:
127
+ raise HTTPException(status_code=503, detail="Database not enabled")
128
+
129
+ logger.info(f"Admin {user.get('email')} listing all users")
130
+
131
+ # Import User model dynamically to avoid circular imports
132
+ from ...models.entities import User
133
+
134
+ repo = Repository(User, table_name="users")
135
+
136
+ # No tenant filter - admin sees all
137
+ users = await repo.find(
138
+ filters={},
139
+ order_by="created_at DESC",
140
+ limit=limit + 1,
141
+ offset=offset,
142
+ )
143
+
144
+ has_more = len(users) > limit
145
+ if has_more:
146
+ users = users[:limit]
147
+
148
+ total = await repo.count({})
149
+
150
+ # Convert to summary format
151
+ summaries = [
152
+ UserSummary(
153
+ id=str(u.id),
154
+ email=u.email,
155
+ name=u.name,
156
+ tier=u.tier.value if u.tier else "free",
157
+ role=u.role,
158
+ created_at=u.created_at.isoformat() if u.created_at else None,
159
+ )
160
+ for u in users
161
+ ]
162
+
163
+ return UserListResponse(data=summaries, total=total, has_more=has_more)
164
+
165
+
166
+ @router.get(
167
+ "/sessions",
168
+ response_model=SessionListResponse,
169
+ responses={
170
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
171
+ },
172
+ )
173
+ async def list_all_sessions(
174
+ user: dict = Depends(require_admin),
175
+ user_id: str | None = Query(default=None, description="Filter by user ID"),
176
+ mode: SessionMode | None = Query(default=None, description="Filter by mode"),
177
+ limit: int = Query(default=50, ge=1, le=100),
178
+ offset: int = Query(default=0, ge=0),
179
+ ) -> SessionListResponse:
180
+ """
181
+ List all sessions across all users.
182
+
183
+ Admin-only endpoint for session monitoring.
184
+ Can optionally filter by user_id or mode.
185
+ """
186
+ if not settings.postgres.enabled:
187
+ raise HTTPException(status_code=503, detail="Database not enabled")
188
+
189
+ logger.info(
190
+ f"Admin {user.get('email')} listing sessions "
191
+ f"(user_id={user_id}, mode={mode})"
192
+ )
193
+
194
+ repo = Repository(Session, table_name="sessions")
195
+
196
+ # Build optional filters
197
+ filters: dict = {}
198
+ if user_id:
199
+ filters["user_id"] = user_id
200
+ if mode:
201
+ filters["mode"] = mode.value
202
+
203
+ sessions = await repo.find(
204
+ filters=filters,
205
+ order_by="created_at DESC",
206
+ limit=limit + 1,
207
+ offset=offset,
208
+ )
209
+
210
+ has_more = len(sessions) > limit
211
+ if has_more:
212
+ sessions = sessions[:limit]
213
+
214
+ total = await repo.count(filters)
215
+
216
+ return SessionListResponse(data=sessions, total=total, has_more=has_more)
217
+
218
+
219
+ @router.get(
220
+ "/messages",
221
+ response_model=MessageListResponse,
222
+ responses={
223
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
224
+ },
225
+ )
226
+ async def list_all_messages(
227
+ user: dict = Depends(require_admin),
228
+ user_id: str | None = Query(default=None, description="Filter by user ID"),
229
+ session_id: str | None = Query(default=None, description="Filter by session ID"),
230
+ message_type: str | None = Query(default=None, description="Filter by type"),
231
+ limit: int = Query(default=50, ge=1, le=100),
232
+ offset: int = Query(default=0, ge=0),
233
+ ) -> MessageListResponse:
234
+ """
235
+ List all messages across all users.
236
+
237
+ Admin-only endpoint for message auditing.
238
+ Can filter by user_id, session_id, or message_type.
239
+ """
240
+ if not settings.postgres.enabled:
241
+ raise HTTPException(status_code=503, detail="Database not enabled")
242
+
243
+ logger.info(
244
+ f"Admin {user.get('email')} listing messages "
245
+ f"(user_id={user_id}, session_id={session_id})"
246
+ )
247
+
248
+ repo = Repository(Message, table_name="messages")
249
+
250
+ # Build optional filters
251
+ filters: dict = {}
252
+ if user_id:
253
+ filters["user_id"] = user_id
254
+ if session_id:
255
+ filters["session_id"] = session_id
256
+ if message_type:
257
+ filters["message_type"] = message_type
258
+
259
+ messages = await repo.find(
260
+ filters=filters,
261
+ order_by="created_at DESC",
262
+ limit=limit + 1,
263
+ offset=offset,
264
+ )
265
+
266
+ has_more = len(messages) > limit
267
+ if has_more:
268
+ messages = messages[:limit]
269
+
270
+ total = await repo.count(filters)
271
+
272
+ return MessageListResponse(data=messages, total=total, has_more=has_more)
273
+
274
+
275
+ @router.get(
276
+ "/stats",
277
+ response_model=SystemStats,
278
+ responses={
279
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
280
+ },
281
+ )
282
+ async def get_system_stats(
283
+ user: dict = Depends(require_admin),
284
+ ) -> SystemStats:
285
+ """
286
+ Get system-wide statistics.
287
+
288
+ Admin-only endpoint for monitoring dashboard.
289
+ """
290
+ if not settings.postgres.enabled:
291
+ raise HTTPException(status_code=503, detail="Database not enabled")
292
+
293
+ logger.info(f"Admin {user.get('email')} fetching system stats")
294
+
295
+ from ...models.entities import User
296
+ from ...utils.date_utils import days_ago
297
+
298
+ user_repo = Repository(User, table_name="users")
299
+ session_repo = Repository(Session, table_name="sessions")
300
+ message_repo = Repository(Message, table_name="messages")
301
+
302
+ # Get totals
303
+ total_users = await user_repo.count({})
304
+ total_sessions = await session_repo.count({})
305
+ total_messages = await message_repo.count({})
306
+
307
+ # For 24h stats, we'd need date filtering in Repository
308
+ # For now, return totals (TODO: add date range support)
309
+ return SystemStats(
310
+ total_users=total_users,
311
+ total_sessions=total_sessions,
312
+ total_messages=total_messages,
313
+ active_sessions_24h=0, # TODO: implement
314
+ messages_24h=0, # TODO: implement
315
+ )
316
+
317
+
318
+ # =============================================================================
319
+ # Internal Endpoints (hidden from Swagger, secret-protected)
320
+ # =============================================================================
321
+
322
+
323
+ class RebuildKVRequest(BaseModel):
324
+ """Request body for kv_store rebuild trigger."""
325
+
326
+ user_id: str | None = None
327
+ triggered_by: str = "api"
328
+ timestamp: str | None = None
329
+
330
+
331
+ class RebuildKVResponse(BaseModel):
332
+ """Response from kv_store rebuild trigger."""
333
+
334
+ status: Literal["submitted", "started", "skipped"]
335
+ message: str
336
+ job_method: str | None = None # "sqs" or "thread"
337
+
338
+
339
+ async def _get_internal_secret() -> str | None:
340
+ """
341
+ Get the internal API secret from cache_system_state table.
342
+
343
+ Returns None if the table doesn't exist or secret not found.
344
+ """
345
+ from ...services.postgres import get_postgres_service
346
+
347
+ db = get_postgres_service()
348
+ if not db:
349
+ return None
350
+
351
+ try:
352
+ await db.connect()
353
+ secret = await db.fetchval("SELECT rem_get_cache_api_secret()")
354
+ return secret
355
+ except Exception as e:
356
+ logger.warning(f"Could not get internal API secret: {e}")
357
+ return None
358
+ finally:
359
+ await db.disconnect()
360
+
361
+
362
+ async def _validate_internal_secret(x_internal_secret: str | None = Header(None)):
363
+ """
364
+ Dependency to validate the X-Internal-Secret header.
365
+
366
+ Raises 401 if secret is missing or invalid.
367
+ """
368
+ if not x_internal_secret:
369
+ logger.warning("Internal endpoint called without X-Internal-Secret header")
370
+ raise HTTPException(status_code=401, detail="Missing X-Internal-Secret header")
371
+
372
+ expected_secret = await _get_internal_secret()
373
+ if not expected_secret:
374
+ logger.error("Could not retrieve internal secret from database")
375
+ raise HTTPException(status_code=503, detail="Internal secret not configured")
376
+
377
+ if x_internal_secret != expected_secret:
378
+ logger.warning("Internal endpoint called with invalid secret")
379
+ raise HTTPException(status_code=401, detail="Invalid X-Internal-Secret")
380
+
381
+ return True
382
+
383
+
384
+ def _run_rebuild_in_thread():
385
+ """
386
+ Run the kv_store rebuild in a background thread.
387
+
388
+ This is the fallback when SQS is not available.
389
+ """
390
+
391
+ def rebuild_task():
392
+ """Thread target function."""
393
+ import asyncio
394
+ from ...workers.unlogged_maintainer import UnloggedMaintainer
395
+
396
+ async def _run():
397
+ maintainer = UnloggedMaintainer()
398
+ if not maintainer.db:
399
+ logger.error("Database not configured, cannot rebuild")
400
+ return
401
+ try:
402
+ await maintainer.db.connect()
403
+ await maintainer.rebuild_with_lock()
404
+ except Exception as e:
405
+ logger.error(f"Background rebuild failed: {e}")
406
+ finally:
407
+ await maintainer.db.disconnect()
408
+
409
+ # Create new event loop for this thread
410
+ loop = asyncio.new_event_loop()
411
+ asyncio.set_event_loop(loop)
412
+ try:
413
+ loop.run_until_complete(_run())
414
+ finally:
415
+ loop.close()
416
+
417
+ thread = threading.Thread(target=rebuild_task, name="kv-rebuild-worker")
418
+ thread.daemon = True
419
+ thread.start()
420
+ logger.info(f"Started background rebuild thread: {thread.name}")
421
+
422
+
423
+ def _submit_sqs_rebuild_job_sync(request: RebuildKVRequest) -> bool:
424
+ """
425
+ Submit rebuild job to SQS queue (synchronous).
426
+
427
+ Returns True if job was submitted, False if SQS unavailable.
428
+ """
429
+ import json
430
+
431
+ import boto3
432
+ from botocore.exceptions import ClientError
433
+
434
+ if not settings.sqs.queue_url:
435
+ logger.debug("SQS queue URL not configured, cannot submit SQS job")
436
+ return False
437
+
438
+ try:
439
+ sqs = boto3.client("sqs", region_name=settings.sqs.region)
440
+
441
+ message_body = {
442
+ "action": "rebuild_kv_store",
443
+ "user_id": request.user_id,
444
+ "triggered_by": request.triggered_by,
445
+ "timestamp": request.timestamp,
446
+ }
447
+
448
+ response = sqs.send_message(
449
+ QueueUrl=settings.sqs.queue_url,
450
+ MessageBody=json.dumps(message_body),
451
+ MessageAttributes={
452
+ "action": {"DataType": "String", "StringValue": "rebuild_kv_store"},
453
+ },
454
+ )
455
+
456
+ message_id = response.get("MessageId")
457
+ logger.info(f"Submitted rebuild job to SQS: {message_id}")
458
+ return True
459
+
460
+ except ClientError as e:
461
+ logger.warning(f"Failed to submit SQS job: {e}")
462
+ return False
463
+ except Exception as e:
464
+ logger.warning(f"SQS submission error: {e}")
465
+ return False
466
+
467
+
468
+ async def _submit_sqs_rebuild_job(request: RebuildKVRequest) -> bool:
469
+ """
470
+ Submit rebuild job to SQS queue (async wrapper).
471
+
472
+ Runs boto3 call in thread pool to avoid blocking event loop.
473
+ """
474
+ import asyncio
475
+
476
+ return await asyncio.to_thread(_submit_sqs_rebuild_job_sync, request)
477
+
478
+
479
+ @internal_router.post("/rebuild-kv", response_model=RebuildKVResponse)
480
+ async def trigger_kv_rebuild(
481
+ request: RebuildKVRequest,
482
+ _: bool = Depends(_validate_internal_secret),
483
+ ) -> RebuildKVResponse:
484
+ """
485
+ Trigger kv_store rebuild (internal endpoint, not shown in Swagger).
486
+
487
+ Called by pg_net from PostgreSQL when self-healing detects empty cache.
488
+ Authentication: X-Internal-Secret header must match secret in cache_system_state.
489
+
490
+ Priority:
491
+ 1. Submit job to SQS (if configured) - scales with KEDA
492
+ 2. Fallback to background thread - runs in same process
493
+
494
+ Note: This endpoint returns immediately. Rebuild happens asynchronously.
495
+ """
496
+ logger.info(
497
+ f"Rebuild kv_store requested by {request.triggered_by} "
498
+ f"(user_id={request.user_id})"
499
+ )
500
+
501
+ # Try SQS first
502
+ if await _submit_sqs_rebuild_job(request):
503
+ return RebuildKVResponse(
504
+ status="submitted",
505
+ message="Rebuild job submitted to SQS queue",
506
+ job_method="sqs",
507
+ )
508
+
509
+ # Fallback to background thread
510
+ _run_rebuild_in_thread()
511
+
512
+ return RebuildKVResponse(
513
+ status="started",
514
+ message="Rebuild started in background thread (SQS unavailable)",
515
+ job_method="thread",
516
+ )
517
+
518
+
519
+ # Include internal router in main router
520
+ router.include_router(internal_router)