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,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from minder.application.admin.use_cases import AdminConsoleUseCases
|
|
7
|
+
from minder.auth.principal import ClientPrincipal, Principal
|
|
8
|
+
from minder.auth.middleware import AuthMiddleware
|
|
9
|
+
from minder.auth.service import AuthService
|
|
10
|
+
from minder.config import MinderConfig
|
|
11
|
+
from minder.store.interfaces import ICacheProvider, IGraphRepository, IOperationalStore
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
|
|
14
|
+
ADMIN_COOKIE_NAME = "minder_admin_token"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class AdminRouteContext:
|
|
19
|
+
config: MinderConfig
|
|
20
|
+
store: IOperationalStore
|
|
21
|
+
graph_store: IGraphRepository | None
|
|
22
|
+
cache: ICacheProvider | None
|
|
23
|
+
auth_service: AuthService
|
|
24
|
+
middleware: AuthMiddleware
|
|
25
|
+
use_cases: AdminConsoleUseCases
|
|
26
|
+
prompt_sync_hook: Callable[[], Awaitable[None]] | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def build(
|
|
30
|
+
cls,
|
|
31
|
+
*,
|
|
32
|
+
config: MinderConfig,
|
|
33
|
+
store: IOperationalStore,
|
|
34
|
+
graph_store: IGraphRepository | None = None,
|
|
35
|
+
cache: ICacheProvider | None = None,
|
|
36
|
+
prompt_sync_hook: Callable[[], Awaitable[None]] | None = None,
|
|
37
|
+
) -> "AdminRouteContext":
|
|
38
|
+
auth_service = AuthService(store, config, cache=cache)
|
|
39
|
+
middleware = AuthMiddleware(auth_service)
|
|
40
|
+
use_cases = AdminConsoleUseCases(
|
|
41
|
+
store=store,
|
|
42
|
+
auth_service=auth_service,
|
|
43
|
+
config=config,
|
|
44
|
+
graph_store=graph_store,
|
|
45
|
+
)
|
|
46
|
+
return cls(
|
|
47
|
+
config=config,
|
|
48
|
+
store=store,
|
|
49
|
+
graph_store=graph_store,
|
|
50
|
+
cache=cache,
|
|
51
|
+
auth_service=auth_service,
|
|
52
|
+
middleware=middleware,
|
|
53
|
+
use_cases=use_cases,
|
|
54
|
+
prompt_sync_hook=prompt_sync_hook,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def request_token(self, request: Request) -> str | None:
|
|
58
|
+
authorization = request.headers.get("Authorization")
|
|
59
|
+
if authorization:
|
|
60
|
+
return authorization
|
|
61
|
+
cookie_token = request.cookies.get(ADMIN_COOKIE_NAME)
|
|
62
|
+
if cookie_token:
|
|
63
|
+
return f"Bearer {cookie_token}"
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def request_client_key(request: Request) -> str | None:
|
|
68
|
+
client_key = request.headers.get("X-Minder-Client-Key")
|
|
69
|
+
if client_key and client_key.strip():
|
|
70
|
+
return client_key.strip()
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
async def principal_from_request(
|
|
74
|
+
self,
|
|
75
|
+
request: Request,
|
|
76
|
+
*,
|
|
77
|
+
requested_scopes: list[str] | None = None,
|
|
78
|
+
) -> Principal:
|
|
79
|
+
return await self.middleware.authenticate_principal(
|
|
80
|
+
self.request_token(request),
|
|
81
|
+
client_key=self.request_client_key(request),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def client_principal_from_request(self, request: Request) -> ClientPrincipal:
|
|
85
|
+
principal = await self.principal_from_request(request)
|
|
86
|
+
if not isinstance(principal, ClientPrincipal):
|
|
87
|
+
raise PermissionError("Client principal required")
|
|
88
|
+
return principal
|
|
89
|
+
|
|
90
|
+
async def admin_user_from_request(self, request: Request) -> Any:
|
|
91
|
+
user = await self.middleware.authenticate(self.request_token(request))
|
|
92
|
+
if user.role != "admin":
|
|
93
|
+
raise PermissionError("Admin role required")
|
|
94
|
+
return user
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from starlette.responses import FileResponse, PlainTextResponse, RedirectResponse
|
|
6
|
+
from starlette.routing import BaseRoute, Route
|
|
7
|
+
|
|
8
|
+
from .context import AdminRouteContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_dashboard_static_root(static_dir: Path) -> Path:
|
|
12
|
+
if (static_dir / "index.html").is_file():
|
|
13
|
+
return static_dir
|
|
14
|
+
|
|
15
|
+
client_dir = static_dir / "client"
|
|
16
|
+
if client_dir.is_dir() and (client_dir / "index.html").is_file():
|
|
17
|
+
return client_dir
|
|
18
|
+
|
|
19
|
+
return static_dir
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_dashboard_routes(context: AdminRouteContext) -> list[BaseRoute]:
|
|
23
|
+
def _dev_dashboard_url(asset_path: str = "") -> str | None:
|
|
24
|
+
dev_server_url = (context.config.dashboard.dev_server_url or "").strip().rstrip("/")
|
|
25
|
+
if not dev_server_url:
|
|
26
|
+
return None
|
|
27
|
+
if asset_path:
|
|
28
|
+
return f"{dev_server_url}/{asset_path.strip('/')}"
|
|
29
|
+
return dev_server_url
|
|
30
|
+
|
|
31
|
+
async def dashboard_static(request):
|
|
32
|
+
asset_path = str(request.path_params.get("asset_path", "")).strip("/")
|
|
33
|
+
dev_target = _dev_dashboard_url(asset_path)
|
|
34
|
+
if dev_target is not None:
|
|
35
|
+
return RedirectResponse(url=dev_target, status_code=307)
|
|
36
|
+
|
|
37
|
+
static_dir = Path(context.config.dashboard.static_dir).expanduser()
|
|
38
|
+
|
|
39
|
+
async def _has_admin_session() -> bool:
|
|
40
|
+
try:
|
|
41
|
+
await context.admin_user_from_request(request)
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
has_admin_users = await context.use_cases.has_admin_users()
|
|
47
|
+
has_admin_session = await _has_admin_session()
|
|
48
|
+
is_asset_request = "." in Path(asset_path).name or asset_path.startswith("_astro/")
|
|
49
|
+
|
|
50
|
+
if not is_asset_request:
|
|
51
|
+
if not has_admin_users and asset_path not in {"setup"}:
|
|
52
|
+
return RedirectResponse(url=f"{context.config.dashboard.base_path}/setup", status_code=303)
|
|
53
|
+
if has_admin_users and asset_path == "setup":
|
|
54
|
+
target = (
|
|
55
|
+
f"{context.config.dashboard.base_path}/clients"
|
|
56
|
+
if has_admin_session
|
|
57
|
+
else f"{context.config.dashboard.base_path}/login"
|
|
58
|
+
)
|
|
59
|
+
return RedirectResponse(url=target, status_code=303)
|
|
60
|
+
if has_admin_users and not has_admin_session and asset_path not in {"login"}:
|
|
61
|
+
return RedirectResponse(url=f"{context.config.dashboard.base_path}/login", status_code=303)
|
|
62
|
+
if has_admin_users and has_admin_session and asset_path in {"", "login"}:
|
|
63
|
+
return RedirectResponse(url=f"{context.config.dashboard.base_path}/clients", status_code=303)
|
|
64
|
+
|
|
65
|
+
if not static_dir.exists():
|
|
66
|
+
return PlainTextResponse("Dashboard build not found", status_code=404)
|
|
67
|
+
|
|
68
|
+
static_root = _resolve_dashboard_static_root(static_dir)
|
|
69
|
+
|
|
70
|
+
if not asset_path:
|
|
71
|
+
candidates = [static_root / "index.html"]
|
|
72
|
+
else:
|
|
73
|
+
requested = static_root / asset_path
|
|
74
|
+
candidates = [requested, requested / "index.html"]
|
|
75
|
+
|
|
76
|
+
for candidate in candidates:
|
|
77
|
+
if candidate.exists() and candidate.is_file():
|
|
78
|
+
return FileResponse(candidate)
|
|
79
|
+
|
|
80
|
+
if asset_path.startswith("clients/"):
|
|
81
|
+
detail_shell = static_root / "clients" / "_client-detail" / "index.html"
|
|
82
|
+
if detail_shell.exists() and detail_shell.is_file():
|
|
83
|
+
return FileResponse(detail_shell)
|
|
84
|
+
|
|
85
|
+
clients_index = static_root / "clients" / "index.html"
|
|
86
|
+
if clients_index.exists() and clients_index.is_file():
|
|
87
|
+
return FileResponse(clients_index)
|
|
88
|
+
|
|
89
|
+
fallback = static_root / "index.html"
|
|
90
|
+
if fallback.exists() and fallback.is_file():
|
|
91
|
+
return FileResponse(fallback)
|
|
92
|
+
return PlainTextResponse("Dashboard build not found", status_code=404)
|
|
93
|
+
|
|
94
|
+
async def setup_redirect(_):
|
|
95
|
+
dev_target = _dev_dashboard_url("setup")
|
|
96
|
+
if dev_target is not None:
|
|
97
|
+
return RedirectResponse(url=dev_target, status_code=308)
|
|
98
|
+
return RedirectResponse(url=f"{context.config.dashboard.base_path}/setup", status_code=308)
|
|
99
|
+
|
|
100
|
+
async def dashboard_favicon_ico(_):
|
|
101
|
+
dev_target = _dev_dashboard_url("favicon.png")
|
|
102
|
+
if dev_target is not None:
|
|
103
|
+
return RedirectResponse(url=dev_target, status_code=308)
|
|
104
|
+
return RedirectResponse(url=f"{context.config.dashboard.base_path}/favicon.png", status_code=308)
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
Route("/setup", setup_redirect, methods=["GET"]),
|
|
108
|
+
Route(f"{context.config.dashboard.base_path}/favicon.ico", dashboard_favicon_ico, methods=["GET"]),
|
|
109
|
+
Route(f"{context.config.dashboard.base_path}", dashboard_static, methods=["GET"]),
|
|
110
|
+
Route(f"{context.config.dashboard.base_path}" + "/{asset_path:path}", dashboard_static, methods=["GET"]),
|
|
111
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import JSONResponse, StreamingResponse
|
|
11
|
+
from starlette.routing import BaseRoute, Route
|
|
12
|
+
|
|
13
|
+
from minder.application.admin.jobs import AdminJobService, iter_job_stream
|
|
14
|
+
|
|
15
|
+
from .context import AdminRouteContext
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdminJobCreateRequest(BaseModel):
|
|
21
|
+
job_type: str
|
|
22
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _job_service_from_request(
|
|
26
|
+
request: Request,
|
|
27
|
+
context: AdminRouteContext,
|
|
28
|
+
) -> AdminJobService:
|
|
29
|
+
service = getattr(request.app.state, "job_service", None)
|
|
30
|
+
if isinstance(service, AdminJobService):
|
|
31
|
+
return service
|
|
32
|
+
service = AdminJobService(context.store, context.config)
|
|
33
|
+
request.app.state.job_service = service
|
|
34
|
+
return service
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _serialize_job(job: Any) -> dict[str, Any]:
|
|
38
|
+
created_at = getattr(job, "created_at", None)
|
|
39
|
+
updated_at = getattr(job, "updated_at", None)
|
|
40
|
+
started_at = getattr(job, "started_at", None)
|
|
41
|
+
finished_at = getattr(job, "finished_at", None)
|
|
42
|
+
progress_current = int(getattr(job, "progress_current", 0) or 0)
|
|
43
|
+
progress_total = int(getattr(job, "progress_total", 0) or 0)
|
|
44
|
+
progress_percent = 0.0
|
|
45
|
+
if progress_total > 0:
|
|
46
|
+
progress_percent = round((progress_current / progress_total) * 100, 1)
|
|
47
|
+
return {
|
|
48
|
+
"id": str(job.id),
|
|
49
|
+
"job_type": str(getattr(job, "job_type", "")),
|
|
50
|
+
"title": str(getattr(job, "title", "")),
|
|
51
|
+
"status": str(getattr(job, "status", "queued")),
|
|
52
|
+
"requested_by_user_id": (
|
|
53
|
+
str(getattr(job, "requested_by_user_id", ""))
|
|
54
|
+
if getattr(job, "requested_by_user_id", None)
|
|
55
|
+
else None
|
|
56
|
+
),
|
|
57
|
+
"payload": dict(getattr(job, "payload", {}) or {}),
|
|
58
|
+
"result_payload": getattr(job, "result_payload", None),
|
|
59
|
+
"error_message": getattr(job, "error_message", None),
|
|
60
|
+
"progress_current": progress_current,
|
|
61
|
+
"progress_total": progress_total,
|
|
62
|
+
"progress_percent": progress_percent,
|
|
63
|
+
"message": getattr(job, "message", None),
|
|
64
|
+
"events": list(getattr(job, "events", []) or []),
|
|
65
|
+
"created_at": created_at.isoformat() if created_at else None,
|
|
66
|
+
"updated_at": updated_at.isoformat() if updated_at else None,
|
|
67
|
+
"started_at": started_at.isoformat() if started_at else None,
|
|
68
|
+
"finished_at": finished_at.isoformat() if finished_at else None,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _job_title(job_type: str, payload: dict[str, Any]) -> str:
|
|
73
|
+
if job_type == "skill_import_git":
|
|
74
|
+
repo_url = str(payload.get("repo_url") or "").strip()
|
|
75
|
+
return f"Import skills from {repo_url or 'Git repository'}"
|
|
76
|
+
return job_type.replace("_", " ").strip() or "Admin job"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_jobs_routes(context: AdminRouteContext) -> list[BaseRoute]:
|
|
80
|
+
async def create_job(request: Request) -> JSONResponse:
|
|
81
|
+
try:
|
|
82
|
+
admin_user = await context.admin_user_from_request(request)
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
payload = AdminJobCreateRequest(**(await request.json()))
|
|
90
|
+
if payload.job_type != "skill_import_git":
|
|
91
|
+
return JSONResponse(
|
|
92
|
+
{"error": f"Unsupported job type: {payload.job_type}"},
|
|
93
|
+
status_code=400,
|
|
94
|
+
)
|
|
95
|
+
service = _job_service_from_request(request, context)
|
|
96
|
+
job = await service.enqueue(
|
|
97
|
+
job_type=payload.job_type,
|
|
98
|
+
title=_job_title(payload.job_type, payload.payload),
|
|
99
|
+
requested_by_user_id=admin_user.id,
|
|
100
|
+
payload=payload.payload,
|
|
101
|
+
)
|
|
102
|
+
return JSONResponse(_serialize_job(job), status_code=202)
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logger.exception("Failed to create admin job", exc_info=exc)
|
|
105
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
106
|
+
|
|
107
|
+
async def list_jobs(request: Request) -> JSONResponse:
|
|
108
|
+
try:
|
|
109
|
+
await context.admin_user_from_request(request)
|
|
110
|
+
except PermissionError:
|
|
111
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
114
|
+
|
|
115
|
+
job_type = (request.query_params.get("job_type") or "").strip() or None
|
|
116
|
+
status = (request.query_params.get("status") or "").strip() or None
|
|
117
|
+
limit = int(request.query_params.get("limit") or 50)
|
|
118
|
+
jobs = await context.store.list_admin_jobs(
|
|
119
|
+
job_type=job_type,
|
|
120
|
+
status=status,
|
|
121
|
+
limit=limit,
|
|
122
|
+
)
|
|
123
|
+
return JSONResponse(
|
|
124
|
+
{"jobs": [_serialize_job(job) for job in jobs], "limit": limit}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def get_job(request: Request) -> JSONResponse:
|
|
128
|
+
try:
|
|
129
|
+
await context.admin_user_from_request(request)
|
|
130
|
+
except PermissionError:
|
|
131
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
134
|
+
|
|
135
|
+
job_id = uuid.UUID(str(request.path_params["job_id"]))
|
|
136
|
+
job = await context.store.get_admin_job_by_id(job_id)
|
|
137
|
+
if job is None:
|
|
138
|
+
return JSONResponse({"error": "Job not found"}, status_code=404)
|
|
139
|
+
return JSONResponse(_serialize_job(job))
|
|
140
|
+
|
|
141
|
+
async def stream_jobs(request: Request) -> StreamingResponse | JSONResponse:
|
|
142
|
+
try:
|
|
143
|
+
await context.admin_user_from_request(request)
|
|
144
|
+
except PermissionError:
|
|
145
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
148
|
+
|
|
149
|
+
job_type = (request.query_params.get("job_type") or "").strip() or None
|
|
150
|
+
status_values = {
|
|
151
|
+
value.strip()
|
|
152
|
+
for value in (request.query_params.get("status") or "").split(",")
|
|
153
|
+
if value.strip()
|
|
154
|
+
}
|
|
155
|
+
service = _job_service_from_request(request, context)
|
|
156
|
+
queue, unsubscribe = service.subscribe(
|
|
157
|
+
job_type=job_type,
|
|
158
|
+
statuses=status_values or None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def event_stream():
|
|
162
|
+
try:
|
|
163
|
+
async for event in iter_job_stream(
|
|
164
|
+
queue=queue,
|
|
165
|
+
request_is_disconnected=request.is_disconnected,
|
|
166
|
+
):
|
|
167
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
168
|
+
finally:
|
|
169
|
+
unsubscribe()
|
|
170
|
+
|
|
171
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
172
|
+
|
|
173
|
+
async def stream_job(request: Request) -> StreamingResponse | JSONResponse:
|
|
174
|
+
try:
|
|
175
|
+
await context.admin_user_from_request(request)
|
|
176
|
+
except PermissionError:
|
|
177
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
180
|
+
|
|
181
|
+
job_id = uuid.UUID(str(request.path_params["job_id"]))
|
|
182
|
+
existing = await context.store.get_admin_job_by_id(job_id)
|
|
183
|
+
if existing is None:
|
|
184
|
+
return JSONResponse({"error": "Job not found"}, status_code=404)
|
|
185
|
+
|
|
186
|
+
service = _job_service_from_request(request, context)
|
|
187
|
+
queue, unsubscribe = service.subscribe(job_id=str(job_id))
|
|
188
|
+
|
|
189
|
+
async def event_stream():
|
|
190
|
+
try:
|
|
191
|
+
yield f"data: {json.dumps({'type': 'job', 'payload': _serialize_job(existing)})}\n\n"
|
|
192
|
+
async for event in iter_job_stream(
|
|
193
|
+
queue=queue,
|
|
194
|
+
request_is_disconnected=request.is_disconnected,
|
|
195
|
+
):
|
|
196
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
197
|
+
finally:
|
|
198
|
+
unsubscribe()
|
|
199
|
+
|
|
200
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
201
|
+
|
|
202
|
+
return [
|
|
203
|
+
Route("/api/v1/jobs", list_jobs, methods=["GET"]),
|
|
204
|
+
Route("/api/v1/jobs", create_job, methods=["POST"]),
|
|
205
|
+
Route("/api/v1/jobs/stream", stream_jobs, methods=["GET"]),
|
|
206
|
+
Route("/api/v1/jobs/{job_id}", get_job, methods=["GET"]),
|
|
207
|
+
Route("/api/v1/jobs/{job_id}/stream", stream_job, methods=["GET"]),
|
|
208
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from starlette.requests import Request
|
|
9
|
+
from starlette.responses import JSONResponse
|
|
10
|
+
from starlette.routing import BaseRoute, Route
|
|
11
|
+
|
|
12
|
+
from minder.config import MinderConfig
|
|
13
|
+
from minder.embedding.local import LocalEmbeddingProvider
|
|
14
|
+
from minder.observability.metrics import record_admin_operation
|
|
15
|
+
from minder.tools.memory import MemoryTools
|
|
16
|
+
|
|
17
|
+
from .context import AdminRouteContext
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryCreateRequest(BaseModel):
|
|
23
|
+
title: str
|
|
24
|
+
content: str
|
|
25
|
+
language: str = "markdown"
|
|
26
|
+
tags: list[str] = Field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryUpdateRequest(BaseModel):
|
|
30
|
+
title: str | None = None
|
|
31
|
+
content: str | None = None
|
|
32
|
+
language: str | None = None
|
|
33
|
+
tags: list[str] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _config_from_request(request: Request) -> MinderConfig:
|
|
37
|
+
config = getattr(request.app.state, "config", None)
|
|
38
|
+
if isinstance(config, MinderConfig):
|
|
39
|
+
return config
|
|
40
|
+
return MinderConfig()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _serialize_memory(skill: Any) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"id": str(skill.id),
|
|
46
|
+
"title": str(skill.title),
|
|
47
|
+
"content": str(skill.content),
|
|
48
|
+
"language": str(getattr(skill, "language", "markdown") or "markdown"),
|
|
49
|
+
"tags": list(getattr(skill, "tags", []) or []),
|
|
50
|
+
"created_at": skill.created_at.isoformat() if skill.created_at else None,
|
|
51
|
+
"updated_at": skill.updated_at.isoformat() if skill.updated_at else None,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_memories_routes(context: AdminRouteContext) -> list[BaseRoute]:
|
|
56
|
+
async def list_memories(request: Request) -> JSONResponse:
|
|
57
|
+
del request
|
|
58
|
+
await record_admin_operation(
|
|
59
|
+
operation="list_memories",
|
|
60
|
+
outcome="success",
|
|
61
|
+
actor_id="system",
|
|
62
|
+
store=context.store,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
skills = sorted(
|
|
66
|
+
await context.store.list_skills(),
|
|
67
|
+
key=lambda skill: (
|
|
68
|
+
str(getattr(skill, "title", "")).lower(),
|
|
69
|
+
str(getattr(skill, "language", "")).lower(),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
return JSONResponse([_serialize_memory(skill) for skill in skills])
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.exception("Failed to list memories", exc_info=exc)
|
|
75
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
76
|
+
|
|
77
|
+
async def get_memory(request: Request) -> JSONResponse:
|
|
78
|
+
memory_id = request.path_params["memory_id"]
|
|
79
|
+
await record_admin_operation(
|
|
80
|
+
operation="get_memory",
|
|
81
|
+
outcome="success",
|
|
82
|
+
actor_id="system",
|
|
83
|
+
store=context.store,
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
skill = await context.store.get_skill_by_id(uuid.UUID(memory_id))
|
|
87
|
+
if skill is None:
|
|
88
|
+
return JSONResponse({"error": "Memory not found"}, status_code=404)
|
|
89
|
+
return JSONResponse(_serialize_memory(skill))
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
logger.exception("Failed to get memory", exc_info=exc)
|
|
92
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
93
|
+
|
|
94
|
+
async def create_memory(request: Request) -> JSONResponse:
|
|
95
|
+
await record_admin_operation(
|
|
96
|
+
operation="create_memory",
|
|
97
|
+
outcome="success",
|
|
98
|
+
actor_id="system",
|
|
99
|
+
store=context.store,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
payload = MemoryCreateRequest(**(await request.json()))
|
|
103
|
+
tools = MemoryTools(context.store, _config_from_request(request))
|
|
104
|
+
created = await tools.minder_memory_store(
|
|
105
|
+
title=payload.title,
|
|
106
|
+
content=payload.content,
|
|
107
|
+
tags=payload.tags,
|
|
108
|
+
language=payload.language,
|
|
109
|
+
)
|
|
110
|
+
skill = await context.store.get_skill_by_id(uuid.UUID(created["id"]))
|
|
111
|
+
if skill is None:
|
|
112
|
+
return JSONResponse(created, status_code=201)
|
|
113
|
+
return JSONResponse(_serialize_memory(skill), status_code=201)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.exception("Failed to create memory", exc_info=exc)
|
|
116
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
117
|
+
|
|
118
|
+
async def update_memory(request: Request) -> JSONResponse:
|
|
119
|
+
memory_id = request.path_params["memory_id"]
|
|
120
|
+
await record_admin_operation(
|
|
121
|
+
operation="update_memory",
|
|
122
|
+
outcome="success",
|
|
123
|
+
actor_id="system",
|
|
124
|
+
store=context.store,
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
payload = MemoryUpdateRequest(**(await request.json()))
|
|
128
|
+
existing = await context.store.get_skill_by_id(uuid.UUID(memory_id))
|
|
129
|
+
if existing is None:
|
|
130
|
+
return JSONResponse({"error": "Memory not found"}, status_code=404)
|
|
131
|
+
|
|
132
|
+
update_data = payload.model_dump(exclude_unset=True)
|
|
133
|
+
next_title = str(update_data.get("title") or existing.title)
|
|
134
|
+
next_content = str(update_data.get("content") or existing.content)
|
|
135
|
+
if "tags" in update_data and update_data["tags"] is not None:
|
|
136
|
+
update_data["tags"] = [
|
|
137
|
+
str(tag).strip() for tag in update_data["tags"] if str(tag).strip()
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
if "title" in update_data or "content" in update_data:
|
|
141
|
+
config = _config_from_request(request)
|
|
142
|
+
embedder = LocalEmbeddingProvider(
|
|
143
|
+
config.embedding.model_path,
|
|
144
|
+
dimensions=min(config.embedding.dimensions, 16),
|
|
145
|
+
runtime="auto",
|
|
146
|
+
)
|
|
147
|
+
update_data["embedding"] = embedder.embed(
|
|
148
|
+
f"{next_title}\n{next_content}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
updated = await context.store.update_skill(
|
|
152
|
+
uuid.UUID(memory_id), **update_data
|
|
153
|
+
)
|
|
154
|
+
if updated is None:
|
|
155
|
+
return JSONResponse({"error": "Memory not found"}, status_code=404)
|
|
156
|
+
return JSONResponse(_serialize_memory(updated))
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
logger.exception("Failed to update memory", exc_info=exc)
|
|
159
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
160
|
+
|
|
161
|
+
async def delete_memory(request: Request) -> JSONResponse:
|
|
162
|
+
memory_id = request.path_params["memory_id"]
|
|
163
|
+
await record_admin_operation(
|
|
164
|
+
operation="delete_memory",
|
|
165
|
+
outcome="success",
|
|
166
|
+
actor_id="system",
|
|
167
|
+
store=context.store,
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
skill = await context.store.get_skill_by_id(uuid.UUID(memory_id))
|
|
171
|
+
if skill is None:
|
|
172
|
+
return JSONResponse({"error": "Memory not found"}, status_code=404)
|
|
173
|
+
await context.store.delete_skill(uuid.UUID(memory_id))
|
|
174
|
+
return JSONResponse({"deleted": True}, status_code=200)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
logger.exception("Failed to delete memory", exc_info=exc)
|
|
177
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
178
|
+
|
|
179
|
+
return [
|
|
180
|
+
Route("/api/v1/memories", list_memories, methods=["GET"]),
|
|
181
|
+
Route("/api/v1/memories", create_memory, methods=["POST"]),
|
|
182
|
+
Route("/api/v1/memories/{memory_id}", get_memory, methods=["GET"]),
|
|
183
|
+
Route("/api/v1/memories/{memory_id}", update_memory, methods=["PATCH", "PUT"]),
|
|
184
|
+
Route("/api/v1/memories/{memory_id}", delete_memory, methods=["DELETE"]),
|
|
185
|
+
]
|