minder-cli 0.2.0__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.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1087 @@
1
+ """
2
+ Relational Store — async SQLAlchemy CRUD for all domain entities.
3
+
4
+ Supports SQLite (dev, via aiosqlite) and PostgreSQL (prod, via asyncpg).
5
+ URL examples:
6
+ SQLite : sqlite+aiosqlite:///path/to/minder.db
7
+ In-mem : sqlite+aiosqlite:///:memory:
8
+ Postgres: postgresql+asyncpg://user:pass@host/db
9
+ """
10
+
11
+ import math
12
+ import uuid
13
+ from collections import Counter
14
+ from contextlib import asynccontextmanager
15
+ from datetime import UTC, datetime, timedelta
16
+ from typing import Any, AsyncGenerator, List, Optional, cast
17
+
18
+ from sqlalchemy import delete, select, update
19
+ from sqlalchemy.engine import CursorResult
20
+ from sqlalchemy.ext.asyncio import (
21
+ AsyncEngine,
22
+ AsyncSession,
23
+ async_sessionmaker,
24
+ create_async_engine,
25
+ )
26
+
27
+ from minder.models import (
28
+ AdminJob,
29
+ AuditLog,
30
+ Base,
31
+ Client,
32
+ ClientApiKey,
33
+ ClientSession,
34
+ Document,
35
+ Error,
36
+ Feedback,
37
+ History,
38
+ Repository,
39
+ RepositoryWorkflowState,
40
+ Rule,
41
+ Session,
42
+ Skill,
43
+ Prompt,
44
+ User,
45
+ Workflow,
46
+ )
47
+
48
+ _REGISTERED_MODELS = (
49
+ AdminJob,
50
+ AuditLog,
51
+ Client,
52
+ ClientApiKey,
53
+ ClientSession,
54
+ Document,
55
+ Error,
56
+ Feedback,
57
+ History,
58
+ Repository,
59
+ RepositoryWorkflowState,
60
+ Rule,
61
+ Session,
62
+ Skill,
63
+ Prompt,
64
+ User,
65
+ Workflow,
66
+ )
67
+
68
+
69
+ def _normalize_datetime(value: datetime | None) -> datetime | None:
70
+ if value is None:
71
+ return None
72
+ if value.tzinfo is None:
73
+ return value.replace(tzinfo=UTC)
74
+ return value.astimezone(UTC)
75
+
76
+
77
+ class RelationalStore:
78
+ """Async SQLAlchemy store. Thread-safe; one instance per application."""
79
+
80
+ def __init__(self, db_url: str, echo: bool = False) -> None:
81
+ self._engine: AsyncEngine = create_async_engine(db_url, echo=echo)
82
+ self._session_factory = async_sessionmaker(
83
+ self._engine,
84
+ expire_on_commit=False,
85
+ class_=AsyncSession,
86
+ )
87
+
88
+ # ------------------------------------------------------------------
89
+ # Lifecycle
90
+ # ------------------------------------------------------------------
91
+
92
+ async def init_db(self) -> None:
93
+ """Create all tables (idempotent)."""
94
+ async with self._engine.begin() as conn:
95
+ await conn.run_sync(Base.metadata.create_all)
96
+
97
+ async def dispose(self) -> None:
98
+ """Dispose the engine connection pool."""
99
+ await self._engine.dispose()
100
+
101
+ @asynccontextmanager
102
+ async def _session(self) -> AsyncGenerator[AsyncSession, None]:
103
+ """Context manager that auto-commits or rolls back."""
104
+ async with self._session_factory() as sess:
105
+ try:
106
+ yield sess
107
+ await sess.commit()
108
+ except Exception:
109
+ await sess.rollback()
110
+ raise
111
+
112
+ # ------------------------------------------------------------------
113
+ # Prompts
114
+ # ------------------------------------------------------------------
115
+
116
+ async def create_prompt(self, **kwargs: Any) -> Prompt:
117
+ async with self._session() as sess:
118
+ item = Prompt(**kwargs)
119
+ sess.add(item)
120
+ await sess.flush()
121
+ await sess.refresh(item)
122
+ return item
123
+
124
+ async def get_prompt_by_id(self, prompt_id: uuid.UUID) -> Optional[Prompt]:
125
+ async with self._session() as sess:
126
+ return await sess.get(Prompt, prompt_id)
127
+
128
+ async def get_prompt_by_name(self, name: str) -> Optional[Prompt]:
129
+ async with self._session() as sess:
130
+ stmt = select(Prompt).where(Prompt.name == name)
131
+ res = await sess.execute(stmt)
132
+ return res.scalar_one_or_none()
133
+
134
+ async def list_prompts(self) -> List[Prompt]:
135
+ async with self._session() as sess:
136
+ stmt = select(Prompt).order_by(Prompt.name)
137
+ res = await sess.execute(stmt)
138
+ return list(res.scalars().all())
139
+
140
+ async def update_prompt(
141
+ self, prompt_id: uuid.UUID, **kwargs: Any
142
+ ) -> Optional[Prompt]:
143
+ async with self._session() as sess:
144
+ item = await sess.get(Prompt, prompt_id)
145
+ if not item:
146
+ return None
147
+ for k, v in kwargs.items():
148
+ setattr(item, k, v)
149
+ await sess.flush()
150
+ await sess.refresh(item)
151
+ return item
152
+
153
+ async def delete_prompt(self, prompt_id: uuid.UUID) -> None:
154
+ async with self._session() as sess:
155
+ stmt = delete(Prompt).where(Prompt.id == prompt_id)
156
+ await sess.execute(stmt)
157
+
158
+ # ------------------------------------------------------------------
159
+ # User
160
+ # ------------------------------------------------------------------
161
+
162
+ async def create_user(self, **kwargs) -> User:
163
+ async with self._session() as sess:
164
+ user = User(**kwargs)
165
+ sess.add(user)
166
+ await sess.flush()
167
+ await sess.refresh(user)
168
+ return user
169
+
170
+ async def get_user_by_id(self, user_id: uuid.UUID) -> Optional[User]:
171
+ async with self._session() as sess:
172
+ result = await sess.execute(select(User).where(User.id == user_id))
173
+ return result.scalar_one_or_none()
174
+
175
+ async def get_user_by_email(self, email: str) -> Optional[User]:
176
+ async with self._session() as sess:
177
+ result = await sess.execute(select(User).where(User.email == email))
178
+ return result.scalar_one_or_none()
179
+
180
+ async def get_user_by_username(self, username: str) -> Optional[User]:
181
+ async with self._session() as sess:
182
+ result = await sess.execute(select(User).where(User.username == username))
183
+ return result.scalar_one_or_none()
184
+
185
+ async def list_users(self, active_only: bool = True) -> List[User]:
186
+ async with self._session() as sess:
187
+ stmt = select(User)
188
+ if active_only:
189
+ stmt = stmt.where(User.is_active.is_(True))
190
+ result = await sess.execute(stmt)
191
+ return list(result.scalars().all())
192
+
193
+ async def update_user(self, user_id: uuid.UUID, **kwargs) -> Optional[User]:
194
+ async with self._session() as sess:
195
+ await sess.execute(update(User).where(User.id == user_id).values(**kwargs))
196
+ result = await sess.execute(select(User).where(User.id == user_id))
197
+ return result.scalar_one_or_none()
198
+
199
+ async def delete_user(self, user_id: uuid.UUID) -> None:
200
+ async with self._session() as sess:
201
+ await sess.execute(delete(User).where(User.id == user_id))
202
+
203
+ async def has_admin_users(self) -> bool:
204
+ async with self._session() as sess:
205
+ result = await sess.execute(
206
+ select(select(User).where(User.role == "admin").exists())
207
+ )
208
+ return result.scalar_one_or_none() or False
209
+
210
+ # ------------------------------------------------------------------
211
+ # Skill
212
+ # ------------------------------------------------------------------
213
+
214
+ async def create_skill(self, **kwargs) -> Skill:
215
+ async with self._session() as sess:
216
+ skill = Skill(**kwargs)
217
+ sess.add(skill)
218
+ await sess.flush()
219
+ await sess.refresh(skill)
220
+ return skill
221
+
222
+ async def get_skill_by_id(self, skill_id: uuid.UUID) -> Optional[Skill]:
223
+ async with self._session() as sess:
224
+ result = await sess.execute(select(Skill).where(Skill.id == skill_id))
225
+ return result.scalar_one_or_none()
226
+
227
+ async def list_skills(self) -> List[Skill]:
228
+ async with self._session() as sess:
229
+ result = await sess.execute(select(Skill))
230
+ return list(result.scalars().all())
231
+
232
+ async def update_skill(self, skill_id: uuid.UUID, **kwargs) -> Optional[Skill]:
233
+ async with self._session() as sess:
234
+ skill = await sess.get(Skill, skill_id)
235
+ if skill is None:
236
+ return None
237
+ for key, value in kwargs.items():
238
+ setattr(skill, key, value)
239
+ await sess.flush()
240
+ await sess.refresh(skill)
241
+ return skill
242
+
243
+ async def delete_skill(self, skill_id: uuid.UUID) -> None:
244
+ async with self._session() as sess:
245
+ await sess.execute(delete(Skill).where(Skill.id == skill_id))
246
+
247
+ # ------------------------------------------------------------------
248
+ # Admin Jobs
249
+ # ------------------------------------------------------------------
250
+
251
+ async def create_admin_job(self, **kwargs: Any) -> AdminJob:
252
+ async with self._session() as sess:
253
+ job = AdminJob(**kwargs)
254
+ sess.add(job)
255
+ await sess.flush()
256
+ await sess.refresh(job)
257
+ return job
258
+
259
+ async def get_admin_job_by_id(self, job_id: uuid.UUID) -> Optional[AdminJob]:
260
+ async with self._session() as sess:
261
+ result = await sess.execute(select(AdminJob).where(AdminJob.id == job_id))
262
+ return result.scalar_one_or_none()
263
+
264
+ async def list_admin_jobs(
265
+ self,
266
+ *,
267
+ job_type: str | None = None,
268
+ status: str | None = None,
269
+ requested_by_user_id: uuid.UUID | None = None,
270
+ limit: int | None = None,
271
+ offset: int = 0,
272
+ ) -> List[AdminJob]:
273
+ async with self._session() as sess:
274
+ stmt = select(AdminJob).order_by(AdminJob.created_at.desc())
275
+ if job_type:
276
+ stmt = stmt.where(AdminJob.job_type == job_type)
277
+ if status:
278
+ stmt = stmt.where(AdminJob.status == status)
279
+ if requested_by_user_id is not None:
280
+ stmt = stmt.where(AdminJob.requested_by_user_id == requested_by_user_id)
281
+ if offset:
282
+ stmt = stmt.offset(offset)
283
+ if limit is not None:
284
+ stmt = stmt.limit(limit)
285
+ result = await sess.execute(stmt)
286
+ return list(result.scalars().all())
287
+
288
+ async def update_admin_job(
289
+ self, job_id: uuid.UUID, **kwargs: Any
290
+ ) -> Optional[AdminJob]:
291
+ async with self._session() as sess:
292
+ job = await sess.get(AdminJob, job_id)
293
+ if job is None:
294
+ return None
295
+ for key, value in kwargs.items():
296
+ setattr(job, key, value)
297
+ await sess.flush()
298
+ await sess.refresh(job)
299
+ return job
300
+
301
+ # ------------------------------------------------------------------
302
+ # Session
303
+ # ------------------------------------------------------------------
304
+
305
+ async def create_session(self, **kwargs) -> Session:
306
+ async with self._session() as sess:
307
+ session = Session(**kwargs)
308
+ sess.add(session)
309
+ await sess.flush()
310
+ await sess.refresh(session)
311
+ return session
312
+
313
+ async def get_session_by_id(self, session_id: uuid.UUID) -> Optional[Session]:
314
+ async with self._session() as sess:
315
+ result = await sess.execute(select(Session).where(Session.id == session_id))
316
+ return result.scalar_one_or_none()
317
+
318
+ async def get_sessions_by_user(self, user_id: uuid.UUID) -> List[Session]:
319
+ async with self._session() as sess:
320
+ result = await sess.execute(
321
+ select(Session).where(Session.user_id == user_id)
322
+ )
323
+ return list(result.scalars().all())
324
+
325
+ async def get_sessions_by_client(self, client_id: uuid.UUID) -> List[Session]:
326
+ async with self._session() as sess:
327
+ result = await sess.execute(
328
+ select(Session).where(Session.client_id == client_id)
329
+ )
330
+ return list(result.scalars().all())
331
+
332
+ async def find_session_by_name(
333
+ self,
334
+ name: str,
335
+ *,
336
+ user_id: uuid.UUID | None = None,
337
+ client_id: uuid.UUID | None = None,
338
+ ) -> Optional[Session]:
339
+ async with self._session() as sess:
340
+ query = select(Session).where(Session.name == name)
341
+ if client_id is not None:
342
+ query = query.where(Session.client_id == client_id)
343
+ elif user_id is not None:
344
+ query = query.where(Session.user_id == user_id)
345
+ query = query.order_by(Session.last_active.desc()).limit(1)
346
+ result = await sess.execute(query)
347
+ return result.scalar_one_or_none()
348
+
349
+ async def update_session(
350
+ self, session_id: uuid.UUID, **kwargs
351
+ ) -> Optional[Session]:
352
+ async with self._session() as sess:
353
+ await sess.execute(
354
+ update(Session).where(Session.id == session_id).values(**kwargs)
355
+ )
356
+ result = await sess.execute(select(Session).where(Session.id == session_id))
357
+ return result.scalar_one_or_none()
358
+
359
+ async def delete_session(self, session_id: uuid.UUID) -> None:
360
+ async with self._session() as sess:
361
+ await sess.execute(delete(Session).where(Session.id == session_id))
362
+
363
+ async def cleanup_expired_sessions(
364
+ self,
365
+ *,
366
+ now: datetime | None = None,
367
+ user_id: uuid.UUID | None = None,
368
+ client_id: uuid.UUID | None = None,
369
+ ) -> dict[str, int]:
370
+ reference_time = _normalize_datetime(now) or datetime.now(UTC)
371
+ async with self._session() as sess:
372
+ query = select(Session)
373
+ if user_id is not None:
374
+ query = query.where(Session.user_id == user_id)
375
+ if client_id is not None:
376
+ query = query.where(Session.client_id == client_id)
377
+
378
+ result = await sess.execute(query)
379
+ sessions = list(result.scalars().all())
380
+ expired_session_ids = [
381
+ session.id
382
+ for session in sessions
383
+ if session.ttl > 0
384
+ and (
385
+ (
386
+ _normalize_datetime(session.last_active)
387
+ or _normalize_datetime(session.created_at)
388
+ or reference_time
389
+ )
390
+ + timedelta(seconds=session.ttl)
391
+ )
392
+ <= reference_time
393
+ ]
394
+ if not expired_session_ids:
395
+ return {"deleted_sessions": 0, "deleted_history": 0}
396
+
397
+ history_result = await sess.execute(
398
+ delete(History).where(History.session_id.in_(expired_session_ids))
399
+ )
400
+ session_result = await sess.execute(
401
+ delete(Session).where(Session.id.in_(expired_session_ids))
402
+ )
403
+ history_cursor = cast(CursorResult[Any], history_result)
404
+ session_cursor = cast(CursorResult[Any], session_result)
405
+ return {
406
+ "deleted_sessions": int(session_cursor.rowcount or 0),
407
+ "deleted_history": int(history_cursor.rowcount or 0),
408
+ }
409
+
410
+ # ------------------------------------------------------------------
411
+ # Workflow
412
+ # ------------------------------------------------------------------
413
+
414
+ async def create_workflow(self, **kwargs) -> Workflow:
415
+ async with self._session() as sess:
416
+ workflow = Workflow(**kwargs)
417
+ sess.add(workflow)
418
+ await sess.flush()
419
+ await sess.refresh(workflow)
420
+ return workflow
421
+
422
+ async def get_workflow_by_id(self, workflow_id: uuid.UUID) -> Optional[Workflow]:
423
+ async with self._session() as sess:
424
+ result = await sess.execute(
425
+ select(Workflow).where(Workflow.id == workflow_id)
426
+ )
427
+ return result.scalar_one_or_none()
428
+
429
+ async def get_workflow_by_name(self, name: str) -> Optional[Workflow]:
430
+ async with self._session() as sess:
431
+ result = await sess.execute(select(Workflow).where(Workflow.name == name))
432
+ return result.scalar_one_or_none()
433
+
434
+ async def list_workflows(self) -> List[Workflow]:
435
+ async with self._session() as sess:
436
+ result = await sess.execute(select(Workflow))
437
+ return list(result.scalars().all())
438
+
439
+ async def update_workflow(
440
+ self, workflow_id: uuid.UUID, **kwargs
441
+ ) -> Optional[Workflow]:
442
+ async with self._session() as sess:
443
+ await sess.execute(
444
+ update(Workflow).where(Workflow.id == workflow_id).values(**kwargs)
445
+ )
446
+ result = await sess.execute(
447
+ select(Workflow).where(Workflow.id == workflow_id)
448
+ )
449
+ return result.scalar_one_or_none()
450
+
451
+ async def delete_workflow(self, workflow_id: uuid.UUID) -> None:
452
+ async with self._session() as sess:
453
+ await sess.execute(delete(Workflow).where(Workflow.id == workflow_id))
454
+
455
+ # ------------------------------------------------------------------
456
+ # Repository
457
+ # ------------------------------------------------------------------
458
+
459
+ async def create_repository(self, **kwargs) -> Repository:
460
+ async with self._session() as sess:
461
+ repo = Repository(**kwargs)
462
+ sess.add(repo)
463
+ await sess.flush()
464
+ await sess.refresh(repo)
465
+ return repo
466
+
467
+ async def get_repository_by_id(self, repo_id: uuid.UUID) -> Optional[Repository]:
468
+ async with self._session() as sess:
469
+ result = await sess.execute(
470
+ select(Repository).where(Repository.id == repo_id)
471
+ )
472
+ return result.scalar_one_or_none()
473
+
474
+ async def get_repository_by_name(self, repo_name: str) -> Optional[Repository]:
475
+ async with self._session() as sess:
476
+ result = await sess.execute(
477
+ select(Repository).where(Repository.repo_name == repo_name)
478
+ )
479
+ return result.scalar_one_or_none()
480
+
481
+ async def list_repositories(self) -> List[Repository]:
482
+ async with self._session() as sess:
483
+ result = await sess.execute(select(Repository))
484
+ return list(result.scalars().all())
485
+
486
+ async def update_repository(
487
+ self, repo_id: uuid.UUID, **kwargs
488
+ ) -> Optional[Repository]:
489
+ async with self._session() as sess:
490
+ await sess.execute(
491
+ update(Repository).where(Repository.id == repo_id).values(**kwargs)
492
+ )
493
+ result = await sess.execute(
494
+ select(Repository).where(Repository.id == repo_id)
495
+ )
496
+ return result.scalar_one_or_none()
497
+
498
+ async def delete_repository(self, repo_id: uuid.UUID) -> None:
499
+ async with self._session() as sess:
500
+ await sess.execute(delete(Repository).where(Repository.id == repo_id))
501
+
502
+ # ------------------------------------------------------------------
503
+ # Client Gateway
504
+ # ------------------------------------------------------------------
505
+
506
+ async def create_client(self, **kwargs) -> Client:
507
+ async with self._session() as sess:
508
+ client = Client(**kwargs)
509
+ sess.add(client)
510
+ await sess.flush()
511
+ await sess.refresh(client)
512
+ return client
513
+
514
+ async def get_client_by_id(self, client_id: uuid.UUID) -> Optional[Client]:
515
+ async with self._session() as sess:
516
+ result = await sess.execute(select(Client).where(Client.id == client_id))
517
+ return result.scalar_one_or_none()
518
+
519
+ async def get_client_by_slug(self, slug: str) -> Optional[Client]:
520
+ async with self._session() as sess:
521
+ result = await sess.execute(select(Client).where(Client.slug == slug))
522
+ return result.scalar_one_or_none()
523
+
524
+ async def list_clients(self) -> List[Client]:
525
+ async with self._session() as sess:
526
+ result = await sess.execute(select(Client))
527
+ return list(result.scalars().all())
528
+
529
+ async def update_client(self, client_id: uuid.UUID, **kwargs) -> Optional[Client]:
530
+ async with self._session() as sess:
531
+ await sess.execute(
532
+ update(Client).where(Client.id == client_id).values(**kwargs)
533
+ )
534
+ result = await sess.execute(select(Client).where(Client.id == client_id))
535
+ return result.scalar_one_or_none()
536
+
537
+ async def create_client_api_key(self, **kwargs) -> ClientApiKey:
538
+ async with self._session() as sess:
539
+ key = ClientApiKey(**kwargs)
540
+ sess.add(key)
541
+ await sess.flush()
542
+ await sess.refresh(key)
543
+ return key
544
+
545
+ async def list_client_api_keys(self, client_id: uuid.UUID) -> List[ClientApiKey]:
546
+ async with self._session() as sess:
547
+ result = await sess.execute(
548
+ select(ClientApiKey).where(ClientApiKey.client_id == client_id)
549
+ )
550
+ return list(result.scalars().all())
551
+
552
+ async def update_client_api_key(
553
+ self, key_id: uuid.UUID, **kwargs
554
+ ) -> Optional[ClientApiKey]:
555
+ async with self._session() as sess:
556
+ await sess.execute(
557
+ update(ClientApiKey).where(ClientApiKey.id == key_id).values(**kwargs)
558
+ )
559
+ result = await sess.execute(
560
+ select(ClientApiKey).where(ClientApiKey.id == key_id)
561
+ )
562
+ return result.scalar_one_or_none()
563
+
564
+ async def create_client_session(self, **kwargs) -> ClientSession:
565
+ async with self._session() as sess:
566
+ client_session = ClientSession(**kwargs)
567
+ sess.add(client_session)
568
+ await sess.flush()
569
+ await sess.refresh(client_session)
570
+ return client_session
571
+
572
+ async def count_active_client_sessions(self) -> int:
573
+ from sqlalchemy import func as sqlfunc
574
+ from datetime import datetime
575
+
576
+ async with self._session() as sess:
577
+ # Using naive comparison for SQLite compatibility
578
+ now = datetime.utcnow()
579
+ stmt = select(sqlfunc.count(ClientSession.id)).where(
580
+ ClientSession.status == "active",
581
+ ClientSession.expires_at > now,
582
+ )
583
+ result = await sess.execute(stmt)
584
+ return result.scalar_one() or 0
585
+
586
+ async def get_client_session_by_token_id(
587
+ self, token_id: str
588
+ ) -> Optional[ClientSession]:
589
+ async with self._session() as sess:
590
+ result = await sess.execute(
591
+ select(ClientSession).where(ClientSession.access_token_id == token_id)
592
+ )
593
+ return result.scalar_one_or_none()
594
+
595
+ async def update_client_session(
596
+ self, session_id: uuid.UUID, **kwargs
597
+ ) -> Optional[ClientSession]:
598
+ async with self._session() as sess:
599
+ await sess.execute(
600
+ update(ClientSession)
601
+ .where(ClientSession.id == session_id)
602
+ .values(**kwargs)
603
+ )
604
+ result = await sess.execute(
605
+ select(ClientSession).where(ClientSession.id == session_id)
606
+ )
607
+ return result.scalar_one_or_none()
608
+
609
+ async def create_audit_log(self, **kwargs) -> AuditLog:
610
+ async with self._session() as sess:
611
+ audit_log = AuditLog(**kwargs)
612
+ sess.add(audit_log)
613
+ await sess.flush()
614
+ await sess.refresh(audit_log)
615
+ return audit_log
616
+
617
+ async def list_audit_logs(
618
+ self,
619
+ *,
620
+ actor_id: str | None = None,
621
+ event_type: str | None = None,
622
+ outcome: str | None = None,
623
+ limit: int | None = None,
624
+ offset: int = 0,
625
+ ) -> List[AuditLog]:
626
+ from sqlalchemy import desc
627
+
628
+ async with self._session() as sess:
629
+ stmt = select(AuditLog).order_by(desc(AuditLog.created_at))
630
+ if actor_id is not None:
631
+ stmt = stmt.where(AuditLog.actor_id == actor_id)
632
+ if event_type is not None:
633
+ stmt = stmt.where(AuditLog.event_type == event_type)
634
+ if outcome is not None:
635
+ stmt = stmt.where(AuditLog.outcome == outcome)
636
+ stmt = stmt.offset(offset)
637
+ if limit is not None:
638
+ stmt = stmt.limit(limit)
639
+ result = await sess.execute(stmt)
640
+ return list(result.scalars().all())
641
+
642
+ async def count_audit_logs(
643
+ self,
644
+ *,
645
+ actor_id: str | None = None,
646
+ event_type: str | None = None,
647
+ outcome: str | None = None,
648
+ ) -> int:
649
+ from sqlalchemy import func as sqlfunc
650
+
651
+ async with self._session() as sess:
652
+ stmt = select(sqlfunc.count()).select_from(AuditLog)
653
+ if actor_id is not None:
654
+ stmt = stmt.where(AuditLog.actor_id == actor_id)
655
+ if event_type is not None:
656
+ stmt = stmt.where(AuditLog.event_type == event_type)
657
+ if outcome is not None:
658
+ stmt = stmt.where(AuditLog.outcome == outcome)
659
+ result = await sess.execute(stmt)
660
+ return result.scalar_one() or 0
661
+
662
+ async def get_audit_summary(
663
+ self,
664
+ *,
665
+ actor_id: str | None = None,
666
+ event_type: str | None = None,
667
+ outcome: str | None = None,
668
+ group_by: str = "event_type",
669
+ ) -> dict[str, int]:
670
+ from sqlalchemy import func as sqlfunc
671
+
672
+ async with self._session() as sess:
673
+ # Handle nested group_by like "audit_metadata.client_id"
674
+ if "." in group_by:
675
+ parent, child = group_by.split(".", 1)
676
+ col = getattr(AuditLog, parent)[child].as_string()
677
+ else:
678
+ col = getattr(AuditLog, group_by)
679
+
680
+ stmt = select(col, sqlfunc.count()).group_by(col)
681
+
682
+ if actor_id is not None:
683
+ stmt = stmt.where(AuditLog.actor_id == actor_id)
684
+ if event_type is not None:
685
+ stmt = stmt.where(AuditLog.event_type == event_type)
686
+ if outcome is not None:
687
+ stmt = stmt.where(AuditLog.outcome == outcome)
688
+
689
+ result = await sess.execute(stmt)
690
+ return {
691
+ str(row[0]) if row[0] is not None else "unknown": int(row[1])
692
+ for row in result.all()
693
+ }
694
+
695
+ # ------------------------------------------------------------------
696
+ # RepositoryWorkflowState
697
+ # ------------------------------------------------------------------
698
+
699
+ async def create_workflow_state(self, **kwargs) -> RepositoryWorkflowState:
700
+ async with self._session() as sess:
701
+ state = RepositoryWorkflowState(**kwargs)
702
+ sess.add(state)
703
+ await sess.flush()
704
+ await sess.refresh(state)
705
+ return state
706
+
707
+ async def get_workflow_state_by_id(
708
+ self, state_id: uuid.UUID
709
+ ) -> Optional[RepositoryWorkflowState]:
710
+ async with self._session() as sess:
711
+ result = await sess.execute(
712
+ select(RepositoryWorkflowState).where(
713
+ RepositoryWorkflowState.id == state_id
714
+ )
715
+ )
716
+ return result.scalar_one_or_none()
717
+
718
+ async def get_workflow_state_by_repo(
719
+ self, repo_id: uuid.UUID
720
+ ) -> Optional[RepositoryWorkflowState]:
721
+ async with self._session() as sess:
722
+ result = await sess.execute(
723
+ select(RepositoryWorkflowState).where(
724
+ RepositoryWorkflowState.repo_id == repo_id
725
+ )
726
+ )
727
+ return result.scalar_one_or_none()
728
+
729
+ async def update_workflow_state(
730
+ self, state_id: uuid.UUID, **kwargs
731
+ ) -> Optional[RepositoryWorkflowState]:
732
+ async with self._session() as sess:
733
+ await sess.execute(
734
+ update(RepositoryWorkflowState)
735
+ .where(RepositoryWorkflowState.id == state_id)
736
+ .values(**kwargs)
737
+ )
738
+ result = await sess.execute(
739
+ select(RepositoryWorkflowState).where(
740
+ RepositoryWorkflowState.id == state_id
741
+ )
742
+ )
743
+ return result.scalar_one_or_none()
744
+
745
+ async def delete_workflow_state(self, state_id: uuid.UUID) -> None:
746
+ async with self._session() as sess:
747
+ await sess.execute(
748
+ delete(RepositoryWorkflowState).where(
749
+ RepositoryWorkflowState.id == state_id
750
+ )
751
+ )
752
+
753
+ # ------------------------------------------------------------------
754
+ # Document
755
+ # ------------------------------------------------------------------
756
+
757
+ async def create_document(
758
+ self,
759
+ title: str,
760
+ content: str,
761
+ doc_type: str,
762
+ source_path: str,
763
+ project: str,
764
+ *,
765
+ chunks: dict[str, Any] | None = None,
766
+ embedding: list[float] | None = None,
767
+ ) -> Document:
768
+ async with self._session() as sess:
769
+ document = Document(
770
+ id=uuid.uuid4(),
771
+ title=title,
772
+ content=content,
773
+ doc_type=doc_type,
774
+ source_path=source_path,
775
+ chunks=chunks or {},
776
+ embedding=embedding,
777
+ project=project,
778
+ )
779
+ sess.add(document)
780
+ await sess.flush()
781
+ await sess.refresh(document)
782
+ return document
783
+
784
+ async def get_document_by_path(
785
+ self, source_path: str, *, project: str | None = None
786
+ ) -> Document | None:
787
+ async with self._session() as sess:
788
+ stmt = select(Document).where(Document.source_path == source_path)
789
+ if project is not None:
790
+ stmt = stmt.where(Document.project == project)
791
+ result = await sess.execute(stmt)
792
+ return result.scalar_one_or_none()
793
+
794
+ async def get_documents_by_ids(self, doc_ids: list[uuid.UUID]) -> list[Document]:
795
+ if not doc_ids:
796
+ return []
797
+ async with self._session() as sess:
798
+ stmt = select(Document).where(Document.id.in_(doc_ids))
799
+ result = await sess.execute(stmt)
800
+ return list(result.scalars().all())
801
+
802
+ async def list_documents(self, project: str | None = None) -> list[Document]:
803
+ async with self._session() as sess:
804
+ stmt = select(Document)
805
+ if project is not None:
806
+ stmt = stmt.where(Document.project == project)
807
+ result = await sess.execute(stmt)
808
+ return list(result.scalars().all())
809
+
810
+ async def upsert_document(
811
+ self,
812
+ *,
813
+ title: str,
814
+ content: str,
815
+ doc_type: str,
816
+ source_path: str,
817
+ project: str,
818
+ chunks: dict[str, Any] | None = None,
819
+ embedding: list[float] | None = None,
820
+ ) -> Document:
821
+ existing = await self.get_document_by_path(source_path, project=project)
822
+ if existing is None:
823
+ return await self.create_document(
824
+ title=title,
825
+ content=content,
826
+ doc_type=doc_type,
827
+ source_path=source_path,
828
+ project=project,
829
+ chunks=chunks,
830
+ embedding=embedding,
831
+ )
832
+
833
+ async with self._session() as sess:
834
+ await sess.execute(
835
+ update(Document)
836
+ .where(Document.id == existing.id)
837
+ .values(
838
+ title=title,
839
+ content=content,
840
+ doc_type=doc_type,
841
+ chunks=chunks or {},
842
+ embedding=embedding,
843
+ project=project,
844
+ )
845
+ )
846
+ result = await sess.execute(
847
+ select(Document).where(Document.id == existing.id)
848
+ )
849
+ return result.scalar_one()
850
+
851
+ async def delete_documents_not_in_paths(
852
+ self, *, project: str, keep_paths: set[str]
853
+ ) -> None:
854
+ async with self._session() as sess:
855
+ stmt = delete(Document).where(Document.project == project)
856
+ if keep_paths:
857
+ stmt = stmt.where(Document.source_path.not_in(keep_paths))
858
+ await sess.execute(stmt)
859
+
860
+ # ------------------------------------------------------------------
861
+ # History
862
+ # ------------------------------------------------------------------
863
+
864
+ async def create_history(
865
+ self,
866
+ session_id: uuid.UUID,
867
+ role: str,
868
+ content: str,
869
+ reasoning_trace: str | None = None,
870
+ tool_calls: dict[str, Any] | None = None,
871
+ tokens_used: int = 0,
872
+ latency_ms: int = 0,
873
+ ) -> History:
874
+ async with self._session() as sess:
875
+ history = History(
876
+ id=uuid.uuid4(),
877
+ session_id=session_id,
878
+ role=role,
879
+ content=content,
880
+ reasoning_trace=reasoning_trace,
881
+ tool_calls=tool_calls or {},
882
+ tokens_used=tokens_used,
883
+ latency_ms=latency_ms,
884
+ )
885
+ sess.add(history)
886
+ await sess.flush()
887
+ await sess.refresh(history)
888
+ return history
889
+
890
+ async def list_history_for_session(self, session_id: uuid.UUID) -> list[History]:
891
+ async with self._session() as sess:
892
+ result = await sess.execute(
893
+ select(History).where(History.session_id == session_id)
894
+ )
895
+ return list(result.scalars().all())
896
+
897
+ async def list_history_for_user(self, user_id: uuid.UUID) -> list[History]:
898
+ async with self._session() as sess:
899
+ result = await sess.execute(
900
+ select(History)
901
+ .join(Session, Session.id == History.session_id)
902
+ .where(Session.user_id == user_id)
903
+ )
904
+ return list(result.scalars().all())
905
+
906
+ async def delete_history_for_session(self, session_id: uuid.UUID) -> int:
907
+ async with self._session() as sess:
908
+ result = await sess.execute(
909
+ delete(History).where(History.session_id == session_id)
910
+ )
911
+ cursor = cast(CursorResult[Any], result)
912
+ return int(cursor.rowcount or 0)
913
+
914
+ # ------------------------------------------------------------------
915
+ # Error
916
+ # ------------------------------------------------------------------
917
+
918
+ async def create_error(
919
+ self,
920
+ error_code: str,
921
+ error_message: str,
922
+ stack_trace: str | None = None,
923
+ context: dict[str, Any] | None = None,
924
+ resolution: str | None = None,
925
+ embedding: list[float] | None = None,
926
+ resolved: bool = False,
927
+ ) -> Error:
928
+ async with self._session() as sess:
929
+ error = Error(
930
+ id=uuid.uuid4(),
931
+ error_code=error_code,
932
+ error_message=error_message,
933
+ stack_trace=stack_trace,
934
+ context=context or {},
935
+ resolution=resolution,
936
+ embedding=embedding,
937
+ resolved=resolved,
938
+ )
939
+ sess.add(error)
940
+ await sess.flush()
941
+ await sess.refresh(error)
942
+ return error
943
+
944
+ async def list_errors(self) -> list[Error]:
945
+ async with self._session() as sess:
946
+ result = await sess.execute(select(Error))
947
+ return list(result.scalars().all())
948
+
949
+ async def search_errors(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
950
+ rows = await self.list_errors()
951
+ query_vector = self._text_vector(query)
952
+ ranked = []
953
+ for row in rows:
954
+ text = f"{row.error_code} {row.error_message} {row.context}"
955
+ score = self._cosine_similarity(query_vector, self._text_vector(text))
956
+ ranked.append(
957
+ {
958
+ "id": row.id,
959
+ "error_code": row.error_code,
960
+ "error_message": row.error_message,
961
+ "resolution": row.resolution,
962
+ "score": round(score, 4),
963
+ }
964
+ )
965
+ ranked.sort(key=lambda item: cast(float, item["score"]), reverse=True)
966
+ return ranked[:limit]
967
+
968
+ @staticmethod
969
+ def _text_vector(text: str) -> Counter[str]:
970
+ return Counter(token for token in text.lower().split() if len(token) > 2)
971
+
972
+ @staticmethod
973
+ def _cosine_similarity(left: Counter[str], right: Counter[str]) -> float:
974
+ if not left or not right:
975
+ return 0.0
976
+ numerator = sum(left[key] * right[key] for key in left.keys() & right.keys())
977
+ left_norm = math.sqrt(sum(value * value for value in left.values()))
978
+ right_norm = math.sqrt(sum(value * value for value in right.values()))
979
+ if left_norm == 0 or right_norm == 0:
980
+ return 0.0
981
+ return numerator / (left_norm * right_norm)
982
+
983
+ # ------------------------------------------------------------------
984
+ # Rule
985
+ # ------------------------------------------------------------------
986
+
987
+ async def create_rule(self, **kwargs: Any) -> Rule:
988
+ async with self._session() as sess:
989
+ rule = Rule(**kwargs)
990
+ sess.add(rule)
991
+ await sess.flush()
992
+ await sess.refresh(rule)
993
+ return rule
994
+
995
+ async def get_rule_by_id(self, rule_id: uuid.UUID) -> Optional[Rule]:
996
+ async with self._session() as sess:
997
+ result = await sess.execute(select(Rule).where(Rule.id == rule_id))
998
+ return result.scalar_one_or_none()
999
+
1000
+ async def list_rules(self) -> List[Rule]:
1001
+ async with self._session() as sess:
1002
+ result = await sess.execute(select(Rule))
1003
+ return list(result.scalars().all())
1004
+
1005
+ async def list_by_scope(self, scope: str) -> List[Rule]:
1006
+ async with self._session() as sess:
1007
+ result = await sess.execute(select(Rule).where(Rule.scope == scope))
1008
+ return list(result.scalars().all())
1009
+
1010
+ async def list_active(self) -> List[Rule]:
1011
+ async with self._session() as sess:
1012
+ result = await sess.execute(select(Rule).where(Rule.active.is_(True)))
1013
+ return list(result.scalars().all())
1014
+
1015
+ async def update_rule(self, rule_id: uuid.UUID, **kwargs: Any) -> Optional[Rule]:
1016
+ async with self._session() as sess:
1017
+ await sess.execute(update(Rule).where(Rule.id == rule_id).values(**kwargs))
1018
+ result = await sess.execute(select(Rule).where(Rule.id == rule_id))
1019
+ return result.scalar_one_or_none()
1020
+
1021
+ async def delete_rule(self, rule_id: uuid.UUID) -> None:
1022
+ async with self._session() as sess:
1023
+ await sess.execute(delete(Rule).where(Rule.id == rule_id))
1024
+
1025
+ # ------------------------------------------------------------------
1026
+ # Feedback
1027
+ # ------------------------------------------------------------------
1028
+
1029
+ async def create_feedback(self, **kwargs: Any) -> Feedback:
1030
+ async with self._session() as sess:
1031
+ fb = Feedback(**kwargs)
1032
+ sess.add(fb)
1033
+ await sess.flush()
1034
+ await sess.refresh(fb)
1035
+ return fb
1036
+
1037
+ async def get_feedback_by_id(self, feedback_id: uuid.UUID) -> Optional[Feedback]:
1038
+ async with self._session() as sess:
1039
+ result = await sess.execute(
1040
+ select(Feedback).where(Feedback.id == feedback_id)
1041
+ )
1042
+ return result.scalar_one_or_none()
1043
+
1044
+ async def list_feedback(self) -> List[Feedback]:
1045
+ async with self._session() as sess:
1046
+ result = await sess.execute(select(Feedback))
1047
+ return list(result.scalars().all())
1048
+
1049
+ async def list_by_entity(
1050
+ self, entity_type: str, entity_id: uuid.UUID
1051
+ ) -> List[Feedback]:
1052
+ async with self._session() as sess:
1053
+ result = await sess.execute(
1054
+ select(Feedback).where(
1055
+ Feedback.entity_type == entity_type,
1056
+ Feedback.entity_id == entity_id,
1057
+ )
1058
+ )
1059
+ return list(result.scalars().all())
1060
+
1061
+ async def average_rating(self, entity_id: uuid.UUID) -> Optional[float]:
1062
+ from sqlalchemy import func as sa_func
1063
+
1064
+ async with self._session() as sess:
1065
+ result = await sess.execute(
1066
+ select(sa_func.avg(Feedback.rating)).where(
1067
+ Feedback.entity_id == entity_id
1068
+ )
1069
+ )
1070
+ avg = result.scalar_one_or_none()
1071
+ return float(avg) if avg is not None else None
1072
+
1073
+ async def update_feedback(
1074
+ self, feedback_id: uuid.UUID, **kwargs: Any
1075
+ ) -> Optional[Feedback]:
1076
+ async with self._session() as sess:
1077
+ await sess.execute(
1078
+ update(Feedback).where(Feedback.id == feedback_id).values(**kwargs)
1079
+ )
1080
+ result = await sess.execute(
1081
+ select(Feedback).where(Feedback.id == feedback_id)
1082
+ )
1083
+ return result.scalar_one_or_none()
1084
+
1085
+ async def delete_feedback(self, feedback_id: uuid.UUID) -> None:
1086
+ async with self._session() as sess:
1087
+ await sess.execute(delete(Feedback).where(Feedback.id == feedback_id))