remdb 0.3.181__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 (48) 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 +2 -2
  5. rem/agentic/providers/pydantic_ai.py +1 -1
  6. rem/agentic/schema.py +2 -2
  7. rem/api/main.py +1 -1
  8. rem/api/mcp_router/server.py +4 -0
  9. rem/api/mcp_router/tools.py +542 -170
  10. rem/api/routers/admin.py +30 -4
  11. rem/api/routers/auth.py +106 -10
  12. rem/api/routers/chat/completions.py +66 -18
  13. rem/api/routers/chat/sse_events.py +7 -3
  14. rem/api/routers/chat/streaming.py +254 -22
  15. rem/api/routers/common.py +18 -0
  16. rem/api/routers/dev.py +7 -1
  17. rem/api/routers/feedback.py +9 -1
  18. rem/api/routers/messages.py +176 -38
  19. rem/api/routers/models.py +9 -1
  20. rem/api/routers/query.py +12 -1
  21. rem/api/routers/shared_sessions.py +16 -0
  22. rem/auth/jwt.py +19 -4
  23. rem/auth/middleware.py +42 -28
  24. rem/cli/README.md +62 -0
  25. rem/cli/commands/db.py +33 -19
  26. rem/cli/commands/process.py +171 -43
  27. rem/models/entities/ontology.py +18 -20
  28. rem/schemas/agents/rem.yaml +1 -1
  29. rem/services/content/service.py +18 -5
  30. rem/services/postgres/__init__.py +28 -3
  31. rem/services/postgres/diff_service.py +57 -5
  32. rem/services/postgres/programmable_diff_service.py +635 -0
  33. rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
  34. rem/services/postgres/register_type.py +11 -10
  35. rem/services/postgres/repository.py +14 -4
  36. rem/services/session/__init__.py +8 -1
  37. rem/services/session/compression.py +40 -2
  38. rem/services/session/pydantic_messages.py +276 -0
  39. rem/settings.py +28 -0
  40. rem/sql/migrations/001_install.sql +125 -7
  41. rem/sql/migrations/002_install_models.sql +136 -126
  42. rem/sql/migrations/004_cache_system.sql +7 -275
  43. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  44. rem/utils/schema_loader.py +6 -6
  45. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/METADATA +1 -1
  46. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/RECORD +48 -44
  47. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/WHEEL +0 -0
  48. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/entry_points.txt +0 -0
