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,368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
from starlette.routing import BaseRoute, Route
|
|
9
|
+
|
|
10
|
+
from minder.config import MinderConfig
|
|
11
|
+
from minder.embedding.local import LocalEmbeddingProvider
|
|
12
|
+
from minder.observability.metrics import record_admin_operation
|
|
13
|
+
from minder.prompts import PromptRegistry
|
|
14
|
+
from minder.tools.memory import MemoryTools
|
|
15
|
+
from minder.tools.skills import SkillTools
|
|
16
|
+
|
|
17
|
+
from .context import AdminRouteContext
|
|
18
|
+
from .memories import _serialize_memory
|
|
19
|
+
from .prompts import _serialize_prompt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _config_from_request(request: Request) -> MinderConfig:
|
|
23
|
+
config = getattr(request.app.state, "config", None)
|
|
24
|
+
if isinstance(config, MinderConfig):
|
|
25
|
+
return config
|
|
26
|
+
return MinderConfig()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tokenize(value: str) -> set[str]:
|
|
30
|
+
return {
|
|
31
|
+
token
|
|
32
|
+
for token in value.lower()
|
|
33
|
+
.replace("/", " ")
|
|
34
|
+
.replace("_", " ")
|
|
35
|
+
.replace("-", " ")
|
|
36
|
+
.split()
|
|
37
|
+
if token
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
|
42
|
+
if not left or not right or len(left) != len(right):
|
|
43
|
+
return 0.0
|
|
44
|
+
numerator = sum(a * b for a, b in zip(left, right, strict=False))
|
|
45
|
+
left_norm = math.sqrt(sum(value * value for value in left))
|
|
46
|
+
right_norm = math.sqrt(sum(value * value for value in right))
|
|
47
|
+
if left_norm == 0 or right_norm == 0:
|
|
48
|
+
return 0.0
|
|
49
|
+
return numerator / (left_norm * right_norm)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lexical_score(query: str, candidate: str) -> float:
|
|
53
|
+
query_lower = query.lower()
|
|
54
|
+
candidate_lower = candidate.lower()
|
|
55
|
+
query_tokens = _tokenize(query)
|
|
56
|
+
candidate_tokens = _tokenize(candidate)
|
|
57
|
+
overlap = 0.0
|
|
58
|
+
if query_tokens:
|
|
59
|
+
overlap = len(query_tokens & candidate_tokens) / max(len(query_tokens), 1)
|
|
60
|
+
contains = 1.0 if query_lower in candidate_lower else 0.0
|
|
61
|
+
prefix = 1.0 if candidate_lower.startswith(query_lower) else 0.0
|
|
62
|
+
return (overlap * 0.6) + (contains * 0.25) + (prefix * 0.15)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rank_serialized_items(
|
|
66
|
+
*,
|
|
67
|
+
items: list[Any],
|
|
68
|
+
query: str,
|
|
69
|
+
text_builder: Callable[[Any], str],
|
|
70
|
+
config: MinderConfig,
|
|
71
|
+
) -> list[Any]:
|
|
72
|
+
if not query.strip():
|
|
73
|
+
return items
|
|
74
|
+
|
|
75
|
+
embedder = LocalEmbeddingProvider(
|
|
76
|
+
config.embedding.model_path,
|
|
77
|
+
dimensions=min(config.embedding.dimensions, 16),
|
|
78
|
+
runtime="auto",
|
|
79
|
+
)
|
|
80
|
+
query_embedding = embedder.embed(query)
|
|
81
|
+
ranked: list[Any] = []
|
|
82
|
+
for item in items:
|
|
83
|
+
candidate_text = text_builder(item).strip()
|
|
84
|
+
if not candidate_text:
|
|
85
|
+
continue
|
|
86
|
+
candidate_embedding = embedder.embed(candidate_text)
|
|
87
|
+
semantic_score = _cosine_similarity(query_embedding, candidate_embedding)
|
|
88
|
+
lexical_score = _lexical_score(query, candidate_text)
|
|
89
|
+
score = (semantic_score * 0.72) + (lexical_score * 0.28)
|
|
90
|
+
next_item = {**item, "search_score": round(score, 4)}
|
|
91
|
+
ranked.append(next_item)
|
|
92
|
+
ranked.sort(
|
|
93
|
+
key=lambda item: (
|
|
94
|
+
-float(item.get("search_score", 0.0) or 0.0),
|
|
95
|
+
str(item.get("title") or item.get("name") or item.get("id") or "").lower(),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return ranked
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rank_skill_items(
|
|
102
|
+
*,
|
|
103
|
+
items: list[dict[str, Any]],
|
|
104
|
+
query: str,
|
|
105
|
+
config: MinderConfig,
|
|
106
|
+
) -> list[dict[str, Any]]:
|
|
107
|
+
if not query.strip():
|
|
108
|
+
return items
|
|
109
|
+
|
|
110
|
+
embedder = LocalEmbeddingProvider(
|
|
111
|
+
config.embedding.model_path,
|
|
112
|
+
dimensions=min(config.embedding.dimensions, 16),
|
|
113
|
+
runtime="auto",
|
|
114
|
+
)
|
|
115
|
+
query_embedding = embedder.embed(query)
|
|
116
|
+
query_lower = query.strip().lower()
|
|
117
|
+
query_tokens = _tokenize(query)
|
|
118
|
+
ranked: list[dict[str, Any]] = []
|
|
119
|
+
|
|
120
|
+
for item in items:
|
|
121
|
+
title = str(item.get("title", "") or "")
|
|
122
|
+
language = str(item.get("language", "") or "")
|
|
123
|
+
tags = [str(tag) for tag in list(item.get("tags", []) or [])]
|
|
124
|
+
candidate_text = " ".join(
|
|
125
|
+
[
|
|
126
|
+
title,
|
|
127
|
+
language,
|
|
128
|
+
" ".join(tags),
|
|
129
|
+
" ".join(item.get("workflow_step_tags", [])),
|
|
130
|
+
" ".join(item.get("artifact_type_tags", [])),
|
|
131
|
+
str(item.get("content", "") or ""),
|
|
132
|
+
]
|
|
133
|
+
).strip()
|
|
134
|
+
if not candidate_text:
|
|
135
|
+
continue
|
|
136
|
+
semantic_score = _cosine_similarity(
|
|
137
|
+
query_embedding,
|
|
138
|
+
embedder.embed(candidate_text),
|
|
139
|
+
)
|
|
140
|
+
lexical_score = _lexical_score(query, candidate_text)
|
|
141
|
+
title_lower = title.lower()
|
|
142
|
+
language_lower = language.lower()
|
|
143
|
+
tag_tokens = {tag.lower() for tag in tags}
|
|
144
|
+
exact_boost = 0.0
|
|
145
|
+
if title_lower == query_lower:
|
|
146
|
+
exact_boost += 2.2
|
|
147
|
+
if language_lower == query_lower:
|
|
148
|
+
exact_boost += 2.8
|
|
149
|
+
if query_lower in tag_tokens:
|
|
150
|
+
exact_boost += 1.9
|
|
151
|
+
if title_lower.startswith(query_lower):
|
|
152
|
+
exact_boost += 0.8
|
|
153
|
+
if any(token == language_lower for token in query_tokens):
|
|
154
|
+
exact_boost += 1.4
|
|
155
|
+
if any(token in tag_tokens for token in query_tokens):
|
|
156
|
+
exact_boost += 0.8
|
|
157
|
+
score = (
|
|
158
|
+
(lexical_score * 0.62)
|
|
159
|
+
+ (semantic_score * 0.18)
|
|
160
|
+
+ exact_boost
|
|
161
|
+
+ (min(float(item.get("quality_score", 0.0) or 0.0), 1.0) * 0.05)
|
|
162
|
+
)
|
|
163
|
+
ranked.append(
|
|
164
|
+
{
|
|
165
|
+
**item,
|
|
166
|
+
"search_score": round(score, 4),
|
|
167
|
+
"lexical_score": round(lexical_score, 4),
|
|
168
|
+
"semantic_score": round(semantic_score, 4),
|
|
169
|
+
"exact_match_score": round(exact_boost, 4),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
query_tokens
|
|
175
|
+
and len(query_tokens) <= 2
|
|
176
|
+
and any(
|
|
177
|
+
float(item.get("exact_match_score", 0.0) or 0.0) > 0
|
|
178
|
+
or float(item.get("lexical_score", 0.0) or 0.0) >= 0.45
|
|
179
|
+
for item in ranked
|
|
180
|
+
)
|
|
181
|
+
):
|
|
182
|
+
ranked = [
|
|
183
|
+
item
|
|
184
|
+
for item in ranked
|
|
185
|
+
if float(item.get("exact_match_score", 0.0) or 0.0) > 0
|
|
186
|
+
or float(item.get("lexical_score", 0.0) or 0.0) > 0
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
ranked.sort(
|
|
190
|
+
key=lambda item: (
|
|
191
|
+
-float(item.get("search_score", 0.0) or 0.0),
|
|
192
|
+
-float(item.get("exact_match_score", 0.0) or 0.0),
|
|
193
|
+
str(item.get("title") or "").lower(),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return ranked
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _slice_items(
|
|
200
|
+
items: list[Any],
|
|
201
|
+
*,
|
|
202
|
+
limit: int,
|
|
203
|
+
offset: int,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
total = len(items)
|
|
206
|
+
return {
|
|
207
|
+
"items": items[offset : offset + limit],
|
|
208
|
+
"total": total,
|
|
209
|
+
"limit": limit,
|
|
210
|
+
"offset": offset,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _prompt_items(context: AdminRouteContext) -> list[dict[str, Any]]:
|
|
215
|
+
prompt_index = {
|
|
216
|
+
prompt.name: prompt for prompt in PromptRegistry.builtin_prompt_models()
|
|
217
|
+
}
|
|
218
|
+
for prompt in await context.store.list_prompts():
|
|
219
|
+
prompt_index[prompt.name] = prompt
|
|
220
|
+
ordered = sorted(
|
|
221
|
+
prompt_index.values(),
|
|
222
|
+
key=lambda prompt: (
|
|
223
|
+
not bool(getattr(prompt, "is_builtin", False)),
|
|
224
|
+
str(getattr(prompt, "name", "")).lower(),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
return [_serialize_prompt(prompt) for prompt in ordered]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_search_routes(context: AdminRouteContext) -> list[BaseRoute]:
|
|
231
|
+
async def admin_catalog_search(request: Request) -> JSONResponse:
|
|
232
|
+
try:
|
|
233
|
+
await context.admin_user_from_request(request)
|
|
234
|
+
except PermissionError:
|
|
235
|
+
return JSONResponse({"error": "Admin role required"}, status_code=403)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
return JSONResponse({"error": str(exc)}, status_code=401)
|
|
238
|
+
|
|
239
|
+
resource = str(request.path_params.get("resource", "")).strip().lower()
|
|
240
|
+
query = (request.query_params.get("query") or "").strip()
|
|
241
|
+
try:
|
|
242
|
+
limit = max(1, min(int(request.query_params.get("limit", "100")), 200))
|
|
243
|
+
offset = max(0, int(request.query_params.get("offset", "0")))
|
|
244
|
+
except ValueError:
|
|
245
|
+
return JSONResponse({"error": "Invalid limit or offset"}, status_code=400)
|
|
246
|
+
|
|
247
|
+
await record_admin_operation(
|
|
248
|
+
operation=f"search_{resource or 'catalog'}",
|
|
249
|
+
outcome="success",
|
|
250
|
+
actor_id="system",
|
|
251
|
+
store=context.store,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
config = _config_from_request(request)
|
|
255
|
+
|
|
256
|
+
ranked: list[Any] = []
|
|
257
|
+
|
|
258
|
+
if resource == "clients":
|
|
259
|
+
client_items = (await context.use_cases.list_clients())["clients"]
|
|
260
|
+
ranked = _rank_serialized_items(
|
|
261
|
+
items=client_items,
|
|
262
|
+
query=query,
|
|
263
|
+
text_builder=lambda item: " ".join(
|
|
264
|
+
[
|
|
265
|
+
str(item.get("name", "")),
|
|
266
|
+
str(item.get("slug", "")),
|
|
267
|
+
str(item.get("description", "")),
|
|
268
|
+
" ".join(item.get("tool_scopes", [])),
|
|
269
|
+
" ".join(item.get("repo_scopes", [])),
|
|
270
|
+
" ".join(item.get("workflow_scopes", [])),
|
|
271
|
+
]
|
|
272
|
+
),
|
|
273
|
+
config=config,
|
|
274
|
+
)
|
|
275
|
+
elif resource == "repositories":
|
|
276
|
+
repo_items = (await context.use_cases.list_repositories())["repositories"]
|
|
277
|
+
ranked = _rank_serialized_items(
|
|
278
|
+
items=repo_items,
|
|
279
|
+
query=query,
|
|
280
|
+
text_builder=lambda item: " ".join(
|
|
281
|
+
[
|
|
282
|
+
str(item.get("name", "")),
|
|
283
|
+
str(item.get("path", "")),
|
|
284
|
+
str(item.get("remote_url", "")),
|
|
285
|
+
str(item.get("default_branch", "")),
|
|
286
|
+
str(item.get("workflow_name", "")),
|
|
287
|
+
str(item.get("workflow_state", "")),
|
|
288
|
+
str(item.get("current_step", "")),
|
|
289
|
+
" ".join(item.get("tracked_branches", [])),
|
|
290
|
+
]
|
|
291
|
+
),
|
|
292
|
+
config=config,
|
|
293
|
+
)
|
|
294
|
+
elif resource == "workflows":
|
|
295
|
+
workflow_items = (await context.use_cases.list_workflows())["workflows"]
|
|
296
|
+
ranked = _rank_serialized_items(
|
|
297
|
+
items=workflow_items,
|
|
298
|
+
query=query,
|
|
299
|
+
text_builder=lambda item: " ".join(
|
|
300
|
+
[
|
|
301
|
+
str(item.get("name", "")),
|
|
302
|
+
str(item.get("description", "")),
|
|
303
|
+
str(item.get("enforcement", "")),
|
|
304
|
+
" ".join(
|
|
305
|
+
" ".join(
|
|
306
|
+
[
|
|
307
|
+
str(step.get("name", "")),
|
|
308
|
+
str(step.get("description", "")),
|
|
309
|
+
str(step.get("gate", "")),
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
for step in item.get("steps", [])
|
|
313
|
+
),
|
|
314
|
+
]
|
|
315
|
+
),
|
|
316
|
+
config=config,
|
|
317
|
+
)
|
|
318
|
+
elif resource == "prompts":
|
|
319
|
+
prompt_items = await _prompt_items(context)
|
|
320
|
+
ranked = _rank_serialized_items(
|
|
321
|
+
items=prompt_items,
|
|
322
|
+
query=query,
|
|
323
|
+
text_builder=lambda item: " ".join(
|
|
324
|
+
[
|
|
325
|
+
str(item.get("name", "")),
|
|
326
|
+
str(item.get("title", "")),
|
|
327
|
+
str(item.get("description", "")),
|
|
328
|
+
str(item.get("content_template", "")),
|
|
329
|
+
" ".join(item.get("arguments", [])),
|
|
330
|
+
]
|
|
331
|
+
),
|
|
332
|
+
config=config,
|
|
333
|
+
)
|
|
334
|
+
elif resource == "skills":
|
|
335
|
+
skill_tools = SkillTools(context.store, config)
|
|
336
|
+
all_skill_items = await skill_tools.minder_skill_list()
|
|
337
|
+
ranked = _rank_skill_items(
|
|
338
|
+
items=all_skill_items,
|
|
339
|
+
query=query,
|
|
340
|
+
config=config,
|
|
341
|
+
)
|
|
342
|
+
elif resource == "memories":
|
|
343
|
+
memory_tools = MemoryTools(context.store, config)
|
|
344
|
+
all_memory_items = [
|
|
345
|
+
_serialize_memory(skill)
|
|
346
|
+
for skill in sorted(
|
|
347
|
+
await context.store.list_skills(),
|
|
348
|
+
key=lambda skill: str(getattr(skill, "title", "")).lower(),
|
|
349
|
+
)
|
|
350
|
+
]
|
|
351
|
+
ranked = (
|
|
352
|
+
await memory_tools.minder_memory_recall(
|
|
353
|
+
query, limit=max(len(all_memory_items), 1)
|
|
354
|
+
)
|
|
355
|
+
if query
|
|
356
|
+
else all_memory_items
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
return JSONResponse({"error": "Unsupported resource"}, status_code=404)
|
|
360
|
+
|
|
361
|
+
payload = _slice_items(ranked, limit=limit, offset=offset)
|
|
362
|
+
payload["resource"] = resource
|
|
363
|
+
payload["query"] = query
|
|
364
|
+
return JSONResponse(payload)
|
|
365
|
+
|
|
366
|
+
return [
|
|
367
|
+
Route("/v1/admin/search/{resource}", admin_catalog_search, methods=["GET"]),
|
|
368
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
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.observability.metrics import record_admin_operation
|
|
14
|
+
from minder.tools.skills import SkillTools
|
|
15
|
+
|
|
16
|
+
from .context import AdminRouteContext
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkillCreateRequest(BaseModel):
|
|
22
|
+
title: str
|
|
23
|
+
content: str
|
|
24
|
+
language: str
|
|
25
|
+
tags: list[str] = Field(default_factory=list)
|
|
26
|
+
workflow_steps: list[str] = Field(default_factory=list)
|
|
27
|
+
artifact_types: list[str] = Field(default_factory=list)
|
|
28
|
+
provenance: str | None = None
|
|
29
|
+
quality_score: float = 0.0
|
|
30
|
+
source: dict[str, Any] | None = None
|
|
31
|
+
excerpt_kind: str = "none"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SkillUpdateRequest(BaseModel):
|
|
35
|
+
title: str | None = None
|
|
36
|
+
content: str | None = None
|
|
37
|
+
language: str | None = None
|
|
38
|
+
tags: list[str] | None = None
|
|
39
|
+
workflow_steps: list[str] | None = None
|
|
40
|
+
artifact_types: list[str] | None = None
|
|
41
|
+
provenance: str | None = None
|
|
42
|
+
quality_score: float | None = None
|
|
43
|
+
source: dict[str, Any] | None = None
|
|
44
|
+
excerpt_kind: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SkillImportRequest(BaseModel):
|
|
48
|
+
repo_url: str
|
|
49
|
+
path: str = "skills"
|
|
50
|
+
ref: str | None = None
|
|
51
|
+
provider: str | None = None
|
|
52
|
+
excerpt_kind: str = "none"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _config_from_request(request: Request) -> MinderConfig:
|
|
56
|
+
config = getattr(request.app.state, "config", None)
|
|
57
|
+
if isinstance(config, MinderConfig):
|
|
58
|
+
return config
|
|
59
|
+
return MinderConfig()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _artifact_tags() -> set[str]:
|
|
63
|
+
return set(SkillTools._ARTIFACT_TAGS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _serialize_skill(skill: Any) -> dict[str, Any]:
|
|
67
|
+
tags = list(getattr(skill, "tags", []) or [])
|
|
68
|
+
artifact_tags = _artifact_tags()
|
|
69
|
+
source_metadata = getattr(skill, "source_metadata", None)
|
|
70
|
+
return {
|
|
71
|
+
"id": str(skill.id),
|
|
72
|
+
"title": str(skill.title),
|
|
73
|
+
"content": str(skill.content),
|
|
74
|
+
"language": str(getattr(skill, "language", "")),
|
|
75
|
+
"tags": tags,
|
|
76
|
+
"quality_score": round(float(getattr(skill, "quality_score", 0.0) or 0.0), 4),
|
|
77
|
+
"usage_count": int(getattr(skill, "usage_count", 0) or 0),
|
|
78
|
+
"workflow_step_tags": [
|
|
79
|
+
tag for tag in tags if ":" not in tag and tag not in artifact_tags
|
|
80
|
+
],
|
|
81
|
+
"artifact_type_tags": [tag for tag in tags if tag in artifact_tags],
|
|
82
|
+
"provenance": next(
|
|
83
|
+
(tag.split(":", 1)[1] for tag in tags if tag.startswith("source:")),
|
|
84
|
+
None,
|
|
85
|
+
),
|
|
86
|
+
"source": dict(source_metadata) if isinstance(source_metadata, dict) else None,
|
|
87
|
+
"excerpt_kind": str(getattr(skill, "excerpt_kind", "none") or "none"),
|
|
88
|
+
"created_at": skill.created_at.isoformat() if skill.created_at else None,
|
|
89
|
+
"updated_at": skill.updated_at.isoformat() if skill.updated_at else None,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_skills_routes(context: AdminRouteContext) -> list[BaseRoute]:
|
|
94
|
+
async def list_skills(request: Request) -> JSONResponse:
|
|
95
|
+
del request
|
|
96
|
+
await record_admin_operation(
|
|
97
|
+
operation="list_skills",
|
|
98
|
+
outcome="success",
|
|
99
|
+
actor_id="system",
|
|
100
|
+
store=context.store,
|
|
101
|
+
)
|
|
102
|
+
try:
|
|
103
|
+
skills = sorted(
|
|
104
|
+
await context.store.list_skills(),
|
|
105
|
+
key=lambda skill: (
|
|
106
|
+
-float(getattr(skill, "quality_score", 0.0) or 0.0),
|
|
107
|
+
str(getattr(skill, "title", "")).lower(),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
return JSONResponse([_serialize_skill(skill) for skill in skills])
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.exception("Failed to list skills", exc_info=exc)
|
|
113
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
114
|
+
|
|
115
|
+
async def get_skill(request: Request) -> JSONResponse:
|
|
116
|
+
skill_id = request.path_params["skill_id"]
|
|
117
|
+
await record_admin_operation(
|
|
118
|
+
operation="get_skill",
|
|
119
|
+
outcome="success",
|
|
120
|
+
actor_id="system",
|
|
121
|
+
store=context.store,
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
skill = await context.store.get_skill_by_id(uuid.UUID(skill_id))
|
|
125
|
+
if not skill:
|
|
126
|
+
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
|
127
|
+
return JSONResponse(_serialize_skill(skill))
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.exception("Failed to get skill", exc_info=exc)
|
|
130
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
131
|
+
|
|
132
|
+
async def create_skill(request: Request) -> JSONResponse:
|
|
133
|
+
await record_admin_operation(
|
|
134
|
+
operation="create_skill",
|
|
135
|
+
outcome="success",
|
|
136
|
+
actor_id="system",
|
|
137
|
+
store=context.store,
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
payload = SkillCreateRequest(**(await request.json()))
|
|
141
|
+
tools = SkillTools(context.store, _config_from_request(request))
|
|
142
|
+
skill = await tools.minder_skill_store(
|
|
143
|
+
title=payload.title,
|
|
144
|
+
content=payload.content,
|
|
145
|
+
language=payload.language,
|
|
146
|
+
tags=payload.tags,
|
|
147
|
+
workflow_steps=payload.workflow_steps,
|
|
148
|
+
artifact_types=payload.artifact_types,
|
|
149
|
+
provenance=payload.provenance,
|
|
150
|
+
quality_score=payload.quality_score,
|
|
151
|
+
source_metadata=payload.source,
|
|
152
|
+
excerpt_kind=payload.excerpt_kind,
|
|
153
|
+
)
|
|
154
|
+
return JSONResponse(skill, status_code=201)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
logger.exception("Failed to create skill", exc_info=exc)
|
|
157
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
158
|
+
|
|
159
|
+
async def update_skill(request: Request) -> JSONResponse:
|
|
160
|
+
skill_id = request.path_params["skill_id"]
|
|
161
|
+
await record_admin_operation(
|
|
162
|
+
operation="update_skill",
|
|
163
|
+
outcome="success",
|
|
164
|
+
actor_id="system",
|
|
165
|
+
store=context.store,
|
|
166
|
+
)
|
|
167
|
+
try:
|
|
168
|
+
payload = SkillUpdateRequest(**(await request.json()))
|
|
169
|
+
tools = SkillTools(context.store, _config_from_request(request))
|
|
170
|
+
update_data = payload.model_dump(exclude={"source"}, exclude_unset=True)
|
|
171
|
+
skill = await tools.minder_skill_update(
|
|
172
|
+
skill_id,
|
|
173
|
+
**update_data,
|
|
174
|
+
source_metadata=payload.source,
|
|
175
|
+
)
|
|
176
|
+
return JSONResponse(skill)
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
return JSONResponse({"error": str(exc)}, status_code=404)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logger.exception("Failed to update skill", exc_info=exc)
|
|
181
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
182
|
+
|
|
183
|
+
async def import_skills(request: Request) -> JSONResponse:
|
|
184
|
+
await record_admin_operation(
|
|
185
|
+
operation="import_skills",
|
|
186
|
+
outcome="success",
|
|
187
|
+
actor_id="system",
|
|
188
|
+
store=context.store,
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
payload = SkillImportRequest(**(await request.json()))
|
|
192
|
+
tools = SkillTools(context.store, _config_from_request(request))
|
|
193
|
+
summary = await tools.minder_skill_import_git(
|
|
194
|
+
repo_url=payload.repo_url,
|
|
195
|
+
source_path=payload.path,
|
|
196
|
+
ref=payload.ref,
|
|
197
|
+
provider=payload.provider,
|
|
198
|
+
excerpt_kind=payload.excerpt_kind,
|
|
199
|
+
)
|
|
200
|
+
return JSONResponse(summary, status_code=201)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.exception("Failed to import skills", exc_info=exc)
|
|
203
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
204
|
+
|
|
205
|
+
async def delete_skill(request: Request) -> JSONResponse:
|
|
206
|
+
skill_id = request.path_params["skill_id"]
|
|
207
|
+
await record_admin_operation(
|
|
208
|
+
operation="delete_skill",
|
|
209
|
+
outcome="success",
|
|
210
|
+
actor_id="system",
|
|
211
|
+
store=context.store,
|
|
212
|
+
)
|
|
213
|
+
try:
|
|
214
|
+
skill = await context.store.get_skill_by_id(uuid.UUID(skill_id))
|
|
215
|
+
if skill is None:
|
|
216
|
+
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
|
217
|
+
await context.store.delete_skill(uuid.UUID(skill_id))
|
|
218
|
+
return JSONResponse({"status": "deleted"}, status_code=200)
|
|
219
|
+
except Exception as exc:
|
|
220
|
+
logger.exception("Failed to delete skill", exc_info=exc)
|
|
221
|
+
return JSONResponse({"error": str(exc)}, status_code=500)
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
Route("/api/v1/skills", list_skills, methods=["GET"]),
|
|
225
|
+
Route("/api/v1/skills", create_skill, methods=["POST"]),
|
|
226
|
+
Route("/api/v1/skills/imports", import_skills, methods=["POST"]),
|
|
227
|
+
Route("/api/v1/skills/{skill_id}", get_skill, methods=["GET"]),
|
|
228
|
+
Route("/api/v1/skills/{skill_id}", update_skill, methods=["PATCH", "PUT"]),
|
|
229
|
+
Route("/api/v1/skills/{skill_id}", delete_skill, methods=["DELETE"]),
|
|
230
|
+
]
|