remdb 0.3.114__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 (83) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/agents/sse_simulator.py +2 -0
  4. rem/agentic/context.py +103 -5
  5. rem/agentic/context_builder.py +36 -9
  6. rem/agentic/mcp/tool_wrapper.py +161 -18
  7. rem/agentic/otel/setup.py +1 -0
  8. rem/agentic/providers/phoenix.py +371 -108
  9. rem/agentic/providers/pydantic_ai.py +172 -30
  10. rem/agentic/schema.py +8 -4
  11. rem/api/deps.py +3 -5
  12. rem/api/main.py +26 -4
  13. rem/api/mcp_router/resources.py +15 -10
  14. rem/api/mcp_router/server.py +11 -3
  15. rem/api/mcp_router/tools.py +418 -4
  16. rem/api/middleware/tracking.py +5 -5
  17. rem/api/routers/admin.py +218 -1
  18. rem/api/routers/auth.py +349 -6
  19. rem/api/routers/chat/completions.py +255 -7
  20. rem/api/routers/chat/models.py +81 -7
  21. rem/api/routers/chat/otel_utils.py +33 -0
  22. rem/api/routers/chat/sse_events.py +17 -1
  23. rem/api/routers/chat/streaming.py +126 -19
  24. rem/api/routers/feedback.py +134 -14
  25. rem/api/routers/messages.py +24 -15
  26. rem/api/routers/query.py +6 -3
  27. rem/auth/__init__.py +13 -3
  28. rem/auth/jwt.py +352 -0
  29. rem/auth/middleware.py +115 -10
  30. rem/auth/providers/__init__.py +4 -1
  31. rem/auth/providers/email.py +215 -0
  32. rem/cli/commands/README.md +42 -0
  33. rem/cli/commands/cluster.py +617 -168
  34. rem/cli/commands/configure.py +4 -7
  35. rem/cli/commands/db.py +66 -22
  36. rem/cli/commands/experiments.py +468 -76
  37. rem/cli/commands/schema.py +6 -5
  38. rem/cli/commands/session.py +336 -0
  39. rem/cli/dreaming.py +2 -2
  40. rem/cli/main.py +2 -0
  41. rem/config.py +8 -1
  42. rem/models/core/experiment.py +58 -14
  43. rem/models/entities/__init__.py +4 -0
  44. rem/models/entities/ontology.py +1 -1
  45. rem/models/entities/ontology_config.py +1 -1
  46. rem/models/entities/subscriber.py +175 -0
  47. rem/models/entities/user.py +1 -0
  48. rem/schemas/agents/core/agent-builder.yaml +235 -0
  49. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  50. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  51. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  52. rem/services/__init__.py +3 -1
  53. rem/services/content/service.py +4 -3
  54. rem/services/email/__init__.py +10 -0
  55. rem/services/email/service.py +513 -0
  56. rem/services/email/templates.py +360 -0
  57. rem/services/phoenix/client.py +59 -18
  58. rem/services/postgres/README.md +38 -0
  59. rem/services/postgres/diff_service.py +127 -6
  60. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  61. rem/services/postgres/repository.py +5 -4
  62. rem/services/postgres/schema_generator.py +205 -4
  63. rem/services/session/compression.py +120 -50
  64. rem/services/session/reload.py +14 -7
  65. rem/services/user_service.py +41 -9
  66. rem/settings.py +442 -23
  67. rem/sql/migrations/001_install.sql +156 -0
  68. rem/sql/migrations/002_install_models.sql +1951 -88
  69. rem/sql/migrations/004_cache_system.sql +548 -0
  70. rem/sql/migrations/005_schema_update.sql +145 -0
  71. rem/utils/README.md +45 -0
  72. rem/utils/__init__.py +18 -0
  73. rem/utils/files.py +157 -1
  74. rem/utils/schema_loader.py +139 -10
  75. rem/utils/sql_paths.py +146 -0
  76. rem/utils/vision.py +1 -1
  77. rem/workers/__init__.py +3 -1
  78. rem/workers/db_listener.py +579 -0
  79. rem/workers/unlogged_maintainer.py +463 -0
  80. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/METADATA +218 -180
  81. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/RECORD +83 -68
  82. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  83. {remdb-0.3.114.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
rem/settings.py CHANGED
@@ -21,8 +21,8 @@ Example .env file:
21
21
  LLM__OPENAI_API_KEY=sk-...
22
22
  LLM__ANTHROPIC_API_KEY=sk-ant-...
23
23
 
24
- # Database (port 5050 for Docker Compose)
25
- POSTGRES__CONNECTION_STRING=postgresql://rem:rem@localhost:5050/rem
24
+ # Database (port 5051 for Docker Compose prebuilt, 5050 for local dev)
25
+ POSTGRES__CONNECTION_STRING=postgresql://rem:rem@localhost:5051/rem
26
26
  POSTGRES__POOL_MIN_SIZE=5
27
27
  POSTGRES__POOL_MAX_SIZE=20
28
28
  POSTGRES__STATEMENT_TIMEOUT=30000
@@ -33,14 +33,15 @@ Example .env file:
33
33
  AUTH__OIDC_CLIENT_ID=your-client-id
34
34
  AUTH__SESSION_SECRET=your-secret-key
35
35
 
36
- # OpenTelemetry (disabled by default)
36
+ # OpenTelemetry (disabled by default - enable via env var when collector available)
37
+ # Standard OTLP collector ports: 4317 (gRPC), 4318 (HTTP)
37
38
  OTEL__ENABLED=false
38
39
  OTEL__SERVICE_NAME=rem-api
39
- OTEL__COLLECTOR_ENDPOINT=http://localhost:4318
40
- OTEL__PROTOCOL=http
40
+ OTEL__COLLECTOR_ENDPOINT=http://localhost:4317
41
+ OTEL__PROTOCOL=grpc
41
42
 
42
- # Arize Phoenix (disabled by default)
43
- PHOENIX__ENABLED=false
43
+ # Arize Phoenix (enabled by default - can be disabled via env var)
44
+ PHOENIX__ENABLED=true
44
45
  PHOENIX__COLLECTOR_ENDPOINT=http://localhost:6006/v1/traces
45
46
  PHOENIX__PROJECT_NAME=rem
46
47
 
@@ -76,6 +77,7 @@ class LLMSettings(BaseSettings):
76
77
  LLM__ANTHROPIC_API_KEY or ANTHROPIC_API_KEY - Anthropic API key
77
78
  LLM__EMBEDDING_PROVIDER or EMBEDDING_PROVIDER - Default embedding provider (openai)
78
79
  LLM__EMBEDDING_MODEL or EMBEDDING_MODEL - Default embedding model name
80
+ LLM__DEFAULT_STRUCTURED_OUTPUT - Default structured output mode (False = streaming text)
79
81
  """
80
82
 
81
83
  model_config = SettingsConfigDict(
@@ -137,6 +139,11 @@ class LLMSettings(BaseSettings):
137
139
  description="Default embedding model (provider-specific model name)",
138
140
  )
139
141
 
142
+ default_structured_output: bool = Field(
143
+ default=False,
144
+ description="Default structured output mode for agents. False = streaming text (easier), True = JSON schema validation",
145
+ )
146
+
140
147
  @field_validator("openai_api_key", mode="before")
141
148
  @classmethod
142
149
  def validate_openai_api_key(cls, v):
@@ -241,6 +248,11 @@ class OTELSettings(BaseSettings):
241
248
  description="Export timeout in milliseconds",
242
249
  )
243
250
 
251
+ insecure: bool = Field(
252
+ default=True,
253
+ description="Use insecure (non-TLS) gRPC connection (default: True for local dev)",
254
+ )
255
+
244
256
 
245
257
  class PhoenixSettings(BaseSettings):
246
258
  """
@@ -267,8 +279,8 @@ class PhoenixSettings(BaseSettings):
267
279
  )