@@ -94,14 +94,14 @@ def generate_table_schema(
94
94
  # Always add id as primary key
95
95
  columns.append("id UUID PRIMARY KEY DEFAULT uuid_generate_v4()")
96
96
 
97
- # Add tenant_id if tenant scoped
97
+ # Add tenant_id if tenant scoped (nullable - NULL means public/shared)
98
98
  if tenant_scoped:
99
- columns.append("tenant_id VARCHAR(100) NOT NULL")
100
- indexes.append(f"CREATE INDEX idx_{table_name}_tenant ON {table_name} (tenant_id);")
99
+ columns.append("tenant_id VARCHAR(100)")
100
+ indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tenant ON {table_name} (tenant_id);")
101
101
 
102
102
  # Add user_id (owner field)
103
103
  columns.append("user_id VARCHAR(256)")
104
- indexes.append(f"CREATE INDEX idx_{table_name}_user ON {table_name} (user_id);")
104
+ indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_user ON {table_name} (user_id);")
105
105
 
106
106
  # Process Pydantic fields (skip system fields)
107
107
  for field_name, field_info in model.model_fields.items():
@@ -125,19 +125,19 @@ def generate_table_schema(
125
125
  # Add graph_edges JSONB field
126
126
  columns.append("graph_edges JSONB DEFAULT '[]'::jsonb")
127
127
  indexes.append(
128
- f"CREATE INDEX idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
128
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
129
129
  )
130
130
 
131
131
  # Add metadata JSONB field
132
132
  columns.append("metadata JSONB DEFAULT '{}'::jsonb")
133
133
  indexes.append(
134
- f"CREATE INDEX idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
134
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
135
135
  )
136
136
 
137
137
  # Add tags field (TEXT[] for list[str])
138
138
  columns.append("tags TEXT[] DEFAULT ARRAY[]::TEXT[]")
139
139
  indexes.append(
140
- f"CREATE INDEX idx_{table_name}_tags ON {table_name} USING GIN (tags);"
140
+ f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tags ON {table_name} USING GIN (tags);"
141
141
  )
142
142
 
143
143
  # Generate CREATE TABLE statement
@@ -202,10 +202,10 @@ CREATE TABLE IF NOT EXISTS {embeddings_table} (
202
202
  );
203
203
 
204
204
  -- Index for entity lookup (get all embeddings for entity)
205
- CREATE INDEX idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
205
+ CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
206
206
 
207
207
  -- Index for field + provider lookup
208
- CREATE INDEX idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
208
+ CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
209
209
 
210
210
  -- HNSW index for vector similarity search (created in background)
211
211
  -- Note: This will be created by background thread after data load
@@ -258,6 +258,7 @@ BEGIN
258
258
  RETURN OLD;
259
259
  ELSIF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
260
260
  -- Upsert to KV_STORE (O(1) lookup by entity_key)
261
+ -- tenant_id can be NULL (meaning public/shared data)
261
262
  INSERT INTO kv_store (
262
263
  entity_key,
263
264
  entity_type,
@@ -277,7 +278,7 @@ BEGIN
277
278
  COALESCE(NEW.graph_edges, '[]'::jsonb),
278
279
  CURRENT_TIMESTAMP
279
280
  )
280
- ON CONFLICT (tenant_id, entity_key)
281
+ ON CONFLICT (COALESCE(tenant_id, ''), entity_key)
281
282
  DO UPDATE SET
282
283
  entity_id = EXCLUDED.entity_id,
283
284
  user_id = EXCLUDED.user_id,
@@ -31,17 +31,27 @@ if TYPE_CHECKING:
31
31
  from .service import PostgresService
32
32
 
33
33
 
34
+ # Singleton instance for connection pool reuse
35
+ _postgres_instance: "PostgresService | None" = None
36
+
37
+
34
38
  def get_postgres_service() -> "PostgresService | None":
35
39
  """
36
- Get PostgresService instance with connection string from settings.
40
+ Get PostgresService singleton instance.
37
41
 
38
42
  Returns None if Postgres is disabled.
43
+ Uses singleton pattern to prevent connection pool exhaustion.
39
44
  """
45
+ global _postgres_instance
46
+
40
47
  if not settings.postgres.enabled:
41
48
  return None
42
-
43
- from .service import PostgresService
44
- return PostgresService()
49
+
50
+ if _postgres_instance is None:
51
+ from .service import PostgresService
52
+ _postgres_instance = PostgresService()
53
+
54
+ return _postgres_instance
45
55
 
46
56
  T = TypeVar("T", bound=BaseModel)
47
57
 
@@ -1,6 +1,13 @@
1
1
  """Session management services for conversation persistence and compression."""
2
2
 
3
3
  from .compression import MessageCompressor, SessionMessageStore
4
+ from .pydantic_messages import audit_session_history, session_to_pydantic_messages
4
5
  from .reload import reload_session
5
6
 
6
- __all__ = ["MessageCompressor", "SessionMessageStore", "reload_session"]
7
+ __all__ = [
8
+ "MessageCompressor",
9
+ "SessionMessageStore",
10
+ "audit_session_history",
11
+ "reload_session",
12
+ "session_to_pydantic_messages",
13
+ ]
@@ -65,7 +65,7 @@ def truncate_key(key: str, max_length: int = MAX_ENTITY_KEY_LENGTH) -> str:
65
65
  logger.warning(f"Truncated key from {len(key)} to {len(truncated)} chars: {key[:50]}...")
66
66
  return truncated
67
67
 
68
- from rem.models.entities import Message
68
+ from rem.models.entities import Message, Session
69
69
  from rem.services.postgres import PostgresService, Repository
70
70
  from rem.settings import settings
71
71
 
@@ -177,6 +177,39 @@ class SessionMessageStore:
177
177
  self.user_id = user_id
178
178
  self.compressor = compressor or MessageCompressor()
179
179
  self.repo = Repository(Message)
180
+ self._session_repo = Repository(Session, table_name="sessions")
181
+
182
+ async def _ensure_session_exists(
183
+ self,
184
+ session_id: str,
185
+ user_id: str | None = None,
186
+ ) -> None:
187
+ """
188
+ Ensure session exists, creating it if necessary.
189
+
190
+ Args:
191
+ session_id: Session UUID from X-Session-Id header
192
+ user_id: Optional user identifier
193
+ """
194
+ try:
195
+ # Check if session already exists by UUID
196
+ existing = await self._session_repo.get_by_id(session_id)
197
+ if existing:
198
+ return # Session already exists
199
+
200
+ # Create new session with the provided UUID as id
201
+ session = Session(
202
+ id=session_id, # Use the provided UUID as session id
203
+ name=session_id, # Default name to UUID, can be updated later
204
+ user_id=user_id or self.user_id,
205
+ tenant_id=self.user_id, # tenant_id set to user_id for scoping
206
+ )
207
+ await self._session_repo.upsert(session)
208
+ logger.info(f"Created session {session_id} for user {user_id or self.user_id}")
209
+
210
+ except Exception as e:
211
+ # Log but don't fail - session creation is best-effort
212
+ logger.warning(f"Failed to ensure session exists: {e}")
180
213
 
181
214
  async def store_message(
182
215
  self,
@@ -283,8 +316,10 @@ class SessionMessageStore:
283
316
  """
284
317
  Store all session messages and return compressed versions.
285
318
 
319
+ Ensures session exists before storing messages.
320
+
286
321
  Args:
287
- session_id: Session identifier
322
+ session_id: Session UUID
288
323
  messages: List of messages to store
289
324
  user_id: Optional user identifier
290
325
  compress: Whether to compress messages (default: True)
@@ -296,6 +331,9 @@ class SessionMessageStore:
296
331
  logger.debug("Postgres disabled, returning messages uncompressed")
297
332
  return messages
298
333
 
334
+ # Ensure session exists before storing messages
335
+ await self._ensure_session_exists(session_id, user_id)
336
+
299
337
  compressed_messages = []
300
338
 
301
339
  for idx, message in enumerate(messages):
@@ -0,0 +1,276 @@
1
+ """Convert stored session messages to pydantic-ai native message format.
2
+
3
+ This module enables proper conversation history replay by converting our simplified
4
+ storage format into pydantic-ai's native ModelRequest/ModelResponse types.
5
+
6
+ Key insight: When we store tool results, we only store the result (ToolReturnPart).
7
+ But LLM APIs require matching ToolCallPart for each ToolReturnPart. So we synthesize
8
+ the ToolCallPart from stored metadata (tool_name, tool_call_id, tool_arguments).
9
+
10
+ Storage format (our simplified format):
11
+ {"role": "user", "content": "..."}
12
+ {"role": "assistant", "content": "..."}
13
+ {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
14
+
15
+ Pydantic-ai format (what the LLM expects):
16
+ ModelRequest(parts=[UserPromptPart(content="...")])
17
+ ModelResponse(parts=[TextPart(content="..."), ToolCallPart(...)]) # Call
18
+ ModelRequest(parts=[ToolReturnPart(...)]) # Result
19
+
20
+ Example usage:
21
+ from rem.services.session.pydantic_messages import session_to_pydantic_messages
22
+
23
+ # Load session history
24
+ session_history = await store.load_session_messages(session_id)
25
+
26
+ # Convert to pydantic-ai format
27
+ message_history = session_to_pydantic_messages(session_history)
28
+
29
+ # Use with agent.run()
30
+ result = await agent.run(user_prompt, message_history=message_history)
31
+ """
32
+
33
+ import json
34
+ from typing import Any
35
+
36
+ from loguru import logger
37
+ from pydantic_ai.messages import (
38
+ ModelMessage,
39
+ ModelRequest,
40
+ ModelResponse,
41
+ SystemPromptPart,
42
+ TextPart,
43
+ ToolCallPart,
44
+ ToolReturnPart,
45
+ UserPromptPart,
46
+ )
47
+
48
+
49
+ def session_to_pydantic_messages(
50
+ session_history: list[dict[str, Any]],
51
+ system_prompt: str | None = None,
52
+ ) -> list[ModelMessage]:
53
+ """Convert stored session messages to pydantic-ai ModelMessage format.
54
+
55
+ Handles the conversion of our simplified storage format to pydantic-ai's
56
+ native message types, including synthesizing ToolCallPart for tool results.
57
+
58
+ IMPORTANT: pydantic-ai only auto-adds system prompts when message_history is empty.
59
+ When passing message_history to agent.run(), you MUST include the system prompt
60
+ via the system_prompt parameter here.
61
+
62
+ Args:
63
+ session_history: List of message dicts from SessionMessageStore.load_session_messages()
64
+ Each dict has: role, content, and optionally tool_name, tool_call_id, tool_arguments
65
+ system_prompt: The agent's system prompt (from schema description). This is REQUIRED
66
+ for proper agent behavior on subsequent turns, as pydantic-ai won't add it
67
+ automatically when message_history is provided.
68
+
69
+ Returns:
70
+ List of ModelMessage (ModelRequest | ModelResponse) ready for agent.run(message_history=...)
71
+
72
+ Note:
73
+ - System prompts ARE included as SystemPromptPart when system_prompt is provided
74
+ - Tool results require synthesized ToolCallPart to satisfy LLM API requirements
75
+ - The first message in session_history should be "user" role (from context builder)
76
+ """
77
+ messages: list[ModelMessage] = []
78
+
79
+ # CRITICAL: Prepend agent's system prompt if provided
80
+ # This ensures the agent's instructions are present on every turn
81
+ # pydantic-ai only auto-adds system prompts when message_history is empty
82
+ if system_prompt:
83
+ messages.append(ModelRequest(parts=[SystemPromptPart(content=system_prompt)]))
84
+ logger.debug(f"Prepended agent system prompt ({len(system_prompt)} chars) to message history")
85
+
86
+ # Track pending tool results to batch them with assistant responses
87
+ # When we see a tool message, we need to:
88
+ # 1. Add a ModelResponse with ToolCallPart (synthesized)
89
+ # 2. Add a ModelRequest with ToolReturnPart (actual result)
90
+
91
+ i = 0
92
+ while i < len(session_history):
93
+ msg = session_history[i]
94
+ role = msg.get("role", "")
95
+ content = msg.get("content", "")
96
+
97
+ if role == "user":
98
+ # User messages become ModelRequest with UserPromptPart
99
+ messages.append(ModelRequest(parts=[UserPromptPart(content=content)]))
100
+
101
+ elif role == "assistant":
102
+ # Assistant text becomes ModelResponse with TextPart
103
+ # Check if there are following tool messages that should be grouped
104
+ tool_calls = []
105
+ tool_returns = []
106
+
107
+ # Look ahead for tool messages that follow this assistant message
108
+ j = i + 1
109
+ while j < len(session_history) and session_history[j].get("role") == "tool":
110
+ tool_msg = session_history[j]
111
+ tool_name = tool_msg.get("tool_name", "unknown_tool")
112
+ tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
113
+ tool_arguments = tool_msg.get("tool_arguments", {})
114
+ tool_content = tool_msg.get("content", "{}")
115
+
116
+ # Parse tool content if it's a JSON string
117
+ if isinstance(tool_content, str):
118
+ try:
119
+ tool_result = json.loads(tool_content)
120
+ except json.JSONDecodeError:
121
+ tool_result = {"raw": tool_content}
122
+ else:
123
+ tool_result = tool_content
124
+
125
+ # Synthesize ToolCallPart (what the model "called")
126
+ tool_calls.append(ToolCallPart(
127
+ tool_name=tool_name,
128
+ args=tool_arguments if tool_arguments else {},
129
+ tool_call_id=tool_call_id,
130
+ ))
131
+
132
+ # Create ToolReturnPart (the actual result)
133
+ tool_returns.append(ToolReturnPart(
134
+ tool_name=tool_name,
135
+ content=tool_result,
136
+ tool_call_id=tool_call_id,
137
+ ))
138
+
139
+ j += 1
140
+
141
+ # Build the assistant's ModelResponse
142
+ response_parts = []
143
+
144
+ # Add tool calls first (if any)
145
+ response_parts.extend(tool_calls)
146
+
147
+ # Add text content (if any)
148
+ if content:
149
+ response_parts.append(TextPart(content=content))
150
+
151
+ # Only add ModelResponse if we have parts
152
+ if response_parts:
153
+ messages.append(ModelResponse(
154
+ parts=response_parts,
155
+ model_name="recovered", # We don't store model name
156
+ ))
157
+
158
+ # Add tool returns as ModelRequest (required by LLM API)
159
+ if tool_returns:
160
+ messages.append(ModelRequest(parts=tool_returns))
161
+
162
+ # Skip the tool messages we just processed
163
+ i = j - 1
164
+
165
+ elif role == "tool":
166
+ # Orphan tool message (no preceding assistant) - synthesize both parts
167
+ tool_name = msg.get("tool_name", "unknown_tool")
168
+ tool_call_id = msg.get("tool_call_id", f"call_{i}")
169
+ tool_arguments = msg.get("tool_arguments", {})
170
+ tool_content = msg.get("content", "{}")
171
+
172
+ # Parse tool content
173
+ if isinstance(tool_content, str):
174
+ try:
175
+ tool_result = json.loads(tool_content)
176
+ except json.JSONDecodeError:
177
+ tool_result = {"raw": tool_content}
178
+ else:
179
+ tool_result = tool_content
180
+
181
+ # Synthesize the tool call (ModelResponse with ToolCallPart)
182
+ messages.append(ModelResponse(
183
+ parts=[ToolCallPart(
184
+ tool_name=tool_name,
185
+ args=tool_arguments if tool_arguments else {},
186
+ tool_call_id=tool_call_id,
187
+ )],
188
+ model_name="recovered",
189
+ ))
190
+
191
+ # Add the tool return (ModelRequest with ToolReturnPart)
192
+ messages.append(ModelRequest(
193
+ parts=[ToolReturnPart(
194
+ tool_name=tool_name,
195
+ content=tool_result,
196
+ tool_call_id=tool_call_id,
197
+ )]
198
+ ))
199
+
200
+ elif role == "system":
201
+ # Skip system messages - pydantic-ai handles these via Agent.system_prompt
202
+ logger.debug("Skipping system message in session history (handled by Agent)")
203
+
204
+ else:
205
+ logger.warning(f"Unknown message role in session history: {role}")
206
+
207
+ i += 1
208
+
209
+ logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
210
+ return messages
211
+
212
+
213
+ def audit_session_history(
214
+ session_id: str,
215
+ agent_name: str,
216
+ prompt: str,
217
+ raw_session_history: list[dict[str, Any]],
218
+ pydantic_messages_count: int,
219
+ ) -> None:
220
+ """
221
+ Dump session history to a YAML file for debugging.
222
+
223
+ Only runs when DEBUG__AUDIT_SESSION=true. Writes to DEBUG__AUDIT_DIR (default /tmp).
224
+ Appends to the same file for a session, so all agent invocations are in one place.
225
+
226
+ Args:
227
+ session_id: The session identifier
228
+ agent_name: Name of the agent being invoked
229
+ prompt: The prompt being sent to the agent
230
+ raw_session_history: The raw session messages from the database
231
+ pydantic_messages_count: Count of converted pydantic-ai messages
232
+ """
233
+ from ...settings import settings
234
+
235
+ if not settings.debug.audit_session:
236
+ return
237
+
238
+ try:
239
+ import yaml
240
+ from pathlib import Path
241
+ from ...utils.date_utils import utc_now, to_iso
242
+
243
+ audit_dir = Path(settings.debug.audit_dir)
244
+ audit_dir.mkdir(parents=True, exist_ok=True)
245
+ audit_file = audit_dir / f"{session_id}.yaml"
246
+
247
+ # Create entry for this agent invocation
248
+ entry = {
249
+ "timestamp": to_iso(utc_now()),
250
+ "agent_name": agent_name,
251
+ "prompt": prompt,
252
+ "raw_history_count": len(raw_session_history),
253
+ "pydantic_messages_count": pydantic_messages_count,
254
+ "raw_session_history": raw_session_history,
255
+ }
256
+
257
+ # Load existing data or create new
258
+ existing_data: dict[str, Any] = {"session_id": session_id, "invocations": []}
259
+ if audit_file.exists():
260
+ with open(audit_file) as f:
261
+ loaded = yaml.safe_load(f)
262
+ if loaded:
263
+ # Ensure session_id is always present (backfill if missing)
264
+ existing_data = {
265
+ "session_id": loaded.get("session_id", session_id),
266
+ "invocations": loaded.get("invocations", []),
267
+ }
268
+
269
+ # Append this invocation
270
+ existing_data["invocations"].append(entry)
271
+
272
+ with open(audit_file, "w") as f:
273
+ yaml.dump(existing_data, f, default_flow_style=False, allow_unicode=True)
274
+ logger.info(f"DEBUG: Session audit updated: {audit_file}")
275
+ except Exception as e:
276
+ logger.warning(f"DEBUG: Failed to dump session audit: {e}")
rem/settings.py CHANGED
@@ -1651,6 +1651,33 @@ class EmailSettings(BaseSettings):
1651
1651
  return kwargs
1652
1652
 
1653
1653
 
1654
+ class DebugSettings(BaseSettings):
1655
+ """
1656
+ Debug settings for development and troubleshooting.
1657
+
1658
+ Environment variables:
1659
+ DEBUG__AUDIT_SESSION - Dump session history to /tmp/{session_id}.yaml
1660
+ DEBUG__AUDIT_DIR - Directory for session audit files (default: /tmp)
1661
+ """
1662
+
1663
+ model_config = SettingsConfigDict(
1664
+ env_prefix="DEBUG__",
1665
+ env_file=".env",
1666
+ env_file_encoding="utf-8",
1667
+ extra="ignore",
1668
+ )
1669
+
1670
+ audit_session: bool = Field(
1671
+ default=False,
1672
+ description="When true, dump full session history to audit files for debugging",
1673
+ )
1674
+
1675
+ audit_dir: str = Field(
1676
+ default="/tmp",
1677
+ description="Directory for session audit files",
1678
+ )
1679
+
1680
+
1654
1681
  class TestSettings(BaseSettings):
1655
1682
  """
1656
1683
  Test environment settings.
@@ -1767,6 +1794,7 @@ class Settings(BaseSettings):
1767
1794
  schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
1768
1795
  email: EmailSettings = Field(default_factory=EmailSettings)
1769
1796
  test: TestSettings = Field(default_factory=TestSettings)
1797
+ debug: DebugSettings = Field(default_factory=DebugSettings)
1770
1798
 
1771
1799
 
1772
1800
  # Auto-load .env file from current directory if it exists