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.
- minder/__init__.py +12 -0
- minder/api/routers/prompts.py +177 -0
- minder/application/__init__.py +1 -0
- minder/application/admin/__init__.py +11 -0
- minder/application/admin/dto.py +453 -0
- minder/application/admin/jobs.py +327 -0
- minder/application/admin/use_cases.py +1895 -0
- minder/auth/__init__.py +12 -0
- minder/auth/context.py +26 -0
- minder/auth/middleware.py +70 -0
- minder/auth/principal.py +59 -0
- minder/auth/rate_limiter.py +89 -0
- minder/auth/rbac.py +60 -0
- minder/auth/service.py +541 -0
- minder/bootstrap/__init__.py +9 -0
- minder/bootstrap/providers.py +109 -0
- minder/bootstrap/transport.py +807 -0
- minder/cache/__init__.py +10 -0
- minder/cache/providers.py +140 -0
- minder/chunking/__init__.py +4 -0
- minder/chunking/code_splitter.py +184 -0
- minder/chunking/splitter.py +136 -0
- minder/cli.py +1542 -0
- minder/config.py +179 -0
- minder/continuity.py +363 -0
- minder/dev.py +160 -0
- minder/embedding/__init__.py +9 -0
- minder/embedding/base.py +7 -0
- minder/embedding/local.py +65 -0
- minder/embedding/openai.py +7 -0
- minder/graph/__init__.py +11 -0
- minder/graph/edges.py +13 -0
- minder/graph/executor.py +127 -0
- minder/graph/graph.py +263 -0
- minder/graph/nodes/__init__.py +27 -0
- minder/graph/nodes/evaluator.py +21 -0
- minder/graph/nodes/guard.py +64 -0
- minder/graph/nodes/llm.py +59 -0
- minder/graph/nodes/planning.py +30 -0
- minder/graph/nodes/reasoning.py +87 -0
- minder/graph/nodes/reranker.py +141 -0
- minder/graph/nodes/retriever.py +86 -0
- minder/graph/nodes/verification.py +230 -0
- minder/graph/nodes/workflow_planner.py +250 -0
- minder/graph/runtime.py +15 -0
- minder/graph/state.py +26 -0
- minder/llm/__init__.py +5 -0
- minder/llm/base.py +14 -0
- minder/llm/local.py +381 -0
- minder/llm/openai.py +89 -0
- minder/models/__init__.py +109 -0
- minder/models/base.py +10 -0
- minder/models/client.py +137 -0
- minder/models/document.py +34 -0
- minder/models/error.py +32 -0
- minder/models/graph.py +114 -0
- minder/models/history.py +32 -0
- minder/models/job.py +62 -0
- minder/models/prompt.py +41 -0
- minder/models/repository.py +62 -0
- minder/models/rule.py +68 -0
- minder/models/session.py +51 -0
- minder/models/skill.py +52 -0
- minder/models/user.py +41 -0
- minder/models/workflow.py +35 -0
- minder/observability/__init__.py +57 -0
- minder/observability/audit.py +243 -0
- minder/observability/logging.py +253 -0
- minder/observability/metrics.py +448 -0
- minder/observability/tracing.py +215 -0
- minder/presentation/__init__.py +1 -0
- minder/presentation/http/__init__.py +1 -0
- minder/presentation/http/admin/__init__.py +3 -0
- minder/presentation/http/admin/api.py +1309 -0
- minder/presentation/http/admin/context.py +94 -0
- minder/presentation/http/admin/dashboard.py +111 -0
- minder/presentation/http/admin/jobs.py +208 -0
- minder/presentation/http/admin/memories.py +185 -0
- minder/presentation/http/admin/prompts.py +219 -0
- minder/presentation/http/admin/routes.py +127 -0
- minder/presentation/http/admin/runtime.py +650 -0
- minder/presentation/http/admin/search.py +368 -0
- minder/presentation/http/admin/skills.py +230 -0
- minder/prompts/__init__.py +646 -0
- minder/prompts/formatter.py +142 -0
- minder/resources/__init__.py +318 -0
- minder/retrieval/__init__.py +5 -0
- minder/retrieval/hybrid.py +178 -0
- minder/retrieval/mmr.py +116 -0
- minder/retrieval/multi_hop.py +115 -0
- minder/runtime.py +15 -0
- minder/server.py +145 -0
- minder/store/__init__.py +64 -0
- minder/store/document.py +115 -0
- minder/store/error.py +82 -0
- minder/store/feedback.py +114 -0
- minder/store/graph.py +588 -0
- minder/store/history.py +57 -0
- minder/store/interfaces.py +512 -0
- minder/store/milvus/__init__.py +11 -0
- minder/store/milvus/client.py +26 -0
- minder/store/milvus/collections.py +15 -0
- minder/store/milvus/vector_store.py +232 -0
- minder/store/mongodb/__init__.py +11 -0
- minder/store/mongodb/client.py +49 -0
- minder/store/mongodb/indexes.py +90 -0
- minder/store/mongodb/operational_store.py +993 -0
- minder/store/relational.py +1087 -0
- minder/store/repo_state.py +58 -0
- minder/store/rule.py +93 -0
- minder/store/vector.py +79 -0
- minder/tools/__init__.py +47 -0
- minder/tools/auth.py +94 -0
- minder/tools/graph.py +839 -0
- minder/tools/ingest.py +353 -0
- minder/tools/memory.py +381 -0
- minder/tools/query.py +307 -0
- minder/tools/registry.py +269 -0
- minder/tools/repo_scanner.py +1266 -0
- minder/tools/search.py +15 -0
- minder/tools/session.py +316 -0
- minder/tools/skills.py +899 -0
- minder/tools/workflow.py +215 -0
- minder/transport/__init__.py +4 -0
- minder/transport/base.py +286 -0
- minder/transport/sse.py +252 -0
- minder/transport/stdio.py +29 -0
- minder_cli-0.2.0.dist-info/METADATA +318 -0
- minder_cli-0.2.0.dist-info/RECORD +132 -0
- minder_cli-0.2.0.dist-info/WHEEL +4 -0
- minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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))
|