268
280
 
269
281
  enabled: bool = Field(
270
- default=False,
271
- description="Enable Phoenix integration (disabled by default for local dev)",
282
+ default=True,
283
+ description="Enable Phoenix integration (enabled by default)",
272
284
  )
273
285
 
274
286
  base_url: str = Field(
@@ -458,10 +470,11 @@ class PostgresSettings(BaseSettings):
458
470
  )
459
471
 
460
472
  connection_string: str = Field(
461
- default="postgresql://rem:rem@localhost:5050/rem",
462
- description="PostgreSQL connection string (default uses Docker Compose port 5050)",
473
+ default="postgresql://rem:rem@localhost:5051/rem",
474
+ description="PostgreSQL connection string (default uses Docker Compose prebuilt port 5051)",
463
475
  )
464
476
 
477
+
465
478
  pool_size: int = Field(
466
479
  default=10,
467
480
  description="Connection pool size (deprecated, use pool_min_size/pool_max_size)",
@@ -686,6 +699,91 @@ class S3Settings(BaseSettings):
686
699
  )
687
700
 
688
701
 
702
+ class DataLakeSettings(BaseSettings):
703
+ """
704
+ Data lake settings for experiment and dataset storage.
705
+
706
+ Data Lake Convention:
707
+ The data lake provides a standardized structure for storing datasets,
708
+ experiments, and calibration data in S3. Users bring their own bucket
709
+ and the version is pinned by default to v0 in the path.
710
+
711
+ S3 Path Structure:
712
+ s3://{bucket}/{version}/datasets/
713
+ ├── raw/ # Raw source data + transformers
714
+ │ └── {dataset_name}/ # e.g., cns_drugs, codes, care
715
+ ├── tables/ # Database table data (JSONL)
716
+ │ ├── resources/ # → resources table
717
+ │ │ ├── drugs/{category}/ # Psychotropic drugs
718
+ │ │ ├── care/stages/ # Treatment stages
719
+ │ │ └── crisis/ # Crisis resources
720
+ │ └── codes/ # → codes table
721
+ │ ├── icd10/{category}/ # ICD-10 codes
722
+ │ └── cpt/ # CPT codes
723
+ └── calibration/ # Agent calibration
724
+ ├── experiments/ # Experiment configs + results
725
+ │ └── {agent}/{task}/ # e.g., siggy/risk-assessment
726
+ └── datasets/ # Shared evaluation datasets
727
+
728
+ Experiment Storage:
729
+ - Local: experiments/{agent}/{task}/experiment.yaml
730
+ - S3: s3://{bucket}/{version}/datasets/calibration/experiments/{agent}/{task}/
731
+
732
+ Environment variables:
733
+ DATA_LAKE__BUCKET_NAME - S3 bucket for data lake (required)
734
+ DATA_LAKE__VERSION - Path version prefix (default: v0)
735
+ DATA_LAKE__DATASETS_PREFIX - Datasets directory (default: datasets)
736
+ DATA_LAKE__EXPERIMENTS_PREFIX - Experiments subdirectory (default: experiments)
737
+ """
738
+
739
+ model_config = SettingsConfigDict(
740
+ env_prefix="DATA_LAKE__",
741
+ env_file=".env",
742
+ env_file_encoding="utf-8",
743
+ extra="ignore",
744
+ )
745
+
746
+ bucket_name: str | None = Field(
747
+ default=None,
748
+ description="S3 bucket for data lake storage (user-provided)",
749
+ )
750
+
751
+ version: str = Field(
752
+ default="v0",
753
+ description="API version for data lake paths",
754
+ )
755
+
756
+ datasets_prefix: str = Field(
757
+ default="datasets",
758
+ description="Root directory for datasets in the bucket",
759
+ )
760
+
761
+ experiments_prefix: str = Field(
762
+ default="experiments",
763
+ description="Subdirectory within calibration for experiments",
764
+ )
765
+
766
+ def get_base_uri(self) -> str | None:
767
+ """Get the base S3 URI for the data lake."""
768
+ if not self.bucket_name:
769
+ return None
770
+ return f"s3://{self.bucket_name}/{self.version}/{self.datasets_prefix}"
771
+
772
+ def get_experiment_uri(self, agent: str, task: str = "general") -> str | None:
773
+ """Get the S3 URI for an experiment."""
774
+ base = self.get_base_uri()
775
+ if not base:
776
+ return None
777
+ return f"{base}/calibration/{self.experiments_prefix}/{agent}/{task}"
778
+
779
+ def get_tables_uri(self, table: str = "resources") -> str | None:
780
+ """Get the S3 URI for a table directory."""
781
+ base = self.get_base_uri()
782
+ if not base:
783
+ return None
784
+ return f"{base}/tables/{table}"
785
+
786
+
689
787
  class ChunkingSettings(BaseSettings):
