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
+38 -1
View File
@@ -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"'},
)
+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"