remdb 0.3.127__py3-none-any.whl → 0.3.172__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 (62) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +36 -9
  5. rem/agentic/mcp/tool_wrapper.py +132 -15
  6. rem/agentic/providers/phoenix.py +371 -108
  7. rem/agentic/providers/pydantic_ai.py +163 -45
  8. rem/agentic/schema.py +8 -4
  9. rem/api/deps.py +3 -5
  10. rem/api/main.py +22 -3
  11. rem/api/mcp_router/resources.py +15 -10
  12. rem/api/mcp_router/server.py +2 -0
  13. rem/api/mcp_router/tools.py +94 -2
  14. rem/api/middleware/tracking.py +5 -5
  15. rem/api/routers/auth.py +349 -6
  16. rem/api/routers/chat/completions.py +5 -3
  17. rem/api/routers/chat/streaming.py +95 -22
  18. rem/api/routers/messages.py +24 -15
  19. rem/auth/__init__.py +13 -3
  20. rem/auth/jwt.py +352 -0
  21. rem/auth/middleware.py +115 -10
  22. rem/auth/providers/__init__.py +4 -1
  23. rem/auth/providers/email.py +215 -0
  24. rem/cli/commands/configure.py +3 -4
  25. rem/cli/commands/experiments.py +226 -50
  26. rem/cli/commands/session.py +336 -0
  27. rem/cli/dreaming.py +2 -2
  28. rem/cli/main.py +2 -0
  29. rem/models/core/experiment.py +58 -14
  30. rem/models/entities/__init__.py +4 -0
  31. rem/models/entities/ontology.py +1 -1
  32. rem/models/entities/ontology_config.py +1 -1
  33. rem/models/entities/subscriber.py +175 -0
  34. rem/models/entities/user.py +1 -0
  35. rem/schemas/agents/core/agent-builder.yaml +235 -0
  36. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  37. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  38. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  39. rem/services/__init__.py +3 -1
  40. rem/services/content/service.py +4 -3
  41. rem/services/email/__init__.py +10 -0
  42. rem/services/email/service.py +513 -0
  43. rem/services/email/templates.py +360 -0
  44. rem/services/postgres/README.md +38 -0
  45. rem/services/postgres/diff_service.py +19 -3
  46. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  47. rem/services/postgres/repository.py +5 -4
  48. rem/services/session/compression.py +113 -50
  49. rem/services/session/reload.py +14 -7
  50. rem/services/user_service.py +41 -9
  51. rem/settings.py +292 -5
  52. rem/sql/migrations/001_install.sql +1 -1
  53. rem/sql/migrations/002_install_models.sql +91 -91
  54. rem/sql/migrations/005_schema_update.sql +145 -0
  55. rem/utils/README.md +45 -0
  56. rem/utils/files.py +157 -1
  57. rem/utils/schema_loader.py +45 -7
  58. rem/utils/vision.py +1 -1
  59. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/METADATA +7 -5
  60. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/RECORD +62 -52
  61. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  62. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/services/__init__.py CHANGED
@@ -4,13 +4,15 @@ REM Services
4
4
  Service layer for REM system operations:
5
5
  - PostgresService: PostgreSQL/CloudNativePG database operations
6
6
  - RemService: REM query execution and graph operations
7
+ - EmailService: Transactional emails and passwordless login
7
8
 
8
9
  For file/S3 operations, use rem.services.fs instead:
9
10
  from rem.services.fs import FS, S3Provider
