feature: /api/v1/runs/{run_id}/export 按照需求导出测试结果

This commit is contained in:
baozaotumao2025
2026-07-18 23:49:41 +08:00
parent c57ec67fe3
commit e980f1a349
8 changed files with 245 additions and 106 deletions
BIN
View File
Binary file not shown.
+10 -2
View File
@@ -27,6 +27,7 @@
| 取消测试运行 | `PATCH /api/v1/runs/{run_id}` | | 取消测试运行 | `PATCH /api/v1/runs/{run_id}` |
| 删除终态运行 | `DELETE /api/v1/runs/{run_id}` | | 删除终态运行 | `DELETE /api/v1/runs/{run_id}` |
| 查询测试结果 | `GET /api/v1/runs/{run_id}/results` | | 查询测试结果 | `GET /api/v1/runs/{run_id}/results` |
| 导出 Markdown 详细报告 | `GET /api/v1/runs/{run_id}/export` |
| 列出汇总报告 | `GET /api/v1/reports` | | 列出汇总报告 | `GET /api/v1/reports` |
| 获取汇总报告 | `GET /api/v1/reports/{run_id}` | | 获取汇总报告 | `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 中只重跑指定结果。 逐条结果用 `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 隐式产生,不单独 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 ```bash
uv run python scripts/export_run.py --run-id 42 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/` 目录
## 快速启动 ## 快速启动
+38 -1
View File
@@ -1,7 +1,7 @@
import json import json
import logging import logging
from hashlib import sha256 from hashlib import sha256
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Path, Query, Response, status 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.capability_service import CapabilityProbeService
from app.services.dataset_service import DatasetGateway, TestPlanService 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 from app.services.test_execution_service import TestExecutionService
router = APIRouter() router = APIRouter()
@@ -519,3 +520,39 @@ def get_run_results(
) )
for row in rows 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"'},
)
+95
View File
@@ -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"
+13 -3
View File
@@ -78,8 +78,9 @@ Swagger 也可直接测试:打开 `/docs` 后,点击右上角 **Authorize**
6. `GET /api/v1/runs` 6. `GET /api/v1/runs`
7. `GET /api/v1/runs/{run_id}` 7. `GET /api/v1/runs/{run_id}`
8. `GET /api/v1/runs/{run_id}/results` 8. `GET /api/v1/runs/{run_id}/results`
9. `GET /api/v1/reports` 9. `GET /api/v1/runs/{run_id}/export`
10. `GET /api/v1/reports/{run_id}` 10. `GET /api/v1/reports`
11. `GET /api/v1/reports/{run_id}`
创建运行的网络重试应复用同一个 `Idempotency-Key` 请求头;同一键与同一请求只会创建一个运行。 创建运行的网络重试应复用同一个 `Idempotency-Key` 请求头;同一键与同一请求只会创建一个运行。
@@ -105,7 +106,16 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs \
报告不单独保存:`POST /runs` 创建运行后即可查询实时报告,结果变化时报告自动重算,因此没有报告 POST/PATCH。删除终态 run 会同时删除其结果,之后对应报告返回 404。 报告不单独保存:`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 ```bash
uv run python scripts/export_run.py --run-id 42 uv run python scripts/export_run.py --run-id 42
+17 -1
View File
@@ -128,7 +128,21 @@ curl -X POST http://127.0.0.1:8000/api/v1/runs/42/results/R0049/retry
### 导出 Markdown 详细报告 ### 导出 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 ```bash
uv run python scripts/export_run.py \ uv run python scripts/export_run.py \
@@ -189,6 +203,8 @@ OC 未泄露率。低于阻断线为 `NOT_READY`,低于目标线为 `CONDITION
| `test_provider_writes_require_admin` | provider 写操作管理员授权 | | `test_provider_writes_require_admin` | provider 写操作管理员授权 |
| `test_provider_contracts` | `POST /providers/{provider_id}/check``GET /providers/{provider_id}/models` | | `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_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_reports_are_derived_read_only_resources` | 报告 404 及不开放独立 POST/PATCH/DELETE 的只读边界 |
| `test_run_cancel_and_delete_contract` | `PATCH/DELETE /runs/{id}` 的取消、终态与删除规则 | | `test_run_cancel_and_delete_contract` | `PATCH/DELETE /runs/{id}` 的取消、终态与删除规则 |
| `test_retry_errors_keeps_successes_and_requeues_only_errors` | resume/retry-errors 分工、成功结果保留与错误重排队 | | `test_retry_errors_keeps_successes_and_requeues_only_errors` | resume/retry-errors 分工、成功结果保留与错误重排队 |
+3 -99
View File
@@ -4,13 +4,14 @@
import argparse import argparse
import getpass import getpass
import json import json
import re
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from app.services.export_service import matches_result, render_markdown
def request_json( def request_json(
base_url: str, base_url: str,
@@ -41,97 +42,6 @@ def request_json(
raise RuntimeError(f"无法连接服务:{exc.reason}") from exc 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: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="将已完成的 run 导出为 Markdown 报告") parser = argparse.ArgumentParser(description="将已完成的 run 导出为 Markdown 报告")
parser.add_argument("--run-id", type=int, default=7, help="要导出的 run ID(默认:7") parser.add_argument("--run-id", type=int, default=7, help="要导出的 run ID(默认:7")
@@ -171,16 +81,10 @@ def main() -> int:
results = request_json( results = request_json(
args.base_url, f"/api/v1/runs/{args.run_id}/results", token=token args.base_url, f"/api/v1/runs/{args.run_id}/results", token=token
) )
verdict = None if args.verdict is None else args.verdict
results = [ results = [
result result
for result in results for result in results
if matches_result( if matches_result(result, args.execution_status, args.verdict)
result,
args.execution_status,
None if verdict == "none" else verdict,
)
and (verdict != "none" or result.get("verdict") is None)
] ]
output = args.output or Path("outputs") / f"run_{args.run_id}_results.md" output = args.output or Path("outputs") / f"run_{args.run_id}_results.md"
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
+69
View File
@@ -290,6 +290,7 @@ def test_expired_refresh_token_is_rejected(client):
("post", "/api/v1/runs/1/results/R0001/retry"), ("post", "/api/v1/runs/1/results/R0001/retry"),
("get", "/api/v1/runs/1"), ("get", "/api/v1/runs/1"),
("get", "/api/v1/runs/1/results"), ("get", "/api/v1/runs/1/results"),
("get", "/api/v1/runs/1/export"),
("get", "/api/v1/reports"), ("get", "/api/v1/reports"),
("get", "/api/v1/reports/1"), ("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 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): 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.get("/api/v1/reports/999", headers=auth_headers).status_code == 404
assert client.post("/api/v1/reports", headers=auth_headers).status_code == 405 assert client.post("/api/v1/reports", headers=auth_headers).status_code == 405