diff --git a/.coverage b/.coverage index 897994e..d75c75e 100644 Binary files a/.coverage and b/.coverage differ diff --git a/README.md b/README.md index d9411ce..8142896 100644 --- a/README.md +++ b/README.md @@ -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 ' \ + 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__results.md`。 +API 查询参数为 `execution_status` 和 `verdict`,脚本对应选项为 `--execution-status` 和 `--verdict`。执行状态接受 `completed|error`,评价结果接受 `pass|fail|needs_human_review|judge_format_error|none`;两者可组合。API 返回附件 `run__results.md`,脚本默认写入 `outputs/` 目录。 ## 快速启动 diff --git a/app/api/v1/endpoints/runs.py b/app/api/v1/endpoints/runs.py index 058e7d1..c013776 100644 --- a/app/api/v1/endpoints/runs.py +++ b/app/api/v1/endpoints/runs.py @@ -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"'}, + ) diff --git a/app/services/export_service.py b/app/services/export_service.py new file mode 100644 index 0000000..cc2efff --- /dev/null +++ b/app/services/export_service.py @@ -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" diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index aee117d..892d97c 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -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 ' \ + '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 diff --git a/docs/tutorial/operations.md b/docs/tutorial/operations.md index 330324d..2eb0245 100644 --- a/docs/tutorial/operations.md +++ b/docs/tutorial/operations.md @@ -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 ' \ + 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__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 分工、成功结果保留与错误重排队 | diff --git a/scripts/export_run.py b/scripts/export_run.py index 751ae4a..ecfb4e3 100644 --- a/scripts/export_run.py +++ b/scripts/export_run.py @@ -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) diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py index 9ebbc43..67a2fba 100644 --- a/tests/test_api_contracts.py +++ b/tests/test_api_contracts.py @@ -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