datasentry-ai 0.1.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.
- datasentry/__init__.py +7 -0
- datasentry/api.py +379 -0
- datasentry/cli.py +920 -0
- datasentry/client.py +414 -0
- datasentry/llm_providers.py +228 -0
- datasentry/mcp_server.py +310 -0
- datasentry/repair_ai.py +420 -0
- datasentry/rules_ai.py +381 -0
- datasentry/trends.py +89 -0
- datasentry/ui.py +307 -0
- datasentry_ai-0.1.0.dist-info/METADATA +178 -0
- datasentry_ai-0.1.0.dist-info/RECORD +15 -0
- datasentry_ai-0.1.0.dist-info/WHEEL +4 -0
- datasentry_ai-0.1.0.dist-info/entry_points.txt +3 -0
- datasentry_ai-0.1.0.dist-info/licenses/LICENSE +202 -0
datasentry/__init__.py
ADDED
datasentry/api.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""DataSentry REST API(Step 23,MVP REST 面,22/23 章 HTTP 映射)。
|
|
2
|
+
|
|
3
|
+
无状态不适用:本 API 是「单工作区门面」——`create_app(project=...)` 绑定一个
|
|
4
|
+
`DataSentry` 实例,所有端点复用同一条导入→扫描→落库→修复闭环(与 CLI/SDK
|
|
5
|
+
同源)。MVP 只提供同步端点(FastAPI async 并发已覆盖多数用法),异步 Job
|
|
6
|
+
队列归 V1(ADR-023)。
|
|
7
|
+
|
|
8
|
+
端点一览:
|
|
9
|
+
GET /health 存活探针
|
|
10
|
+
GET / 端点清单
|
|
11
|
+
POST /scans 扫描文件 → 201 ScanResponse
|
|
12
|
+
GET /scans ScanRun id 列表
|
|
13
|
+
GET /scans/{run_id} ScanRun 详情
|
|
14
|
+
GET /scans/{run_id}/issues Issue 列表
|
|
15
|
+
GET /scans/{run_id}/report 26 章规范 JSON 报告
|
|
16
|
+
GET /scans/{run_id}/score 27 章质量总分
|
|
17
|
+
GET /issues 跨扫描 Issue(severity 过滤)
|
|
18
|
+
POST /scans/{run_id}/repairs/propose 修复提案
|
|
19
|
+
POST /scans/{run_id}/repairs/preview 提案+预览
|
|
20
|
+
POST /scans/{run_id}/repairs/apply 应用修复
|
|
21
|
+
POST /repairs/{run_id}/rollback 回滚
|
|
22
|
+
GET /repairs 修复运行列表
|
|
23
|
+
|
|
24
|
+
错误映射:FileNotFoundError/KeyError→404、ValueError→400、其余→500,
|
|
25
|
+
body 统一 {"ok": false, "detail": "..."}。
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from fastapi import FastAPI, Form, HTTPException, Query
|
|
34
|
+
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
35
|
+
from pydantic import BaseModel, Field
|
|
36
|
+
|
|
37
|
+
from datasentry import __version__, ui
|
|
38
|
+
from datasentry import client as sdk
|
|
39
|
+
from datasentry_core.models.issue import Issue
|
|
40
|
+
from datasentry_core.models.repair import RepairPreview, RepairProposal, RepairRun
|
|
41
|
+
from datasentry_core.models.scan import DetectorRun, ScanConfig, ScanRun
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScanRequest(BaseModel):
|
|
45
|
+
"""POST /scans 请求体:源文件路径(workspace 相对或绝对)+ 扫描配置。"""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
dataset_id: str | None = None
|
|
49
|
+
detectors: list[str] | None = None
|
|
50
|
+
seed: int = 42
|
|
51
|
+
tags: dict[str, str] = Field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ScanResponse(BaseModel):
|
|
55
|
+
run: ScanRun
|
|
56
|
+
detector_runs: list[DetectorRun]
|
|
57
|
+
issues: list[Issue]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProposeRequest(BaseModel):
|
|
61
|
+
source_path: str
|
|
62
|
+
issue_id: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PreviewRequest(BaseModel):
|
|
66
|
+
source_path: str
|
|
67
|
+
issue_id: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ApplyRequest(BaseModel):
|
|
71
|
+
source_path: str
|
|
72
|
+
issue_id: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HealthResponse(BaseModel):
|
|
76
|
+
ok: bool
|
|
77
|
+
service: str
|
|
78
|
+
version: str
|
|
79
|
+
workspace: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ErrorBody(BaseModel):
|
|
83
|
+
ok: bool = False
|
|
84
|
+
detail: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _config_from(req: ScanRequest) -> ScanConfig:
|
|
88
|
+
return ScanConfig(
|
|
89
|
+
detectors=req.detectors,
|
|
90
|
+
seed=req.seed,
|
|
91
|
+
scan_tags=req.tags,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _error(exc: Exception) -> int:
|
|
96
|
+
if isinstance(exc, (FileNotFoundError, KeyError)):
|
|
97
|
+
return 404
|
|
98
|
+
if isinstance(exc, ValueError):
|
|
99
|
+
return 422
|
|
100
|
+
return 500
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _handle(exc: Exception) -> HTTPException:
|
|
104
|
+
return HTTPException(status_code=_error(exc), detail=str(exc))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_issue(client: sdk.DataSentry, issue_id: str) -> Issue | None:
|
|
108
|
+
return client.get_issue(issue_id)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# 应用工厂
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_app(project: str | Path | None = None) -> FastAPI:
|
|
117
|
+
"""创建绑定给定工作区的应用(默认当前目录或 DATASENTRY_PROJECT)。"""
|
|
118
|
+
if project is None:
|
|
119
|
+
project = os.environ.get("DATASENTRY_PROJECT")
|
|
120
|
+
app = FastAPI(title="DataSentry API", version=__version__)
|
|
121
|
+
client = sdk.DataSentry(project=project)
|
|
122
|
+
app.state.client = client
|
|
123
|
+
|
|
124
|
+
@app.get("/health", response_model=HealthResponse, tags=["meta"])
|
|
125
|
+
def health() -> HealthResponse:
|
|
126
|
+
return HealthResponse(
|
|
127
|
+
ok=True,
|
|
128
|
+
service="datasentry",
|
|
129
|
+
version=__version__,
|
|
130
|
+
workspace=str(client.workspace),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@app.get("/", tags=["meta"])
|
|
134
|
+
def root() -> dict[str, object]:
|
|
135
|
+
return {
|
|
136
|
+
"service": "datasentry",
|
|
137
|
+
"version": __version__,
|
|
138
|
+
"endpoints": list(_ENDPOINTS),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@app.get("/scans", tags=["scans"])
|
|
142
|
+
def list_scan_ids() -> list[str]:
|
|
143
|
+
return [scan.id for scan in client.list_scan_runs()]
|
|
144
|
+
|
|
145
|
+
@app.post("/scans", response_model=ScanResponse, tags=["scans"], status_code=201)
|
|
146
|
+
def create_scan(req: ScanRequest) -> ScanResponse:
|
|
147
|
+
try:
|
|
148
|
+
scan, runs, issues = client.scan_file(
|
|
149
|
+
req.path,
|
|
150
|
+
dataset_id=req.dataset_id,
|
|
151
|
+
config=_config_from(req),
|
|
152
|
+
)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
raise _handle(exc) from exc
|
|
155
|
+
return ScanResponse(run=scan, detector_runs=runs, issues=issues)
|
|
156
|
+
|
|
157
|
+
@app.get("/scans/{run_id}", response_model=ScanRun, tags=["scans"])
|
|
158
|
+
def get_scan(run_id: str) -> ScanRun:
|
|
159
|
+
try:
|
|
160
|
+
scan = client.get_scan(run_id)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
raise _handle(exc) from exc
|
|
163
|
+
if scan is None:
|
|
164
|
+
raise HTTPException(status_code=404, detail=f"scan run not found: {run_id}")
|
|
165
|
+
return scan
|
|
166
|
+
|
|
167
|
+
@app.get("/scans/{run_id}/issues", response_model=list[Issue], tags=["scans"])
|
|
168
|
+
def get_scan_issues(run_id: str) -> list[Issue]:
|
|
169
|
+
return client.list_issues(scan_run_id=run_id)
|
|
170
|
+
|
|
171
|
+
@app.get("/scans/{run_id}/report", tags=["scans"])
|
|
172
|
+
def get_report(run_id: str) -> dict[str, object]:
|
|
173
|
+
try:
|
|
174
|
+
return client.export_report(run_id)
|
|
175
|
+
except Exception as exc:
|
|
176
|
+
raise _handle(exc) from exc
|
|
177
|
+
|
|
178
|
+
@app.get("/scans/{run_id}/score", tags=["scans"])
|
|
179
|
+
def get_score(run_id: str) -> dict[str, object]:
|
|
180
|
+
try:
|
|
181
|
+
score = client.quality_score(run_id)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise _handle(exc) from exc
|
|
184
|
+
if score is None:
|
|
185
|
+
raise HTTPException(status_code=404, detail="quality score unavailable")
|
|
186
|
+
return score.model_dump()
|
|
187
|
+
|
|
188
|
+
@app.get("/issues", response_model=list[Issue], tags=["issues"])
|
|
189
|
+
def list_all_issues(
|
|
190
|
+
severity_at_least: str | None = Query(default=None),
|
|
191
|
+
) -> list[Issue]:
|
|
192
|
+
return client.list_issues(severity_at_least=severity_at_least)
|
|
193
|
+
|
|
194
|
+
# ---- 修复端点(15 章 / ADR-020;source_path 为待修复源文件) -------
|
|
195
|
+
|
|
196
|
+
@app.post(
|
|
197
|
+
"/scans/{run_id}/repairs/propose",
|
|
198
|
+
response_model=RepairProposal | None,
|
|
199
|
+
tags=["repairs"],
|
|
200
|
+
)
|
|
201
|
+
def repair_propose(run_id: str, req: ProposeRequest) -> RepairProposal | None:
|
|
202
|
+
try:
|
|
203
|
+
return client.repair_propose(req.issue_id, req.source_path)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
raise _handle(exc) from exc
|
|
206
|
+
|
|
207
|
+
@app.post(
|
|
208
|
+
"/scans/{run_id}/repairs/preview",
|
|
209
|
+
tags=["repairs"],
|
|
210
|
+
)
|
|
211
|
+
def repair_preview(run_id: str, req: PreviewRequest) -> dict[str, object] | None:
|
|
212
|
+
try:
|
|
213
|
+
result = client.repair_preview(req.issue_id, req.source_path)
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
raise _handle(exc) from exc
|
|
216
|
+
if result is None:
|
|
217
|
+
return None
|
|
218
|
+
proposal, preview = result
|
|
219
|
+
return {
|
|
220
|
+
"proposal": proposal.model_dump(),
|
|
221
|
+
"preview": preview.model_dump(),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@app.post(
|
|
225
|
+
"/scans/{run_id}/repairs/apply",
|
|
226
|
+
response_model=RepairRun,
|
|
227
|
+
tags=["repairs"],
|
|
228
|
+
)
|
|
229
|
+
def repair_apply(run_id: str, req: ApplyRequest) -> RepairRun:
|
|
230
|
+
try:
|
|
231
|
+
return client.repair_apply(req.issue_id, req.source_path)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
raise _handle(exc) from exc
|
|
234
|
+
|
|
235
|
+
@app.post(
|
|
236
|
+
"/repairs/{run_id}/rollback",
|
|
237
|
+
response_model=RepairRun,
|
|
238
|
+
tags=["repairs"],
|
|
239
|
+
)
|
|
240
|
+
def repair_rollback(run_id: str) -> RepairRun:
|
|
241
|
+
try:
|
|
242
|
+
return client.repair_rollback(run_id)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
raise _handle(exc) from exc
|
|
245
|
+
|
|
246
|
+
@app.get("/repairs", response_model=list[RepairRun], tags=["repairs"])
|
|
247
|
+
def list_repair_runs() -> list[RepairRun]:
|
|
248
|
+
return client.list_repair_runs()
|
|
249
|
+
|
|
250
|
+
# ---- Web UI(Step 24:服务端渲染核心页) ----------------------------
|
|
251
|
+
|
|
252
|
+
@app.get("/ui", response_class=HTMLResponse, tags=["ui"])
|
|
253
|
+
@app.get("/ui/", response_class=HTMLResponse, tags=["ui"])
|
|
254
|
+
def ui_home() -> HTMLResponse:
|
|
255
|
+
return HTMLResponse(ui.render_home(client.list_scan_runs()))
|
|
256
|
+
|
|
257
|
+
@app.post("/ui/scans", response_class=HTMLResponse, tags=["ui"])
|
|
258
|
+
def ui_create_scan(path: str = Form()) -> Response:
|
|
259
|
+
try:
|
|
260
|
+
scan, _runs, _issues = client.scan_file(path)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
return HTMLResponse(ui.render_error("Scan failed", str(exc)), status_code=404)
|
|
263
|
+
return RedirectResponse(url=f"/ui/scans/{scan.id}", status_code=303)
|
|
264
|
+
|
|
265
|
+
@app.get("/ui/scans", response_class=HTMLResponse, tags=["ui"])
|
|
266
|
+
def ui_scans_list() -> HTMLResponse:
|
|
267
|
+
return HTMLResponse(ui.render_home(client.list_scan_runs()))
|
|
268
|
+
|
|
269
|
+
@app.get("/ui/trends", response_class=HTMLResponse, tags=["ui"])
|
|
270
|
+
def ui_trends() -> HTMLResponse:
|
|
271
|
+
from datasentry.trends import build_trends
|
|
272
|
+
|
|
273
|
+
return HTMLResponse(ui.render_trends(build_trends(client.list_scan_runs())))
|
|
274
|
+
|
|
275
|
+
@app.get("/ui/scans/{run_id}", response_class=HTMLResponse, tags=["ui"])
|
|
276
|
+
def ui_scan_detail(run_id: str, severity: str | None = Query(default=None)) -> HTMLResponse:
|
|
277
|
+
scan = client.get_scan(run_id)
|
|
278
|
+
if scan is None:
|
|
279
|
+
return HTMLResponse(
|
|
280
|
+
ui.render_error("Scan not found", f"scan run: {run_id}"), status_code=404
|
|
281
|
+
)
|
|
282
|
+
issues = client.list_issues(scan_run_id=run_id, severity_at_least=severity)
|
|
283
|
+
return HTMLResponse(ui.render_scan_detail(scan, issues, severity_filter=severity))
|
|
284
|
+
|
|
285
|
+
@app.get(
|
|
286
|
+
"/ui/scans/{run_id}/issues/{issue_id}",
|
|
287
|
+
response_class=HTMLResponse,
|
|
288
|
+
tags=["ui"],
|
|
289
|
+
)
|
|
290
|
+
def ui_workbench(run_id: str, issue_id: str) -> HTMLResponse:
|
|
291
|
+
issue = _get_issue(client, issue_id)
|
|
292
|
+
if issue is None:
|
|
293
|
+
return HTMLResponse(ui.render_error("Issue not found", issue_id), status_code=404)
|
|
294
|
+
return HTMLResponse(ui.render_workbench(issue, run_id=run_id))
|
|
295
|
+
|
|
296
|
+
@app.post(
|
|
297
|
+
"/ui/scans/{run_id}/issues/{issue_id}",
|
|
298
|
+
response_class=HTMLResponse,
|
|
299
|
+
tags=["ui"],
|
|
300
|
+
)
|
|
301
|
+
def ui_workbench_action(
|
|
302
|
+
run_id: str,
|
|
303
|
+
issue_id: str,
|
|
304
|
+
source_path: str = Form(),
|
|
305
|
+
action: str = Form(),
|
|
306
|
+
) -> HTMLResponse:
|
|
307
|
+
issue = _get_issue(client, issue_id)
|
|
308
|
+
if issue is None:
|
|
309
|
+
return HTMLResponse(ui.render_error("Issue not found", issue_id), status_code=404)
|
|
310
|
+
error: str | None = None
|
|
311
|
+
proposal: RepairProposal | None = None
|
|
312
|
+
preview: RepairPreview | None = None
|
|
313
|
+
run: RepairRun | None = None
|
|
314
|
+
try:
|
|
315
|
+
if action == "propose":
|
|
316
|
+
proposal = client.repair_propose(issue_id, source_path)
|
|
317
|
+
if proposal is not None:
|
|
318
|
+
pair = client.repair_preview(issue_id, source_path)
|
|
319
|
+
if pair is not None:
|
|
320
|
+
preview = pair[1]
|
|
321
|
+
else:
|
|
322
|
+
error = "no repair proposal available for this issue"
|
|
323
|
+
elif action == "apply":
|
|
324
|
+
run = client.repair_apply(issue_id, source_path)
|
|
325
|
+
else:
|
|
326
|
+
error = f"unknown action: {action}"
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
error = str(exc)
|
|
329
|
+
return HTMLResponse(
|
|
330
|
+
ui.render_workbench(
|
|
331
|
+
issue,
|
|
332
|
+
run_id=run_id,
|
|
333
|
+
source_path=source_path,
|
|
334
|
+
proposal=proposal,
|
|
335
|
+
preview=preview,
|
|
336
|
+
run=run,
|
|
337
|
+
error=error,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
@app.post(
|
|
342
|
+
"/ui/scans/{run_id}/repairs/{repair_run_id}/rollback",
|
|
343
|
+
response_class=HTMLResponse,
|
|
344
|
+
tags=["ui"],
|
|
345
|
+
)
|
|
346
|
+
def ui_rollback(run_id: str, repair_run_id: str) -> Response:
|
|
347
|
+
try:
|
|
348
|
+
client.repair_rollback(repair_run_id)
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
return HTMLResponse(ui.render_error("Rollback failed", str(exc)), status_code=404)
|
|
351
|
+
return RedirectResponse(url=f"/ui/scans/{run_id}", status_code=303)
|
|
352
|
+
|
|
353
|
+
return app
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_ENDPOINTS = frozenset(
|
|
357
|
+
{
|
|
358
|
+
"GET /health",
|
|
359
|
+
"GET /scans",
|
|
360
|
+
"POST /scans",
|
|
361
|
+
"GET /scans/{run_id}",
|
|
362
|
+
"GET /scans/{run_id}/issues",
|
|
363
|
+
"GET /scans/{run_id}/report",
|
|
364
|
+
"GET /scans/{run_id}/score",
|
|
365
|
+
"GET /issues",
|
|
366
|
+
"POST /scans/{run_id}/repairs/propose",
|
|
367
|
+
"POST /scans/{run_id}/repairs/preview",
|
|
368
|
+
"POST /scans/{run_id}/repairs/apply",
|
|
369
|
+
"POST /repairs/{run_id}/rollback",
|
|
370
|
+
"GET /repairs",
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def main() -> None:
|
|
376
|
+
"""启动 API 服务(容器/开发入口,默认 0.0.0.0:8000)。"""
|
|
377
|
+
import uvicorn
|
|
378
|
+
|
|
379
|
+
uvicorn.run(create_app(), host="0.0.0.0", port=8000)
|