690
788
  """
691
789
  Document chunking settings for semantic text splitting.
@@ -936,7 +1034,7 @@ class ChatSettings(BaseSettings):
936
1034
  - Prevents context window bloat while maintaining conversation continuity
937
1035
 
938
1036
  User Context (on-demand by default):
939
- - Agent system prompt includes: "User ID: {user_id}. To load user profile: Use REM LOOKUP users/{user_id}"
1037
+ - Agent system prompt includes: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
940
1038
  - Agent decides whether to load profile based on query
941
1039
  - More efficient for queries that don't need personalization
942
1040
 
@@ -969,6 +1067,8 @@ class APISettings(BaseSettings):
969
1067
  API__RELOAD - Enable auto-reload for development
970
1068
  API__WORKERS - Number of worker processes (production)
971
1069
  API__LOG_LEVEL - Logging level (debug, info, warning, error)
1070
+ API__API_KEY_ENABLED - Enable X-API-Key header authentication
1071
+ API__API_KEY - API key for X-API-Key authentication
972
1072
  """
973
1073
 
974
1074
  model_config = SettingsConfigDict(
@@ -1003,6 +1103,31 @@ class APISettings(BaseSettings):
1003
1103
  description="Logging level (debug, info, warning, error, critical)",
1004
1104
  )