10
11
  """
11
12
 
13
+ from .email import EmailService
12
14
  from .fs.service import FileSystemService
13
15
  from .postgres import PostgresService
14
16
  from .rem import RemService
15
17
 
16
- __all__ = ["PostgresService", "RemService", "FileSystemService"]
18
+ __all__ = ["EmailService", "PostgresService", "RemService", "FileSystemService"]
@@ -666,10 +666,11 @@ class ContentService:
666
666
  # IMPORTANT: category field distinguishes agents from evaluators
667
667
  # - kind=agent → category="agent" (AI agents with tools/resources)
668
668
  # - kind=evaluator → category="evaluator" (LLM-as-a-Judge evaluators)
669
- # Schemas (agents/evaluators) default to system tenant for shared access
669
+ # User-scoped schemas: if user_id provided, scope to user's tenant
670
+ # System schemas: if no user_id, use "system" tenant for shared access
670
671
  schema_entity = Schema(
671
- tenant_id="system",
672
- user_id=None,
672
+ tenant_id=user_id or "system",
673
+ user_id=user_id,
673
674
  name=name,
674
675
  spec=schema_data,
675
676
  category=kind, # Maps kind → category for database filtering
@@ -0,0 +1,10 @@
1
+ """
2
+ Email Service Module.
3
+
4
+ Provides EmailService for sending transactional emails and passwordless login.
5
+ """
6
+
7
+ from .service import EmailService
8
+ from .templates import EmailTemplate, login_code_template
9
+
10
+ __all__ = ["EmailService", "EmailTemplate", "login_code_template"]
@@ -0,0 +1,513 @@
1
+ """
2
+ Email Service.
3
+
4
+ Provides methods for sending transactional emails via SMTP.
5
+ Supports passwordless login via email codes.
6
+ """
7
+
8
+ import uuid
9
+ import random
10
+ import string
11
+ import smtplib
12
+ import logging
13
+ from email.mime.text import MIMEText
14
+ from email.mime.multipart import MIMEMultipart
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any, Optional, TYPE_CHECKING
17
+
18
+ from .templates import EmailTemplate, login_code_template, welcome_template
19
+
20
+ if TYPE_CHECKING:
21
+ from ..postgres import PostgresService
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class EmailService:
27
+ """Service for sending transactional emails and passwordless login."""
28
+
29
+ # Store last login code for mock mode testing
30
+ _last_login_code: dict[str, str] = {}
31
+
32
+ def __init__(
33
+ self,
34
+ smtp_host: str | None = None,
35
+ smtp_port: int | None = None,
36
+ sender_email: str | None = None,
37
+ sender_name: str | None = None,
38
+ app_password: str | None = None,
39
+ use_tls: bool = True,
40
+ login_code_expiry_minutes: int = 10,
41
+ mock_mode: bool | None = None,
42
+ ):
43
+ """
44
+ Initialize EmailService.
45
+
46
+ If no arguments provided, uses settings from rem.settings.
47
+ This allows no-arg construction for simple usage.
48
+
49
+ Args:
50
+ smtp_host: SMTP server host
51
+ smtp_port: SMTP server port
52
+ sender_email: Sender email address
53
+ sender_name: Sender display name
54
+ app_password: SMTP app password
55
+ use_tls: Use TLS encryption
56
+ login_code_expiry_minutes: Login code expiry in minutes
57
+ mock_mode: If True, don't send real emails (log code instead)
58
+ """
59
+ # Import settings lazily to avoid circular imports
60
+ from ...settings import settings
61
+
62
+ self._smtp_host = smtp_host or settings.email.smtp_host
63
+ self._smtp_port = smtp_port or settings.email.smtp_port
64
+ self._sender_email = sender_email or settings.email.sender_email
65
+ self._sender_name = sender_name or settings.email.sender_name
66
+ self._app_password = app_password or settings.email.app_password
67
+ self._use_tls = use_tls
68
+ self._login_code_expiry_minutes = (
69
+ login_code_expiry_minutes
70
+ or settings.email.login_code_expiry_minutes
71
+ )
72
+
73
+ # Mock mode: enabled via setting or if not configured
74
+ if mock_mode is not None:
75
+ self._mock_mode = mock_mode
76
+ elif hasattr(settings.email, 'mock_mode'):
77
+ self._mock_mode = settings.email.mock_mode
78
+ else:
79
+ # Auto-enable mock mode if email is not configured
80
+ self._mock_mode = not self._app_password
81
+
82
+ if not self._app_password and not self._mock_mode:
83
+ logger.warning(
84
+ "Email app password not configured. "
85
+ "Set EMAIL__APP_PASSWORD to enable email sending."
86
+ )
87
+
88
+ if self._mock_mode:
89
+ logger.info(
90
+ "Email service running in MOCK MODE. "
91
+ "Codes will be logged but not emailed."
92
+ )
93
+
94
+ @property
95
+ def is_configured(self) -> bool:
96
+ """Check if email service is properly configured (or in mock mode)."""
97
+ return self._mock_mode or bool(self._sender_email and self._app_password)
98
+
99
+ def _create_smtp_connection(self) -> smtplib.SMTP:
100
+ """Create and authenticate SMTP connection."""
101
+ server = smtplib.SMTP(self._smtp_host, self._smtp_port)
102
+
103
+ if self._use_tls:
104
+ server.starttls()
105
+
106
+ server.login(self._sender_email, self._app_password)
107
+ return server
108
+
109
+ def send_email(
110
+ self,
111
+ to_email: str,
112
+ template: EmailTemplate,
113
+ reply_to: Optional[str] = None,
114
+ ) -> bool:
115
+ """
116
+ Send an email using a template.
117
+
118
+ Args:
119
+ to_email: Recipient email address
120
+ template: EmailTemplate with subject and HTML body
121
+ reply_to: Optional reply-to address
122
+
123
+ Returns:
124
+ True if sent successfully, False otherwise
125
+ """
126
+ if not self.is_configured:
127
+ logger.error("Email service not configured. Cannot send email.")
128
+ return False
129
+
130
+ # Mock mode - log but don't send
131
+ if self._mock_mode:
132
+ logger.info(
133
+ f"[MOCK EMAIL] To: {to_email}, Subject: {template.subject}"
134
+ )
135
+ return True
136
+
137
+ try:
138
+ # Create message
139
+ msg = MIMEMultipart("alternative")
140
+ msg["Subject"] = template.subject
141
+ msg["From"] = f"{self._sender_name} <{self._sender_email}>"
142
+ msg["To"] = to_email
143
+
144
+ if reply_to:
145
+ msg["Reply-To"] = reply_to
146
+
147
+ # Attach HTML body
148
+ html_part = MIMEText(template.html_body, "html", "utf-8")
149
+ msg.attach(html_part)
150
+
151
+ # Send email
152
+ with self._create_smtp_connection() as server:
153
+ server.sendmail(
154
+ self._sender_email,
155
+ to_email,
156
+ msg.as_string(),
157
+ )
158
+
159
+ logger.info(f"Email sent successfully to {to_email}: {template.subject}")
160
+ return True
161
+
162
+ except smtplib.SMTPAuthenticationError as e:
163
+ logger.error(f"SMTP authentication failed: {e}")
164
+ return False
165
+ except smtplib.SMTPException as e:
166
+ logger.error(f"SMTP error sending email to {to_email}: {e}")
167
+ return False
168
+ except Exception as e:
169
+ logger.error(f"Unexpected error sending email to {to_email}: {e}")
170
+ return False
171
+
172
+ @staticmethod
173
+ def generate_login_code() -> str:
174
+ """
175
+ Generate a 6-digit login code.
176
+
177
+ Returns:
178
+ 6-digit numeric string
179
+ """
180
+ return "".join(random.choices(string.digits, k=6))
181
+
182
+ @classmethod
183
+ def get_mock_code(cls, email: str) -> str | None:
184
+ """
185
+ Get the last login code sent to an email (mock mode only).
186
+
187
+ For testing purposes - retrieves the code that would have been
188
+ sent in mock mode.
189
+
190
+ Args:
191
+ email: Email address to look up
192
+
193
+ Returns:
194
+ The login code or None if not found
195
+ """
196
+ return cls._last_login_code.get(email.lower().strip())
197
+
198
+ @staticmethod
199
+ def generate_user_id_from_email(email: str) -> str:
200
+ """
201
+ Generate a deterministic UUID from email address.
202
+
203
+ Uses the centralized email_to_user_id() for consistency.
204
+ Same email always produces same UUID (bijection).
205
+
206
+ Args:
207
+ email: Email address
208
+
209
+ Returns:
210
+ UUID string
211
+ """
212
+ from rem.utils.user_id import email_to_user_id
213
+ return email_to_user_id(email)
214
+
215
+ async def send_login_code(
216
+ self,
217
+ email: str,
218
+ db: "PostgresService | None" = None,
219
+ tenant_id: str = "default",
220
+ template_kwargs: dict | None = None,
221
+ ) -> dict:
222
+ """
223
+ Send a login code to an email address.
224
+
225
+ Access control logic:
226
+ 1. If user exists and tier is BLOCKED -> reject with "Account blocked"
227
+ 2. If user exists and tier is not BLOCKED -> allow (send code)
228
+ 3. If user doesn't exist -> check trusted_email_domains setting:
229
+ - If domain is trusted (or no restrictions) -> create user and send code
230
+ - If domain is not trusted -> reject with "Domain not allowed"
231
+
232
+ This method:
233
+ 1. Generates a 6-digit login code
234
+ 2. Checks user existence and access permissions
235
+ 3. Upserts the user with login code in metadata
236
+ 4. Sends the code via email
237
+
238
+ Args:
239
+ email: User's email address
240
+ db: PostgresService instance for repository operations
241
+ tenant_id: Tenant identifier for multi-tenancy
242
+ template_kwargs: Additional arguments for template customization
243
+
244
+ Returns:
245
+ Dict with status and details:
246
+ {
247
+ "success": bool,
248
+ "email": str,
249
+ "user_id": str,
250
+ "code_sent": bool,
251
+ "expires_at": str (ISO format),
252
+ "error": str (if failed)
253
+ }
254
+ """
255
+ from ...settings import settings
256
+
257
+ email = email.lower().strip()
258
+ code = self.generate_login_code()
259
+ user_id = self.generate_user_id_from_email(email)
260
+ expires_at = datetime.now(timezone.utc) + timedelta(
261
+ minutes=self._login_code_expiry_minutes
262
+ )
263
+
264
+ result = {
265
+ "success": False,
266
+ "email": email,
267
+ "user_id": user_id,
268
+ "code_sent": False,
269
+ "expires_at": expires_at.isoformat(),
270
+ }
271
+
272
+ # Check user access and upsert login code using repository
273
+ if db:
274
+ try:
275
+ access_result = await self._check_and_upsert_user_login_code(
276
+ db=db,
277
+ email=email,
278
+ user_id=user_id,
279
+ code=code,
280
+ expires_at=expires_at,
281
+ tenant_id=tenant_id,
282
+ settings=settings,
283
+ )
284
+ if not access_result["allowed"]:
285
+ result["error"] = access_result["error"]
286
+ return result
287
+ except Exception as e:
288
+ logger.error(f"Failed to upsert user login code: {e}")
289
+ result["error"] = "Failed to store login code"
290
+ return result
291
+
292
+ # Send the email with branding from settings
293
+ # Merge settings.email.template_kwargs with any explicit overrides
294
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
295
+ template = login_code_template(code=code, email=email, **kwargs)
296
+ sent = self.send_email(to_email=email, template=template)
297
+
298
+ if sent:
299
+ result["success"] = True
300
+ result["code_sent"] = True
301
+
302
+ # Store code for mock mode retrieval
303
+ if self._mock_mode:
304
+ EmailService._last_login_code[email] = code
305
+ logger.info(
306
+ f"[MOCK MODE] Login code for {email}: {code} "
307
+ f"(expires at {expires_at.isoformat()})"
308
+ )
309
+
310
+ logger.info(
311
+ f"Login code sent to {email}, "
312
+ f"user_id={user_id}, expires at {expires_at.isoformat()}"
313
+ )
314
+ else:
315
+ result["error"] = "Failed to send email"
316
+
317
+ return result
318
+
319
+ async def _check_and_upsert_user_login_code(
320
+ self,
321
+ db: "PostgresService",
322
+ email: str,
323
+ user_id: str,
324
+ code: str,
325
+ expires_at: datetime,
326
+ tenant_id: str = "default",
327
+ settings: Any = None,
328
+ ) -> dict:
329
+ """
330
+ Check user access and upsert login code in metadata using repository pattern.
331
+
332
+ Access control logic:
333
+ 1. If user exists and tier is BLOCKED -> reject
334
+ 2. If user exists and tier is not BLOCKED -> allow and update
335
+ 3. If user doesn't exist -> check trusted_email_domains:
336
+ - If domain is trusted -> create user
337
+ - If domain is not trusted -> reject
338
+
339
+ Args:
340
+ db: PostgresService instance
341
+ email: User's email
342
+ user_id: Deterministic UUID from email
343
+ code: Generated login code
344
+ expires_at: Code expiration datetime
345
+ tenant_id: Tenant identifier
346
+ settings: Settings instance for domain checking
347
+
348
+ Returns:
349
+ Dict with {"allowed": bool, "error": str | None}
350
+ """
351
+ from ...models.entities import User, UserTier
352
+ from ..postgres.repository import Repository
353
+
354
+ now = datetime.now(timezone.utc)
355
+ login_metadata = {
356
+ "login_code": code,
357
+ "login_code_expires_at": expires_at.isoformat(),
358
+ "login_code_sent_at": now.isoformat(),
359
+ }
360
+
361
+ # Use repository pattern for User operations
362
+ user_repo = Repository(User, db=db)
363
+
364
+ # Try to get existing user first
365
+ existing_user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
366
+
367
+ if existing_user:
368
+ # Check if user is blocked
369
+ if existing_user.tier == UserTier.BLOCKED:
370
+ logger.warning(f"Blocked user attempted login: {email}")
371
+ return {"allowed": False, "error": "Account is blocked"}
372
+
373
+ # User exists and is not blocked - merge login code into existing metadata
374
+ existing_user.metadata = {**(existing_user.metadata or {}), **login_metadata}
375
+ existing_user.email = email # Ensure email is current
376
+ await user_repo.upsert(existing_user)
377
+ return {"allowed": True, "error": None}
378
+ else:
379
+ # New user - check if domain is trusted
380
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
381
+ if not settings.email.is_domain_trusted(email):
382
+ email_domain = email.split("@")[-1]
383
+ logger.warning(f"Untrusted domain attempted signup: {email_domain}")
384
+ return {"allowed": False, "error": "Email domain not allowed for signup"}
385
+
386
+ # Domain is trusted (or no restrictions) - create new user
387
+ # Users from trusted domains get admin role
388
+ user_role = None
389
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
390
+ if settings.email.is_domain_trusted(email):
391
+ user_role = "admin"
392
+ logger.info(f"New user {email} assigned admin role (trusted domain)")
393
+
394
+ new_user = User(
395
+ id=uuid.UUID(user_id),
396
+ tenant_id=tenant_id,
397
+ user_id=user_id, # UUID5 hash of email (same as id)
398
+ name=email, # Full email as entity_key for LOOKUP
399
+ email=email,
400
+ role=user_role,
401
+ metadata=login_metadata,
402
+ )
403
+ await user_repo.upsert(new_user)
404
+ return {"allowed": True, "error": None}
405
+
406
+ async def verify_login_code(
407
+ self,
408
+ email: str,
409
+ code: str,
410
+ db: "PostgresService",
411
+ tenant_id: str = "default",
412
+ ) -> dict:
413
+ """
414
+ Verify a login code for an email address.
415
+
416
+ On success, clears the code from metadata and returns user info.
417
+
418
+ Args:
419
+ email: User's email address
420
+ code: The login code to verify
421
+ db: PostgresService instance
422
+ tenant_id: Tenant identifier
423
+
424
+ Returns:
425
+ Dict with verification result:
426
+ {
427
+ "valid": bool,
428
+ "user_id": str (if valid),
429
+ "email": str,
430
+ "error": str (if invalid)
431
+ }
432
+ """
433
+ from ...models.entities import User
434
+ from ..postgres.repository import Repository
435
+
436
+ email = email.lower().strip()
437
+ user_id = self.generate_user_id_from_email(email)
438
+
439
+ result = {
440
+ "valid": False,
441
+ "email": email,
442
+ }
443
+
444
+ try:
445
+ # Use repository pattern for User operations
446
+ user_repo = Repository(User, db=db)
447
+
448
+ # Get user by deterministic ID
449
+ user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
450
+
451
+ if not user:
452
+ result["error"] = "User not found"
453
+ return result
454
+
455
+ metadata = user.metadata or {}
456
+ stored_code = metadata.get("login_code")
457
+ expires_at_str = metadata.get("login_code_expires_at")
458
+
459
+ if not stored_code or not expires_at_str:
460
+ result["error"] = "No login code requested"
461
+ return result
462
+
463
+ # Check expiration
464
+ expires_at = datetime.fromisoformat(expires_at_str)
465
+ if datetime.now(timezone.utc) > expires_at:
466
+ result["error"] = "Login code expired"
467
+ return result
468
+
469
+ # Check code match
470
+ if stored_code != code:
471
+ result["error"] = "Invalid login code"
472
+ return result
473
+
474
+ # Code is valid - clear it from metadata
475
+ user.metadata = {
476
+ k: v for k, v in metadata.items()
477
+ if k not in ("login_code", "login_code_expires_at", "login_code_sent_at")
478
+ }
479
+ await user_repo.upsert(user)
480
+
481
+ result["valid"] = True
482
+ result["user_id"] = str(user.id)
483
+ logger.info(f"Login code verified for {email}, user_id={result['user_id']}")
484
+
485
+ except Exception as e:
486
+ logger.error(f"Error verifying login code: {e}")
487
+ result["error"] = "Verification failed"
488
+
489
+ return result
490
+
491
+ async def send_welcome_email(
492
+ self,
493
+ email: str,
494
+ name: Optional[str] = None,
495
+ template_kwargs: dict | None = None,
496
+ ) -> bool:
497
+ """
498
+ Send a welcome email to a new user.
499
+
500
+ Args:
501
+ email: User's email address
502
+ name: Optional user's name
503
+ template_kwargs: Additional arguments for template customization
504
+
505
+ Returns:
506
+ True if sent successfully
507
+ """
508
+ from ...settings import settings
509
+
510
+ # Merge settings.email.template_kwargs with any explicit overrides
511
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
512
+ template = welcome_template(name=name, **kwargs)
513
+ return self.send_email(to_email=email, template=template)