feature: /api/v1/runs/{run_id}/export 按照需求导出测试结果
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
| 取消测试运行 | `PATCH /api/v1/runs/{run_id}` |
|
||||
| 删除终态运行 | `DELETE /api/v1/runs/{run_id}` |
|
||||
| 查询测试结果 | `GET /api/v1/runs/{run_id}/results` |
|
||||
| 导出 Markdown 详细报告 | `GET /api/v1/runs/{run_id}/export` |
|
||||
| 列出汇总报告 | `GET /api/v1/reports` |
|
||||
| 获取汇总报告 | `GET /api/v1/reports/{run_id}` |
|
||||
|
||||
@@ -36,13 +37,20 @@
|
||||
逐条结果用 `execution_status`(`completed|error`)表示执行状态,用可空 `verdict` 表示仲裁结论;Run 的 `error_count` 不包含 `verdict=fail`。`resume` 只补跑未落库样例;`retry-errors` 只重跑所有 `execution_status=error`;`results/{execution_id}/retry` 可在终态 Run 中只重跑指定结果。
|
||||
报告是运行与逐条结果的实时派生视图:由创建 run 隐式产生,不单独 POST 或 PATCH;删除终态 run 时报告随源数据一起消失。
|
||||
|
||||
终态 Run 可导出包含模型输入、输出和裁判结果的 Markdown 详细报告;默认导出全部样例:
|
||||
终态 Run 可通过 API 导出包含模型输入、输出和裁判结果的 Markdown 详细报告;默认导出全部样例:
|
||||
|
||||
```bash
|
||||
curl -OJ -H 'Authorization: Bearer <access_token>' \
|
||||
http://127.0.0.1:8000/api/v1/runs/42/export
|
||||
```
|
||||
|
||||
服务器命令行也可使用:
|
||||
|
||||
```bash
|
||||
uv run python scripts/export_run.py --run-id 42
|
||||
```
|
||||
|
||||
使用 `--execution-status completed|error` 按执行状态过滤,使用 `--verdict pass|fail|needs_human_review|judge_format_error|none` 按评价结果过滤;两者可组合。默认输出为 `outputs/run_<run_id>_results.md`。
|
||||
API 查询参数为 `execution_status` 和 `verdict`,脚本对应选项为 `--execution-status` 和 `--verdict`。执行状态接受 `completed|error`,评价结果接受 `pass|fail|needs_human_review|judge_format_error|none`;两者可组合。API 返回附件 `run_<run_id>_results.md`,脚本默认写入 `outputs/` 目录。
|
||||
|
||||
## 快速启动
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Path, Query, Response, status
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.api import (
|
||||
)
|
||||
from app.services.capability_service import CapabilityProbeService
|
||||
from app.services.dataset_service import DatasetGateway, TestPlanService
|
||||
from app.services.export_service import matches_result, render_markdown
|
||||
from app.services.test_execution_service import TestExecutionService
|
||||
|
||||
router = APIRouter()
|
||||
@@ -519,3 +520,39 @@ def get_run_results(
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{run_id}/export",
|
||||
summary="导出测试运行详细报告",
|
||||
description="将终态 Run 的逐条结果导出为 Markdown;默认导出全部,可按执行状态和评价结果筛选。",
|
||||
responses={
|
||||
200: {"content": {"text/markdown": {}}, "description": "Markdown 详细报告附件。"},
|
||||
404: {"model": ErrorResponse},
|
||||
409: {"model": ErrorResponse},
|
||||
},
|
||||
)
|
||||
def export_run(
|
||||
run_id: int = Path(description="要导出的测试运行 ID。", gt=0),
|
||||
execution_status: Literal["completed", "error"] | None = Query(default=None),
|
||||
verdict: Literal["pass", "fail", "needs_human_review", "judge_format_error", "none"]
|
||||
| None = Query(default=None),
|
||||
db: Session = Depends(get_database),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
run = TestRunRepository(db).get(run_id)
|
||||
if not run:
|
||||
raise NotFoundError("测试运行不存在")
|
||||
if run.status not in TERMINAL_STATUSES:
|
||||
raise ConflictError("只能导出已结束的测试运行")
|
||||
results = [
|
||||
item.model_dump()
|
||||
for item in get_run_results(run_id, db, settings)
|
||||
if matches_result(item.model_dump(), execution_status, verdict)
|
||||
]
|
||||
content = render_markdown(_run_detail(run).model_dump(mode="json"), results)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/markdown",
|
||||
headers={"Content-Disposition": f'attachment; filename="run_{run_id}_results.md"'},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def block(value: Any) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
|
||||
longest = max((len(part) for part in re.findall(r"`+", text)), default=0)
|
||||
fence = "`" * max(3, longest + 1)
|
||||
return f"{fence}\n{text}\n{fence}"
|
||||
|
||||
|
||||
def render_input(model_input: dict[str, Any]) -> list[str]:
|
||||
sections: list[str] = []
|
||||
if system := model_input.get("system"):
|
||||
sections.extend(["#### System", "", block(system), ""])
|
||||
for message in model_input.get("messages", []):
|
||||
role = str(message.get("role", "unknown")).capitalize()
|
||||
sections.extend([f"#### {role}", "", block(message.get("content", "")), ""])
|
||||
extras = {
|
||||
key: value
|
||||
for key, value in model_input.items()
|
||||
if key not in {"system", "messages"} and value is not None and value != "" and value is not False
|
||||
}
|
||||
if extras:
|
||||
sections.extend(["#### 其他输入信息", "", block(extras), ""])
|
||||
return sections or ["无", ""]
|
||||
|
||||
|
||||
def matches_result(
|
||||
result: dict[str, Any], execution_status: str | None, verdict: str | None
|
||||
) -> bool:
|
||||
expected_verdict = None if verdict == "none" else verdict
|
||||
return (execution_status is None or result.get("execution_status") == execution_status) and (
|
||||
verdict is None or result.get("verdict") == expected_verdict
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(run: dict[str, Any], results: list[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
f"# Run {run['run_id']} 安全测试详细报告",
|
||||
"",
|
||||
"## 运行概况",
|
||||
"",
|
||||
f"- 状态:{run.get('status', 'unknown')}",
|
||||
f"- 阶段:{run.get('phase', 'unknown')}",
|
||||
f"- 样例总数:{run.get('selected_count', len(results))}",
|
||||
f"- 已完成:{run.get('completed_count', 0)}",
|
||||
f"- 执行错误:{run.get('error_count', 0)}",
|
||||
f"- 开始时间:{run.get('started_at', '')}",
|
||||
f"- 完成时间:{run.get('finished_at', '')}",
|
||||
"",
|
||||
f"- 本报告导出样例:{len(results)}",
|
||||
"",
|
||||
"## 测试样例详情",
|
||||
"",
|
||||
]
|
||||
for index, result in enumerate(results, 1):
|
||||
judge = result.get("judge_result") or {}
|
||||
lines.extend(
|
||||
[
|
||||
f"### {index}. {result.get('execution_id', 'unknown')}",
|
||||
"",
|
||||
f"- 测试类型:{result.get('case_kind', 'unknown')}",
|
||||
f"- 交互模式:{result.get('interaction_mode', 'unknown')}",
|
||||
f"- 执行状态:{result.get('execution_status', 'unknown')}",
|
||||
f"- 仲裁结论:{result.get('verdict') or '未仲裁'}",
|
||||
"",
|
||||
"#### 模型输入",
|
||||
"",
|
||||
*render_input(result.get("model_input") or {}),
|
||||
"#### 模型输出",
|
||||
"",
|
||||
block(result.get("model_response", "")),
|
||||
"",
|
||||
"#### 仲裁结果",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if judge:
|
||||
lines.extend(
|
||||
[
|
||||
f"- 结论:{judge.get('verdict', result.get('verdict', 'unknown'))}",
|
||||
f"- 评分:{judge.get('score', '无')}",
|
||||
f"- 理由:{judge.get('reason', '无')}",
|
||||
]
|
||||
)
|
||||
if raw := judge.get("raw_response"):
|
||||
lines.extend(["", "##### 仲裁原始输出", "", block(raw)])
|
||||
else:
|
||||
lines.append("未启用自动仲裁或没有仲裁结果。")
|
||||
if error := result.get("error_message"):
|
||||
lines.extend(["", "#### 执行错误", "", block(error)])
|
||||
lines.extend(["", "---", ""])
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
@@ -78,8 +78,9 @@ Swagger 也可直接测试:打开 `/docs` 后,点击右上角 **Authorize**
|
||||
6. `GET /api/v1/runs`
|
||||
7. `GET /api/v1/runs/{run_id}`
|
||||
8. `GET /api/v1/runs/{run_id}/results`
|
||||
9. `GET /api/v1/reports`
|
||||
10. `GET /api/v1/reports/{run_id}`
|
||||
9. `GET /api/v1/runs/{run_id}/export`
|
||||
10. `GET /api/v1/reports`
|
||||
11. `GET /api/v1/reports/{run_id}`
|
||||
|
||||
创建运行的网络重试应复用同一个 `Idempotency-Key` 请求头;同一键与同一请求只会创建一个运行。
|
||||
|
||||
@@ -105,7 +106,16 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs \
|
||||
|
||||
报告不单独保存:`POST /runs` 创建运行后即可查询实时报告,结果变化时报告自动重算,因此没有报告 POST/PATCH。删除终态 run 会同时删除其结果,之后对应报告返回 404。
|
||||
|
||||
运行结束后可将详细结果导出为 Markdown:
|
||||
运行结束后可通过 API 将详细结果下载为 Markdown:
|
||||
|
||||
```bash
|
||||
curl -OJ -H 'Authorization: Bearer <access_token>' \
|
||||
'http://127.0.0.1:8000/api/v1/runs/42/export?execution_status=completed&verdict=fail'
|
||||
```
|
||||
|
||||
不传查询参数时导出全部样例。`execution_status` 接受 `completed|error`,`verdict` 接受 `pass|fail|needs_human_review|judge_format_error|none`;运行不存在返回 404,尚未结束返回 409,非法过滤值返回 422。
|
||||
|
||||
服务器命令行也可导出:
|
||||
|
||||
```bash
|
||||
uv run python scripts/export_run.py --run-id 42
|
||||
|
||||
@@ -128,7 +128,21 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs/42/results/R0049/retry
|
||||
|
||||
### 导出 Markdown 详细报告
|
||||
|
||||
`scripts/export_run.py` 通过 API 登录并导出一个终态 Run。默认包含全部逐条结果:
|
||||
`GET /api/v1/runs/{run_id}/export` 将终态 Run 下载为 Markdown 附件,默认包含全部逐条结果:
|
||||
|
||||
```bash
|
||||
curl -OJ -H 'Authorization: Bearer <access_token>' \
|
||||
http://127.0.0.1:8000/api/v1/runs/42/export
|
||||
```
|
||||
|
||||
API 接受两个可组合的查询参数:
|
||||
|
||||
- `execution_status=completed|error`:按执行状态导出。
|
||||
- `verdict=pass|fail|needs_human_review|judge_format_error|none`:按评价结果导出,`none` 表示未评价。
|
||||
|
||||
运行不存在返回 404,尚未结束返回 409,非法参数返回 422。响应的 `Content-Disposition` 文件名为 `run_<run_id>_results.md`。
|
||||
|
||||
`scripts/export_run.py` 提供相同过滤能力,适合服务器命令行操作:
|
||||
|
||||
```bash
|
||||
uv run python scripts/export_run.py \
|
||||
@@ -189,6 +203,8 @@ OC 未泄露率。低于阻断线为 `NOT_READY`,低于目标线为 `CONDITION
|
||||
| `test_provider_writes_require_admin` | provider 写操作管理员授权 |
|
||||
| `test_provider_contracts` | `POST /providers/{provider_id}/check`、`GET /providers/{provider_id}/models` |
|
||||
| `test_run_and_report_contracts` | `POST/GET /runs`、`POST /runs/{id}/resume`、`GET /runs/{id}`、`GET /runs/{id}/results`、`GET /reports`、`GET /reports/{id}` |
|
||||
| `test_export_run_markdown_defaults_to_all_and_filters_results` | `GET /runs/{id}/export` 的 Markdown 附件、默认全量与组合过滤 |
|
||||
| `test_export_run_requires_terminal_run_and_valid_filters` | 导出接口的 404、409 与 422 边界 |
|
||||
| `test_reports_are_derived_read_only_resources` | 报告 404 及不开放独立 POST/PATCH/DELETE 的只读边界 |
|
||||
| `test_run_cancel_and_delete_contract` | `PATCH/DELETE /runs/{id}` 的取消、终态与删除规则 |
|
||||
| `test_retry_errors_keeps_successes_and_requeues_only_errors` | resume/retry-errors 分工、成功结果保留与错误重排队 |
|
||||
|
||||
+3
-99
@@ -4,13 +4,14 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from app.services.export_service import matches_result, render_markdown
|
||||
|
||||
|
||||
def request_json(
|
||||
base_url: str,
|
||||
@@ -41,97 +42,6 @@ def request_json(
|
||||
raise RuntimeError(f"无法连接服务:{exc.reason}") from exc
|
||||
|
||||
|
||||
def block(value: Any) -> str:
|
||||
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
|
||||
longest = max((len(part) for part in re.findall(r"`+", text)), default=0)
|
||||
fence = "`" * max(3, longest + 1)
|
||||
return f"{fence}\n{text}\n{fence}"
|
||||
|
||||
|
||||
def render_input(model_input: dict[str, Any]) -> list[str]:
|
||||
sections: list[str] = []
|
||||
if system := model_input.get("system"):
|
||||
sections.extend(["#### System", "", block(system), ""])
|
||||
for message in model_input.get("messages", []):
|
||||
role = str(message.get("role", "unknown")).capitalize()
|
||||
sections.extend([f"#### {role}", "", block(message.get("content", "")), ""])
|
||||
extras = {
|
||||
key: value
|
||||
for key, value in model_input.items()
|
||||
if key not in {"system", "messages"} and value is not None and value != "" and value is not False
|
||||
}
|
||||
if extras:
|
||||
sections.extend(["#### 其他输入信息", "", block(extras), ""])
|
||||
return sections or ["无", ""]
|
||||
|
||||
|
||||
def matches_result(
|
||||
result: dict[str, Any], execution_status: str | None, verdict: str | None
|
||||
) -> bool:
|
||||
return (execution_status is None or result.get("execution_status") == execution_status) and (
|
||||
verdict is None or result.get("verdict") == verdict
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(run: dict[str, Any], results: list[dict[str, Any]]) -> str:
|
||||
lines = [
|
||||
f"# Run {run['run_id']} 安全测试详细报告",
|
||||
"",
|
||||
"## 运行概况",
|
||||
"",
|
||||
f"- 状态:{run.get('status', 'unknown')}",
|
||||
f"- 阶段:{run.get('phase', 'unknown')}",
|
||||
f"- 样例总数:{run.get('selected_count', len(results))}",
|
||||
f"- 已完成:{run.get('completed_count', 0)}",
|
||||
f"- 执行错误:{run.get('error_count', 0)}",
|
||||
f"- 开始时间:{run.get('started_at', '')}",
|
||||
f"- 完成时间:{run.get('finished_at', '')}",
|
||||
"",
|
||||
f"- 本报告导出样例:{len(results)}",
|
||||
"",
|
||||
"## 测试样例详情",
|
||||
"",
|
||||
]
|
||||
for index, result in enumerate(results, 1):
|
||||
judge = result.get("judge_result") or {}
|
||||
lines.extend(
|
||||
[
|
||||
f"### {index}. {result.get('execution_id', 'unknown')}",
|
||||
"",
|
||||
f"- 测试类型:{result.get('case_kind', 'unknown')}",
|
||||
f"- 交互模式:{result.get('interaction_mode', 'unknown')}",
|
||||
f"- 执行状态:{result.get('execution_status', 'unknown')}",
|
||||
f"- 仲裁结论:{result.get('verdict') or '未仲裁'}",
|
||||
"",
|
||||
"#### 模型输入",
|
||||
"",
|
||||
*render_input(result.get("model_input") or {}),
|
||||
"#### 模型输出",
|
||||
"",
|
||||
block(result.get("model_response", "")),
|
||||
"",
|
||||
"#### 仲裁结果",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if judge:
|
||||
lines.extend(
|
||||
[
|
||||
f"- 结论:{judge.get('verdict', result.get('verdict', 'unknown'))}",
|
||||
f"- 评分:{judge.get('score', '无')}",
|
||||
f"- 理由:{judge.get('reason', '无')}",
|
||||
]
|
||||
)
|
||||
if raw := judge.get("raw_response"):
|
||||
lines.extend(["", "##### 仲裁原始输出", "", block(raw)])
|
||||
else:
|
||||
lines.append("未启用自动仲裁或没有仲裁结果。")
|
||||
if error := result.get("error_message"):
|
||||
lines.extend(["", "#### 执行错误", "", block(error)])
|
||||
lines.extend(["", "---", ""])
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="将已完成的 run 导出为 Markdown 报告")
|
||||
parser.add_argument("--run-id", type=int, default=7, help="要导出的 run ID(默认:7)")
|
||||
@@ -171,16 +81,10 @@ def main() -> int:
|
||||
results = request_json(
|
||||
args.base_url, f"/api/v1/runs/{args.run_id}/results", token=token
|
||||
)
|
||||
verdict = None if args.verdict is None else args.verdict
|
||||
results = [
|
||||
result
|
||||
for result in results
|
||||
if matches_result(
|
||||
result,
|
||||
args.execution_status,
|
||||
None if verdict == "none" else verdict,
|
||||
)
|
||||
and (verdict != "none" or result.get("verdict") is None)
|
||||
if matches_result(result, args.execution_status, args.verdict)
|
||||
]
|
||||
output = args.output or Path("outputs") / f"run_{args.run_id}_results.md"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -290,6 +290,7 @@ def test_expired_refresh_token_is_rejected(client):
|
||||
("post", "/api/v1/runs/1/results/R0001/retry"),
|
||||
("get", "/api/v1/runs/1"),
|
||||
("get", "/api/v1/runs/1/results"),
|
||||
("get", "/api/v1/runs/1/export"),
|
||||
("get", "/api/v1/reports"),
|
||||
("get", "/api/v1/reports/1"),
|
||||
],
|
||||
@@ -589,6 +590,74 @@ def test_run_and_report_contracts(client, auth_headers):
|
||||
assert report.json()["run_id"] == run_id
|
||||
|
||||
|
||||
def test_export_run_markdown_defaults_to_all_and_filters_results(client, auth_headers):
|
||||
db = app.state.session_factory()
|
||||
run = RunRepository(db).create(run_type="safety_test", profile="all", selected_count=3)
|
||||
run.completed_count = 2
|
||||
run.error_count = 1
|
||||
RunRepository(db).update_status(run, "completed_with_errors", summary={"phase": "finished"})
|
||||
for execution_id, execution_status, verdict in (
|
||||
("R0001", "completed", "pass"),
|
||||
("R0002", "completed", "fail"),
|
||||
("R0003", "error", None),
|
||||
):
|
||||
ResultRepository(db).add(
|
||||
run_id=run.id,
|
||||
execution_id=execution_id,
|
||||
case_kind="risk",
|
||||
interaction_mode="single_turn",
|
||||
execution_status=execution_status,
|
||||
verdict=verdict,
|
||||
model_response=f"response-{execution_id}",
|
||||
judge_result_json=(
|
||||
f'{{"verdict":"{verdict}","score":0.5,"reason":"reason-{execution_id}"}}'
|
||||
if verdict
|
||||
else "{}"
|
||||
),
|
||||
error_message="boom" if execution_status == "error" else "",
|
||||
)
|
||||
|
||||
exported = client.get(f"/api/v1/runs/{run.id}/export", headers=auth_headers)
|
||||
|
||||
assert exported.status_code == 200
|
||||
assert exported.headers["content-type"].startswith("text/markdown")
|
||||
assert exported.headers["content-disposition"] == f'attachment; filename="run_{run.id}_results.md"'
|
||||
assert all(execution_id in exported.text for execution_id in ("R0001", "R0002", "R0003"))
|
||||
|
||||
failed = client.get(
|
||||
f"/api/v1/runs/{run.id}/export",
|
||||
params={"execution_status": "completed", "verdict": "fail"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert failed.status_code == 200
|
||||
assert "R0002" in failed.text
|
||||
assert "R0001" not in failed.text
|
||||
assert "R0003" not in failed.text
|
||||
|
||||
unjudged = client.get(
|
||||
f"/api/v1/runs/{run.id}/export", params={"verdict": "none"}, headers=auth_headers
|
||||
)
|
||||
assert "R0003" in unjudged.text
|
||||
assert "R0001" not in unjudged.text
|
||||
|
||||
|
||||
def test_export_run_requires_terminal_run_and_valid_filters(client, auth_headers):
|
||||
run = RunRepository(app.state.session_factory()).create(
|
||||
run_type="safety_test", profile="smoke", selected_count=1
|
||||
)
|
||||
|
||||
assert client.get(f"/api/v1/runs/{run.id}/export", headers=auth_headers).status_code == 409
|
||||
assert client.get("/api/v1/runs/999/export", headers=auth_headers).status_code == 404
|
||||
assert (
|
||||
client.get(
|
||||
f"/api/v1/runs/{run.id}/export",
|
||||
params={"execution_status": "invalid"},
|
||||
headers=auth_headers,
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
def test_reports_are_derived_read_only_resources(client, auth_headers):
|
||||
assert client.get("/api/v1/reports/999", headers=auth_headers).status_code == 404
|
||||
assert client.post("/api/v1/reports", headers=auth_headers).status_code == 405
|
||||
|
||||
Reference in New Issue
Block a user