1005
1105
 
1106
+ api_key_enabled: bool = Field(
1107
+ default=False,
1108
+ description=(
1109
+ "Enable X-API-Key header authentication for API endpoints. "
1110
+ "When enabled, requests must include X-API-Key header with valid key. "
1111
+ "This provides simple API key auth independent of OAuth."
1112
+ ),
1113
+ )
1114
+
1115
+ api_key: str | None = Field(
1116
+ default=None,
1117
+ description=(
1118
+ "API key for X-API-Key authentication. Required when api_key_enabled=true. "
1119
+ "Generate with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
1120
+ ),
1121
+ )
1122
+
1123
+ rate_limit_enabled: bool = Field(
1124
+ default=True,
1125
+ description=(
1126
+ "Enable rate limiting for API endpoints. "
1127
+ "Set to false to disable rate limiting entirely (useful for development)."
1128
+ ),
1129
+ )
1130
+
1006
1131
 
1007
1132
  class ModelsSettings(BaseSettings):
1008
1133
  """
@@ -1051,10 +1176,26 @@ class ModelsSettings(BaseSettings):
1051
1176
 
1052
1177
  @property
1053
1178
  def module_list(self) -> list[str]:
1054
- """Get modules as a list, filtering empty strings."""
1055
- if not self.import_modules:
1056
- return []
1057
- return [m.strip() for m in self.import_modules.split(";") if m.strip()]
1179
+ """
1180
+ Get modules as a list, filtering empty strings.
1181
+
1182
+ Auto-detects ./models folder if it exists and is importable.
1183
+ """
1184
+ modules = []
1185
+ if self.import_modules:
1186
+ modules = [m.strip() for m in self.import_modules.split(";") if m.strip()]
1187
+
1188
+ # Auto-detect ./models if it exists and is a Python package (convention over configuration)
1189
+ from pathlib import Path
1190
+
1191
+ models_path = Path("./models")
1192
+ if models_path.exists() and models_path.is_dir():
1193
+ # Check if it's a Python package (has __init__.py)
1194
+ if (models_path / "__init__.py").exists():
1195
+ if "models" not in modules:
1196
+ modules.insert(0, "models")
1197
+
1198
+ return modules
1058
1199
 
1059
1200
 
1060
1201
  class SchemaSettings(BaseSettings):
@@ -1240,6 +1381,276 @@ class GitSettings(BaseSettings):
1240
1381
  )
1241
1382
 
1242
1383
 
1384
+ class DBListenerSettings(BaseSettings):
1385
+ """
1386
+ PostgreSQL LISTEN/NOTIFY database listener settings.
1387
+
1388
+ The DB Listener is a lightweight worker that subscribes to PostgreSQL
1389
+ NOTIFY events and dispatches them to external systems (SQS, REST, custom).
1390
+
1391
+ Architecture:
1392
+ - Single-replica deployment (to avoid duplicate processing)
1393
+ - Dedicated connection for LISTEN (not from connection pool)
1394
+ - Automatic reconnection with exponential backoff
1395
+ - Graceful shutdown on SIGTERM
1396
+
1397
+ Use Cases:
1398
+ - Sync data changes to external systems (Phoenix, webhooks)
1399
+ - Trigger async jobs without polling
1400
+ - Event-driven architectures with PostgreSQL as event source
1401
+
1402
+ Example PostgreSQL trigger:
1403
+ CREATE OR REPLACE FUNCTION notify_feedback_insert()
1404
+ RETURNS TRIGGER AS $$
1405
+ BEGIN
1406
+ PERFORM pg_notify('feedback_sync', json_build_object(
1407
+ 'id', NEW.id,
1408
+ 'table', 'feedbacks',
1409
+ 'action', 'insert'
1410
+ )::text);
1411
+ RETURN NEW;
1412
+ END;
1413
+ $$ LANGUAGE plpgsql;
1414
+
1415
+ Environment variables:
1416
+ DB_LISTENER__ENABLED - Enable the listener worker (default: false)
1417
+ DB_LISTENER__CHANNELS - Comma-separated PostgreSQL channels to listen on
1418
+ DB_LISTENER__HANDLER_TYPE - Handler type: 'sqs', 'rest', or 'custom'
1419
+ DB_LISTENER__SQS_QUEUE_URL - SQS queue URL (for handler_type=sqs)
1420
+ DB_LISTENER__REST_ENDPOINT - REST endpoint URL (for handler_type=rest)
1421
+ DB_LISTENER__RECONNECT_DELAY - Initial reconnect delay in seconds
1422
+ DB_LISTENER__MAX_RECONNECT_DELAY - Maximum reconnect delay in seconds
1423
+
1424
+ References:
1425
+ - PostgreSQL NOTIFY: https://www.postgresql.org/docs/current/sql-notify.html
1426
+ - Brandur's Notifier: https://brandur.org/notifier
1427
+ """
1428
+
1429
+ model_config = SettingsConfigDict(
1430
+ env_prefix="DB_LISTENER__",
1431
+ env_file=".env",
1432
+ env_file_encoding="utf-8",
1433
+ extra="ignore",
1434
+ )
1435
+
1436
+ enabled: bool = Field(
1437
+ default=False,
1438
+ description="Enable the DB Listener worker (disabled by default)",
1439
+ )
1440
+
1441
+ channels: str = Field(
1442
+ default="",
1443
+ description=(
1444
+ "Comma-separated list of PostgreSQL channels to LISTEN on. "
1445
+ "Example: 'feedback_sync,entity_update,user_events'"
1446
+ ),
1447
+ )
1448
+
1449
+ handler_type: str = Field(
1450
+ default="rest",
1451
+ description=(
1452
+ "Handler type for dispatching notifications. Options: "
1453
+ "'sqs' (publish to SQS), 'rest' (POST to endpoint), 'custom' (Python handlers)"
1454
+ ),
1455
+ )
1456
+
1457
+ sqs_queue_url: str = Field(
1458
+ default="",
1459
+ description="SQS queue URL for handler_type='sqs'",
1460
+ )
1461
+
1462
+ rest_endpoint: str = Field(
1463
+ default="http://localhost:8000/api/v1/internal/events",
1464
+ description=(
1465
+ "REST endpoint URL for handler_type='rest'. "
1466
+ "Receives POST with {channel, payload, source} JSON body."
1467
+ ),
1468
+ )
1469
+
1470
+ reconnect_delay: float = Field(
1471
+ default=1.0,
1472
+ description="Initial delay (seconds) between reconnection attempts",
1473
+ )
1474
+
1475
+ max_reconnect_delay: float = Field(
1476
+ default=60.0,
1477
+ description="Maximum delay (seconds) between reconnection attempts (exponential backoff cap)",
1478
+ )
1479
+
1480
+ @property
1481
+ def channel_list(self) -> list[str]:
1482
+ """Get channels as a list, filtering empty strings."""
1483
+ if not self.channels:
1484
+ return []
1485
+ return [c.strip() for c in self.channels.split(",") if c.strip()]
1486
+
1487
+
1488
+ class EmailSettings(BaseSettings):
1489
+ """
1490
+ Email service settings for SMTP.
1491
+
1492
+ Supports passwordless login via email codes and transactional emails.
1493
+ Uses Gmail SMTP with App Passwords by default.
1494
+
1495
+ Generate app password at: https://myaccount.google.com/apppasswords
1496
+
1497
+ Environment variables:
1498
+ EMAIL__ENABLED - Enable email service (default: false)
1499
+ EMAIL__SMTP_HOST - SMTP server host (default: smtp.gmail.com)
1500
+ EMAIL__SMTP_PORT - SMTP server port (default: 587 for TLS)
1501
+ EMAIL__SENDER_EMAIL - Sender email address
1502
+ EMAIL__SENDER_NAME - Sender display name
1503
+ EMAIL__APP_PASSWORD - Gmail app password (from secrets)
1504
+ EMAIL__USE_TLS - Use TLS encryption (default: true)
1505
+ EMAIL__LOGIN_CODE_EXPIRY_MINUTES - Login code expiry (default: 10)
1506
+
1507
+ Branding environment variables (for email templates):
1508
+ EMAIL__APP_NAME - Application name in emails (default: REM)
1509
+ EMAIL__LOGO_URL - Logo URL for email templates (40x40 recommended)
1510
+ EMAIL__TAGLINE - Tagline shown in email footer
1511
+ EMAIL__WEBSITE_URL - Main website URL for email links
1512
+ EMAIL__PRIVACY_URL - Privacy policy URL for email footer
1513
+ EMAIL__TERMS_URL - Terms of service URL for email footer
1514
+ """
1515
+
1516
+ model_config = SettingsConfigDict(
1517
+ env_prefix="EMAIL__",
1518
+ env_file=".env",
1519
+ env_file_encoding="utf-8",
1520
+ extra="ignore",
1521
+ )
1522
+
1523
+ enabled: bool = Field(
1524
+ default=False,
1525
+ description="Enable email service (requires app_password to be set)",
1526
+ )
1527
+
1528
+ smtp_host: str = Field(
1529
+ default="smtp.gmail.com",
1530
+ description="SMTP server host",
1531
+ )
1532
+
1533
+ smtp_port: int = Field(
1534
+ default=587,
1535
+ description="SMTP server port (587 for TLS, 465 for SSL)",
1536
+ )
1537
+
1538
+ sender_email: str = Field(
1539
+ default="",
1540
+ description="Sender email address",
1541
+ )
1542
+
1543
+ sender_name: str = Field(
1544
+ default="REM",
1545
+ description="Sender display name",
1546
+ )
1547
+
1548
+ # Branding settings for email templates
1549
+ app_name: str = Field(
1550
+ default="REM",
1551
+ description="Application name shown in email templates",
1552
+ )
1553
+
1554
+ logo_url: str | None = Field(
1555
+ default=None,
1556
+ description="Logo URL for email templates (40x40 recommended)",
1557
+ )
1558
+
1559
+ tagline: str = Field(
1560
+ default="Your AI-powered platform",
1561
+ description="Tagline shown in email footer",
1562
+ )
1563
+
1564
+ website_url: str = Field(
1565
+ default="https://rem.ai",
1566
+ description="Main website URL for email links",
1567
+ )
1568
+
1569
+ privacy_url: str = Field(
1570
+ default="https://rem.ai/privacy",
1571
+ description="Privacy policy URL for email footer",
1572
+ )
1573
+
1574
+ terms_url: str = Field(
1575
+ default="https://rem.ai/terms",
1576
+ description="Terms of service URL for email footer",
1577
+ )
1578
+
1579
+ app_password: str | None = Field(
1580
+ default=None,
1581
+ description="Gmail app password for SMTP authentication",
1582
+ )
1583
+
1584
+ use_tls: bool = Field(
1585
+ default=True,
1586
+ description="Use TLS encryption for SMTP",
1587
+ )
1588
+
1589
+ login_code_expiry_minutes: int = Field(
1590
+ default=10,
1591
+ description="Login code expiry in minutes",
1592
+ )
1593
+
1594
+ trusted_email_domains: str = Field(
1595
+ default="",
1596
+ description=(
1597
+ "Comma-separated list of trusted email domains for new user registration. "
1598
+ "Existing users can always login regardless of domain. "
1599
+ "New users must have an email from a trusted domain. "
1600
+ "Empty string means all domains are allowed. "
1601
+ "Example: 'siggymd.ai,example.com'"
1602
+ ),
1603
+ )
1604
+
1605
+ @property
1606
+ def trusted_domain_list(self) -> list[str]:
1607
+ """Get trusted domains as a list, filtering empty strings."""
1608
+ if not self.trusted_email_domains:
1609
+ return []
1610
+ return [d.strip().lower() for d in self.trusted_email_domains.split(",") if d.strip()]
1611
+
1612
+ def is_domain_trusted(self, email: str) -> bool:
1613
+ """Check if an email's domain is in the trusted list.
1614
+
1615
+ Args:
1616
+ email: Email address to check
1617
+
1618
+ Returns:
1619
+ True if domain is trusted (or if no trusted domains configured)
1620
+ """
1621
+ domains = self.trusted_domain_list
1622
+ if not domains:
1623
+ # No restrictions configured
1624
+ return True
1625
+
1626
+ email_domain = email.lower().split("@")[-1].strip()
1627
+ return email_domain in domains
1628
+
1629
+ @property
1630
+ def is_configured(self) -> bool:
1631
+ """Check if email service is properly configured."""
1632
+ return bool(self.sender_email and self.app_password)
1633
+
1634
+ @property
1635
+ def template_kwargs(self) -> dict:
1636
+ """
1637
+ Get branding kwargs for email templates.
1638
+
1639
+ Returns a dict that can be passed to template functions:
1640
+ login_code_template(..., **settings.email.template_kwargs)
1641
+ """
1642
+ kwargs = {
1643
+ "app_name": self.app_name,
1644
+ "tagline": self.tagline,
1645
+ "website_url": self.website_url,
1646
+ "privacy_url": self.privacy_url,
1647
+ "terms_url": self.terms_url,
1648
+ }
1649
+ if self.logo_url:
1650
+ kwargs["logo_url"] = self.logo_url
1651
+ return kwargs
1652
+
1653
+
1243
1654
  class TestSettings(BaseSettings):
1244
1655
  """
1245
1656
  Test environment settings.
@@ -1334,11 +1745,6 @@ class Settings(BaseSettings):
1334
1745
  description="Root path for reverse proxy (e.g., /rem for ALB routing)",
1335
1746
  )
1336
1747
 
1337
- sql_dir: str = Field(
1338
- default="src/rem/sql",
1339
- description="Directory for SQL files and migrations",
1340
- )
1341
-
1342
1748
  # Nested settings groups
1343
1749
  api: APISettings = Field(default_factory=APISettings)
1344
1750
  chat: ChatSettings = Field(default_factory=ChatSettings)
@@ -1352,18 +1758,31 @@ class Settings(BaseSettings):
1352
1758
  migration: MigrationSettings = Field(default_factory=MigrationSettings)
1353
1759
  storage: StorageSettings = Field(default_factory=StorageSettings)
1354
1760
  s3: S3Settings = Field(default_factory=S3Settings)
1761
+ data_lake: DataLakeSettings = Field(default_factory=DataLakeSettings)
1355
1762
  git: GitSettings = Field(default_factory=GitSettings)
1356
1763
  sqs: SQSSettings = Field(default_factory=SQSSettings)
1764
+ db_listener: DBListenerSettings = Field(default_factory=DBListenerSettings)
1357
1765
  chunking: ChunkingSettings = Field(default_factory=ChunkingSettings)
1358
1766
  content: ContentSettings = Field(default_factory=ContentSettings)
1359
1767
  schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
1768
+ email: EmailSettings = Field(default_factory=EmailSettings)
1360
1769
  test: TestSettings = Field(default_factory=TestSettings)
1361
1770
 
1362
1771
 
1772
+ # Auto-load .env file from current directory if it exists
1773
+ # This happens BEFORE config file loading, so .env takes precedence
1774
+ from pathlib import Path
1775
+ from dotenv import load_dotenv
1776
+
1777
+ _dotenv_path = Path(".env")
1778
+ if _dotenv_path.exists():
1779
+ load_dotenv(_dotenv_path, override=False) # Don't override existing env vars
1780
+ logger.debug(f"Loaded environment from {_dotenv_path.resolve()}")
1781
+
1363
1782
  # Load configuration from ~/.rem/config.yaml before initializing settings
1364
1783
  # This allows user configuration to be merged with environment variables
1365
- # Set REM_SKIP_CONFIG_FILE=true to disable (useful for development with .env)
1366
- if not os.getenv("REM_SKIP_CONFIG_FILE", "").lower() in ("true", "1", "yes"):
1784
+ # Set REM_SKIP_CONFIG=1 to disable (useful for development with .env)
1785
+ if not os.getenv("REM_SKIP_CONFIG", "").lower() in ("true", "1", "yes"):
1367
1786
  try:
1368
1787
  from rem.config import load_config, merge_config_to_env
1369
1788