feature: 增加功能 导出详细报告
This commit is contained in:
@@ -23,6 +23,8 @@ docs/ # UI、交互、架构、API、测试与部署文档
|
|||||||
|
|
||||||
认证支持 access/refresh token 原子轮换、活跃用户静默续约、标签页内/跨标签页 single-flight,以及业务请求 401 后的一次续约与重放。跨标签页协调使用浏览器 Web Locks API。
|
认证支持 access/refresh token 原子轮换、活跃用户静默续约、标签页内/跨标签页 single-flight,以及业务请求 401 后的一次续约与重放。跨标签页协调使用浏览器 Web Locks API。
|
||||||
|
|
||||||
|
终态测试运行支持按执行状态和仲裁结论筛选并导出 Markdown 详细报告。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
需要 Node.js 20–24 和 Corepack。
|
需要 Node.js 20–24 和 Corepack。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| 提供商 | 列表、详情、创建、修改、删除 | `GET/POST /providers`, `GET/PATCH/DELETE /providers/{id}` |
|
| 提供商 | 列表、详情、创建、修改、删除 | `GET/POST /providers`, `GET/PATCH/DELETE /providers/{id}` |
|
||||||
| 提供商 | 连通检查、模型发现 | `POST /providers/{id}/check`, `GET /providers/{id}/models` |
|
| 提供商 | 连通检查、模型发现 | `POST /providers/{id}/check`, `GET /providers/{id}/models` |
|
||||||
| 运行 | 列表、创建、详情、取消、删除 | `GET/POST /runs`, `GET/PATCH/DELETE /runs/{id}` |
|
| 运行 | 列表、创建、详情、取消、删除 | `GET/POST /runs`, `GET/PATCH/DELETE /runs/{id}` |
|
||||||
| 运行 | 恢复、重试错误、单条重试、逐条结果 | `POST /runs/{id}/resume`, `POST /runs/{id}/retry-errors`, `POST /runs/{id}/results/{execution_id}/retry`, `GET /runs/{id}/results` |
|
| 运行 | 恢复、重试错误、单条重试、逐条结果、导出详细报告 | `POST /runs/{id}/resume`, `POST /runs/{id}/retry-errors`, `POST /runs/{id}/results/{execution_id}/retry`, `GET /runs/{id}/results`, `GET /runs/{id}/export` |
|
||||||
| 报告 | 列表、详情 | `GET /reports`, `GET /reports/{run_id}` |
|
| 报告 | 列表、详情 | `GET /reports`, `GET /reports/{run_id}` |
|
||||||
| 系统 | 健康状态 | `GET /health` |
|
| 系统 | 健康状态 | `GET /health` |
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
- 运行详情必须满足 `processed_count = completed_count + error_count`;`error_count` 仅统计执行异常,不包含 `verdict = fail`;终态必须有 `poll_after_seconds = 0`。
|
- 运行详情必须满足 `processed_count = completed_count + error_count`;`error_count` 仅统计执行异常,不包含 `verdict = fail`;终态必须有 `poll_after_seconds = 0`。
|
||||||
- 逐条结果使用 `execution_status`(`completed | error`)表示调用是否完成,使用可空 `verdict`(`pass | fail | needs_human_review | judge_format_error | null`)表示仲裁结论;前端不读取旧 `status`。
|
- 逐条结果使用 `execution_status`(`completed | error`)表示调用是否完成,使用可空 `verdict`(`pass | fail | needs_human_review | judge_format_error | null`)表示仲裁结论;前端不读取旧 `status`。
|
||||||
- 逐条结果的执行 ID 搜索直接使用 `GET /runs/{id}/results` 已返回的 `execution_id` 在 UI 过滤,不新增请求参数或后端端点。
|
- 逐条结果的执行 ID 搜索直接使用 `GET /runs/{id}/results` 已返回的 `execution_id` 在 UI 过滤,不新增请求参数或后端端点。
|
||||||
|
- `GET /runs/{id}/export` 仅允许终态 Run,返回 `text/markdown` 文件而非 JSON;可组合传入 `execution_status=completed|error` 与 `verdict=pass|fail|needs_human_review|judge_format_error|none`,其中 `none` 表示未评价。前端复用结果筛选器,但执行 ID 搜索不参与导出。
|
||||||
- 报告的 `summary.execution_statuses` 与 `summary.verdicts` 分开统计;`by_mode` 下也分别包含 `execution_statuses` 和 `verdicts`。
|
- 报告的 `summary.execution_statuses` 与 `summary.verdicts` 分开统计;`by_mode` 下也分别包含 `execution_statuses` 和 `verdicts`。
|
||||||
- `POST /runs/{id}/retry-errors` 只重试 `execution_status = error`,不重试任何仲裁结论。
|
- `POST /runs/{id}/retry-errors` 只重试 `execution_status = error`,不重试任何仲裁结论。
|
||||||
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。
|
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。
|
||||||
@@ -29,4 +30,4 @@
|
|||||||
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带同一共享生成器产生的 UUID v4 `Idempotency-Key`。
|
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带同一共享生成器产生的 UUID v4 `Idempotency-Key`。
|
||||||
- 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key;409 保留凭据并等待 `refresh_after`。
|
- 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key;409 保留凭据并等待 `refresh_after`。
|
||||||
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
|
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
|
||||||
- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。
|
- HTTP 204 按无响应体处理;Markdown 成功响应按 Blob 读取;错误响应仍按 JSON 归一化。403/404/409/422/502 映射为可操作的用户消息。
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
UI 页面只调用 `services/`;service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。
|
UI 页面只调用 `services/`;service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。
|
||||||
|
|
||||||
|
API 客户端默认读取并校验 JSON;文件端点显式选择 Blob 响应类型,同时复用 Bearer Token、401 单次续约重放和 JSON 错误归一化。下载由页面使用浏览器原生 Object URL 完成,不引入额外下载依赖。
|
||||||
|
|
||||||
创建运行和 Token 续约统一通过 `idempotencyKey.ts` 生成 UUID v4 幂等键:安全上下文优先使用浏览器原生 `crypto.randomUUID()`,HTTP 或旧浏览器回退到 `crypto.getRandomValues()`;不使用可预测的 `Math.random()`。
|
创建运行和 Token 续约统一通过 `idempotencyKey.ts` 生成 UUID v4 幂等键:安全上下文优先使用浏览器原生 `crypto.randomUUID()`,HTTP 或旧浏览器回退到 `crypto.getRandomValues()`;不使用可预测的 `Math.random()`。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
3. 管理员在模型配置页建立 target/judge 配置,可查询模型并执行最小连通性检查。
|
3. 管理员在模型配置页建立 target/judge 配置,可查询模型并执行最小连通性检查。
|
||||||
4. 用户选择 smoke/all 与是否自动裁判,确认后创建运行。客户端自动携带 UUID 幂等键,避免重复提交。
|
4. 用户选择 smoke/all 与是否自动裁判,确认后创建运行。客户端自动携带 UUID 幂等键,避免重复提交。
|
||||||
5. 详情页展示探测、执行、裁判和完成阶段,按后端 `poll_after_seconds` 轮询。
|
5. 详情页展示探测、执行、裁判和完成阶段,按后端 `poll_after_seconds` 轮询。
|
||||||
6. 运行中可取消;中断后可恢复;局部错误可只重试错误样例;终态后可查看逐条结果、汇总报告或确认删除。
|
6. 运行中可取消;中断后可恢复;局部错误可只重试错误样例;终态后可查看逐条结果、汇总报告、导出 Markdown 详细报告或确认删除。
|
||||||
`cancelled / failed / pending / probing / running` 可确认后“恢复运行”;`completed_with_errors` 且 `error_count > 0` 可确认后“重试错误(N)”。提交期间按钮禁用,成功提示跳过或重试数量;409 会刷新详情以校正操作状态。
|
`cancelled / failed / pending / probing / running` 可确认后“恢复运行”;`completed_with_errors` 且 `error_count > 0` 可确认后“重试错误(N)”。提交期间按钮禁用,成功提示跳过或重试数量;409 会刷新详情以校正操作状态。
|
||||||
7. 逐条结果可按执行状态、仲裁结论筛选,并可按执行 ID 搜索;三项条件组合生效,执行 ID 支持忽略大小写的片段匹配。
|
7. 逐条结果可按执行状态、仲裁结论筛选,并可按执行 ID 搜索;三项条件组合生效,执行 ID 支持忽略大小写的片段匹配。
|
||||||
8. `completed / completed_with_errors` 的每条结果可确认后单独“重新执行”。提交中仅该按钮显示“提交中…”,受理后该 Run 的全部单条重试按钮锁定并显示目标执行 ID“重新执行中…”,直到 Run 再次进入终态。目标结果会进入跟踪状态并置顶,暂时绕过执行状态、仲裁结论和执行 ID 筛选,用户可手动停止跟踪;原筛选值始终保留。
|
8. `completed / completed_with_errors` 的每条结果可确认后单独“重新执行”。提交中仅该按钮显示“提交中…”,受理后该 Run 的全部单条重试按钮锁定并显示目标执行 ID“重新执行中…”,直到 Run 再次进入终态。目标结果会进入跟踪状态并置顶,暂时绕过执行状态、仲裁结论和执行 ID 筛选,用户可手动停止跟踪;原筛选值始终保留。
|
||||||
|
9. “导出详细报告”位于逐条结果筛选栏,复用当前执行状态和仲裁结论筛选,不占用页头运行控制区;“全部”不传参数,“未仲裁”映射为 `verdict=none`,执行 ID 搜索不参与导出。非终态按钮禁用,导出期间显示加载状态并阻止重复提交,文件名为 `run_{run_id}_results.md`;409 会刷新运行详情。
|
||||||
|
|
||||||
## 模型等待交互
|
## 模型等待交互
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
|
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、JSON/Blob API 响应、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、终态 Markdown 导出、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
- 运行详情:阶段、进度、计数和当前样例位于首屏,结果和报告位于下方标签页。
|
- 运行详情:阶段、进度、计数和当前样例位于首屏,结果和报告位于下方标签页。
|
||||||
- 结果卡片:顶部使用统一 Tag 明确标识“类型 / 模式 / 执行状态 / 仲裁结论”,将后端原始值转为中文语义。执行状态和仲裁结论使用两个独立筛选器,不混合 `completed/error` 与 `pass/fail`;执行 ID 搜索与两个筛选器组合生效,并支持忽略大小写的片段匹配。所有标签使用相同的 `8px` 圆角;结论只增加字重和状态点。通过为绿色、未通过或错误为红色、待复核为橙色,文字始终保留。
|
- 结果卡片:顶部使用统一 Tag 明确标识“类型 / 模式 / 执行状态 / 仲裁结论”,将后端原始值转为中文语义。执行状态和仲裁结论使用两个独立筛选器,不混合 `completed/error` 与 `pass/fail`;执行 ID 搜索与两个筛选器组合生效,并支持忽略大小写的片段匹配。所有标签使用相同的 `8px` 圆角;结论只增加字重和状态点。通过为绿色、未通过或错误为红色、待复核为橙色,文字始终保留。
|
||||||
- 结果卡片在 Run 完成或部分错误时提供“重新执行”;使用 Ant Design 确认框说明结果将被替换。提交、运行中状态以按钮文字和信息提示表达,不只依赖颜色;Run 非终态时同 Run 的全部单条重试按钮禁用。重试目标使用信息提示说明跟踪状态并置顶显示,不修改用户已有筛选;提示提供“停止跟踪”按钮。
|
- 结果卡片在 Run 完成或部分错误时提供“重新执行”;使用 Ant Design 确认框说明结果将被替换。提交、运行中状态以按钮文字和信息提示表达,不只依赖颜色;Run 非终态时同 Run 的全部单条重试按钮禁用。重试目标使用信息提示说明跟踪状态并置顶显示,不修改用户已有筛选;提示提供“停止跟踪”按钮。
|
||||||
|
- Run 详情页在逐条结果筛选栏提供 Markdown 详细报告导出,复用执行状态和仲裁结论筛选,不复制第二组筛选器,也不占用页头运行控制区;非终态保留禁用按钮以说明能力,加载状态使用按钮文字和图标表达。执行 ID 搜索只影响页面结果,不影响导出。
|
||||||
|
|
||||||
## 响应式与可访问性
|
## 响应式与可访问性
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -5,6 +5,7 @@ import { logger } from '../logging'
|
|||||||
export type RequestOptions<T> = Omit<RequestInit, 'body'> & {
|
export type RequestOptions<T> = Omit<RequestInit, 'body'> & {
|
||||||
body?: unknown
|
body?: unknown
|
||||||
schema?: ZodType<T>
|
schema?: ZodType<T>
|
||||||
|
responseType?: 'json' | 'blob'
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
@@ -22,26 +23,27 @@ export class ApiClient {
|
|||||||
private async execute<T>(path: string, options: RequestOptions<T>, replayed: boolean): Promise<T> {
|
private async execute<T>(path: string, options: RequestOptions<T>, replayed: boolean): Promise<T> {
|
||||||
const token = this.getToken()
|
const token = this.getToken()
|
||||||
const headers = new Headers(options.headers)
|
const headers = new Headers(options.headers)
|
||||||
headers.set('Accept', 'application/json')
|
headers.set('Accept', options.responseType === 'blob' ? 'text/markdown' : 'application/json')
|
||||||
if (options.body !== undefined) headers.set('Content-Type', 'application/json')
|
if (options.body !== undefined) headers.set('Content-Type', 'application/json')
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { responseType, schema, ...requestOptions } = options
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
...options,
|
...requestOptions,
|
||||||
headers,
|
headers,
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
})
|
})
|
||||||
if (response.status === 204) return undefined as T
|
if (response.status === 204) return undefined as T
|
||||||
const body = await response.json().catch(() => null)
|
const body = response.ok && responseType === 'blob' ? await response.blob() : await response.json().catch(() => null)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401 && !replayed && await this.refreshAuth?.(true)) {
|
if (response.status === 401 && !replayed && await this.refreshAuth?.(true)) {
|
||||||
return this.execute(path, options, true)
|
return this.execute(path, options, true)
|
||||||
}
|
}
|
||||||
throw { status: response.status, body }
|
throw { status: response.status, body }
|
||||||
}
|
}
|
||||||
if (!options.schema) return body as T
|
if (!schema) return body as T
|
||||||
const parsed = options.schema.safeParse(body)
|
const parsed = schema.safeParse(body)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError('服务响应格式异常', 'validation', response.status, 'INVALID_RESPONSE', undefined, {
|
throw new AppError('服务响应格式异常', 'validation', response.status, 'INVALID_RESPONSE', undefined, {
|
||||||
issues: parsed.error.issues,
|
issues: parsed.error.issues,
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ export function RunDetailPage() {
|
|||||||
message.error(status === undefined || (typeof status === 'number' && status >= 500) ? '重新执行提交失败,请稍后重试' : error.message)
|
message.error(status === undefined || (typeof status === 'number' && status >= 500) ? '重新执行提交失败,请稍后重试' : error.message)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const exportReport = useMutation({
|
||||||
|
mutationFn: () => services.runs.export(id, {
|
||||||
|
executionStatus: executionFilter === 'all' ? undefined : executionFilter as 'completed' | 'error',
|
||||||
|
verdict: verdictFilter === 'all' ? undefined : verdictFilter === 'unjudged' ? 'none' : verdictFilter as 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error',
|
||||||
|
}),
|
||||||
|
onSuccess: (blob) => {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `run_${id}_results.md`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
message.error(error.message)
|
||||||
|
if ('status' in error && error.status === 409) refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
const action = useMutation({
|
const action = useMutation({
|
||||||
mutationFn: async (name: 'cancel' | 'resume' | 'retry' | 'delete') => {
|
mutationFn: async (name: 'cancel' | 'resume' | 'retry' | 'delete') => {
|
||||||
if (name === 'cancel') return services.runs.cancel(id)
|
if (name === 'cancel') return services.runs.cancel(id)
|
||||||
@@ -83,16 +101,16 @@ export function RunDetailPage() {
|
|||||||
<div className="stat-strip"><span><strong>{data.processed_count}</strong>已处理</span><span><strong>{data.completed_count}</strong>已完成</span><span><strong>{data.error_count}</strong>执行错误</span><span><strong>{data.selected_count}</strong>总样例</span></div>
|
<div className="stat-strip"><span><strong>{data.processed_count}</strong>已处理</span><span><strong>{data.completed_count}</strong>已完成</span><span><strong>{data.error_count}</strong>执行错误</span><span><strong>{data.selected_count}</strong>总样例</span></div>
|
||||||
</Card>
|
</Card>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
|
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} canExport={data.terminal} exporting={exportReport.isPending} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onExport={() => exportReport.mutate()} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
|
||||||
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
|
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
|
||||||
]} />
|
]} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, retryingExecutionId, followedExecutionId, submittingExecutionId, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
|
function ResultsPanel({ rows, executionFilter, verdictFilter, executionIdQuery, onExecutionFilter, onVerdictFilter, onExecutionIdQuery, loading, canRetry, canExport, exporting, retryingExecutionId, followedExecutionId, submittingExecutionId, onExport, onStopFollowing, onRetry }: { rows: ResultItem[]; executionFilter: string; verdictFilter: string; executionIdQuery: string; onExecutionFilter: (value: string) => void; onVerdictFilter: (value: string) => void; onExecutionIdQuery: (value: string) => void; loading: boolean; canRetry: boolean; canExport: boolean; exporting: boolean; retryingExecutionId: string | null; followedExecutionId: string | null; submittingExecutionId: string | null; onExport: () => void; onStopFollowing: () => void; onRetry: (executionId: string) => void }) {
|
||||||
if (loading) return <LoadingState />
|
if (loading) return <LoadingState />
|
||||||
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /></div>
|
return <div className="results-panel"><div className="action-group"><Select aria-label="执行状态" value={executionFilter} onChange={onExecutionFilter} labelRender={({ label }) => `执行状态:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'completed', label: '已完成' }, { value: 'error', label: '执行错误' }]} /><Select aria-label="仲裁结论" value={verdictFilter} onChange={onVerdictFilter} labelRender={({ label }) => `仲裁结论:${label}`} options={[{ value: 'all', label: '全部' }, { value: 'pass', label: '通过' }, { value: 'fail', label: '不通过' }, { value: 'needs_human_review', label: '人工复核' }, { value: 'judge_format_error', label: '裁判格式错误' }, { value: 'unjudged', label: '未仲裁' }]} /><Input type="search" allowClear aria-label="执行 ID" placeholder="根据执行 ID 搜索,如 R0029" value={executionIdQuery} onChange={(event) => onExecutionIdQuery(event.target.value)} /><Button type="primary" disabled={!canExport} loading={exporting} title={canExport ? undefined : '运行结束后可导出'} onClick={onExport}>导出详细报告</Button></div>
|
||||||
{followedExecutionId && <Alert type="info" showIcon message={retryingExecutionId ? `${followedExecutionId} 重新执行中…` : `正在跟踪 ${followedExecutionId};即使不符合当前筛选也会置顶显示。`} action={<Button size="small" onClick={onStopFollowing}>停止跟踪</Button>} />}
|
{followedExecutionId && <Alert type="info" showIcon message={retryingExecutionId ? `${followedExecutionId} 重新执行中…` : `正在跟踪 ${followedExecutionId};即使不符合当前筛选也会置顶显示。`} action={<Button size="small" onClick={onStopFollowing}>停止跟踪</Button>} />}
|
||||||
{!rows.length ? <Empty description={followedExecutionId ? '正在等待跟踪结果…' : '暂无符合条件的结果'} /> : rows.map((row) => <Card key={row.execution_id} className="result-card"><div className="result-title"><strong>{row.execution_id}</strong><span className="result-tags" aria-label="结果标识">{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => <Tag key={tag.label} className={`result-tag-${tag.color}${tag.emphasis ? ' result-verdict' : ''}`}>{tag.label}</Tag>)}</span><Button disabled={!canRetry || Boolean(retryingExecutionId) || Boolean(submittingExecutionId)} loading={submittingExecutionId === row.execution_id} onClick={() => onRetry(row.execution_id)} aria-label={submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : `重新执行 ${row.execution_id}`}>{submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : '重新执行'}</Button></div><div className="result-columns"><section><h3>模型输入</h3><pre>{JSON.stringify(row.model_input, null, 2)}</pre></section><section><h3>模型回复</h3><pre>{row.model_response || '-'}</pre></section></div>{Object.keys(row.judge_result).length > 0 && <section className="judge-result"><h3>Judge 结果</h3><pre>{JSON.stringify(row.judge_result, null, 2)}</pre></section>}{row.error_message && <Alert type="error" message={row.error_message} />}</Card>)}</div>
|
{!rows.length ? <Empty description={followedExecutionId ? '正在等待跟踪结果…' : '暂无符合条件的结果'} /> : rows.map((row) => <Card key={row.execution_id} className="result-card"><div className="result-title"><strong>{row.execution_id}</strong><span className="result-tags" aria-label="结果标识">{resultTags({ caseKind: row.case_kind, mode: row.interaction_mode, executionStatus: row.execution_status, verdict: row.verdict }).map((tag) => <Tag key={tag.label} className={`result-tag-${tag.color}${tag.emphasis ? ' result-verdict' : ''}`}>{tag.label}</Tag>)}</span><Button disabled={!canRetry || Boolean(retryingExecutionId) || Boolean(submittingExecutionId)} loading={submittingExecutionId === row.execution_id} onClick={() => onRetry(row.execution_id)} aria-label={submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : `重新执行 ${row.execution_id}`}>{submittingExecutionId === row.execution_id ? '提交中…' : retryingExecutionId === row.execution_id ? '重新执行中…' : '重新执行'}</Button></div><div className="result-columns"><section><h3>模型输入</h3><pre>{JSON.stringify(row.model_input, null, 2)}</pre></section><section><h3>模型回复</h3><pre>{row.model_response || '-'}</pre></section></div>{Object.keys(row.judge_result).length > 0 && <section className="judge-result"><h3>Judge 结果</h3><pre>{JSON.stringify(row.judge_result, null, 2)}</pre></section>}{row.error_message && <Alert type="error" message={row.error_message} />}</Card>)}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { ApiClient } from '../api/client'
|
|||||||
import { createIdempotencyKey } from '../idempotencyKey'
|
import { createIdempotencyKey } from '../idempotencyKey'
|
||||||
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
|
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
|
||||||
|
|
||||||
|
type ExportFilters = { executionStatus?: 'completed' | 'error'; verdict?: 'pass' | 'fail' | 'needs_human_review' | 'judge_format_error' | 'none' }
|
||||||
|
|
||||||
export const runService = (api: ApiClient) => ({
|
export const runService = (api: ApiClient) => ({
|
||||||
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
|
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
|
||||||
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
|
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
|
||||||
@@ -13,4 +15,11 @@ export const runService = (api: ApiClient) => ({
|
|||||||
retryErrors: (id: number) => api.request(`/runs/${id}/retry-errors`, { method: 'POST', schema: retryErrorsSchema }),
|
retryErrors: (id: number) => api.request(`/runs/${id}/retry-errors`, { method: 'POST', schema: retryErrorsSchema }),
|
||||||
retryResult: (id: number, executionId: string) => api.request(`/runs/${id}/results/${encodeURIComponent(executionId)}/retry`, { method: 'POST', schema: retryResultSchema }),
|
retryResult: (id: number, executionId: string) => api.request(`/runs/${id}/results/${encodeURIComponent(executionId)}/retry`, { method: 'POST', schema: retryResultSchema }),
|
||||||
results: (id: number) => api.request(`/runs/${id}/results`, { schema: resultListSchema }),
|
results: (id: number) => api.request(`/runs/${id}/results`, { schema: resultListSchema }),
|
||||||
|
export: (id: number, filters: ExportFilters = {}) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.executionStatus) params.set('execution_status', filters.executionStatus)
|
||||||
|
if (filters.verdict) params.set('verdict', filters.verdict)
|
||||||
|
const query = params.toString()
|
||||||
|
return api.request<Blob>(`/runs/${id}/export${query ? `?${query}` : ''}`, { responseType: 'blob' })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
retryErrors: vi.fn(),
|
retryErrors: vi.fn(),
|
||||||
retryResult: vi.fn(),
|
retryResult: vi.fn(),
|
||||||
results: vi.fn(),
|
results: vi.fn(),
|
||||||
|
exportRun: vi.fn(),
|
||||||
reports: vi.fn(),
|
reports: vi.fn(),
|
||||||
report: vi.fn(),
|
report: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -24,7 +25,7 @@ vi.mock('../../src/services', () => ({ services: {
|
|||||||
auth: { me: mocks.me, login: vi.fn(), logout: vi.fn(), changePassword: vi.fn(), listUsers: vi.fn().mockResolvedValue([]) },
|
auth: { me: mocks.me, login: vi.fn(), logout: vi.fn(), changePassword: vi.fn(), listUsers: vi.fn().mockResolvedValue([]) },
|
||||||
health: { get: mocks.health },
|
health: { get: mocks.health },
|
||||||
providers: { list: mocks.providers },
|
providers: { list: mocks.providers },
|
||||||
runs: { list: mocks.runs, get: mocks.run, resume: mocks.resume, retryErrors: mocks.retryErrors, retryResult: mocks.retryResult, results: mocks.results },
|
runs: { list: mocks.runs, get: mocks.run, resume: mocks.resume, retryErrors: mocks.retryErrors, retryResult: mocks.retryResult, results: mocks.results, export: mocks.exportRun },
|
||||||
reports: { list: mocks.reports, get: mocks.report },
|
reports: { list: mocks.reports, get: mocks.report },
|
||||||
} }))
|
} }))
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ beforeEach(() => {
|
|||||||
mocks.retryErrors.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, retry_count: 5 })
|
mocks.retryErrors.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 100, retry_count: 5 })
|
||||||
mocks.retryResult.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0049' })
|
mocks.retryResult.mockResolvedValue({ run_id: 1, status: 'pending', selected_count: 1, execution_id: 'R0049' })
|
||||||
mocks.results.mockResolvedValue([])
|
mocks.results.mockResolvedValue([])
|
||||||
|
mocks.exportRun.mockResolvedValue(new Blob(['# report'], { type: 'text/markdown' }))
|
||||||
mocks.reports.mockResolvedValue([])
|
mocks.reports.mockResolvedValue([])
|
||||||
mocks.report.mockResolvedValue({ run_id: 1, summary: { test_result: {}, admission: { decision: 'pass', coverage: {} } } })
|
mocks.report.mockResolvedValue({ run_id: 1, summary: { test_result: {}, admission: { decision: 'pass', coverage: {} } } })
|
||||||
})
|
})
|
||||||
@@ -64,6 +66,7 @@ afterEach(() => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('page mounting and route security', () => {
|
describe('page mounting and route security', () => {
|
||||||
@@ -122,6 +125,36 @@ describe('page mounting and route security', () => {
|
|||||||
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
|
expect(screen.getByText('逐条结果 (1 / 2)')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exports terminal run results with the active filters', async () => {
|
||||||
|
const createObjectURL = vi.fn(() => 'blob:report')
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
class MockURL extends URL {}
|
||||||
|
MockURL.createObjectURL = createObjectURL
|
||||||
|
MockURL.revokeObjectURL = revokeObjectURL
|
||||||
|
vi.stubGlobal('URL', MockURL)
|
||||||
|
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||||
|
mount('/runs/1')
|
||||||
|
|
||||||
|
const exportButton = await screen.findByRole('button', { name: '导出详细报告' })
|
||||||
|
expect(exportButton.closest('.results-panel')).not.toBeNull()
|
||||||
|
expect(document.querySelector('.page-actions')).not.toContainElement(exportButton)
|
||||||
|
await userEvent.click(screen.getByRole('combobox', { name: '仲裁结论' }))
|
||||||
|
await userEvent.click(await screen.findByText('不通过'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '导出详细报告' }))
|
||||||
|
|
||||||
|
expect(mocks.exportRun).toHaveBeenCalledWith(1, { executionStatus: undefined, verdict: 'fail' })
|
||||||
|
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
|
||||||
|
expect(click).toHaveBeenCalled()
|
||||||
|
expect(click.mock.contexts[0]).toMatchObject({ download: 'run_1_results.md', href: 'blob:report' })
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:report')
|
||||||
|
}, 20_000)
|
||||||
|
|
||||||
|
it('disables report export while the run is not terminal', async () => {
|
||||||
|
mocks.run.mockResolvedValueOnce({ ...run, status: 'running', terminal: false, phase: 'executing', progress_percent: 50, poll_after_seconds: 2 })
|
||||||
|
mount('/runs/1')
|
||||||
|
expect(await screen.findByRole('button', { name: '导出详细报告' })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
it('searches run results by execution ID', async () => {
|
it('searches run results by execution ID', async () => {
|
||||||
mocks.results.mockResolvedValueOnce([
|
mocks.results.mockResolvedValueOnce([
|
||||||
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
{ execution_id: 'R0001', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ describe('service endpoint contracts', () => {
|
|||||||
await providers.create({ provider_id: 'target', base_url: 'https://example.com', chat_path: '/chat', models_path: '/models', model_name: 'm', auth_type: 'none', api_key: '', auth_header: 'Authorization', auth_prefix: 'Bearer', verify_ssl: true })
|
await providers.create({ provider_id: 'target', base_url: 'https://example.com', chat_path: '/chat', models_path: '/models', model_name: 'm', auth_type: 'none', api_key: '', auth_header: 'Authorization', auth_prefix: 'Bearer', verify_ssl: true })
|
||||||
await providers.update('target', { model_name: 'm2' }); await providers.check('target'); await providers.models('target'); await providers.remove('target')
|
await providers.update('target', { model_name: 'm2' }); await providers.check('target'); await providers.models('target'); await providers.remove('target')
|
||||||
const idempotencyKey = '00000000-0000-4000-8000-000000000001'
|
const idempotencyKey = '00000000-0000-4000-8000-000000000001'
|
||||||
await runs.list(); await runs.get(1); await runs.start({ profile: 'smoke', auto_judge: true }, idempotencyKey); await runs.cancel(1); await runs.resume(1); await runs.retryErrors(1); await runs.retryResult(1, 'R0049'); await runs.results(1); await runs.remove(1)
|
await runs.list(); await runs.get(1); await runs.start({ profile: 'smoke', auto_judge: true }, idempotencyKey); await runs.cancel(1); await runs.resume(1); await runs.retryErrors(1); await runs.retryResult(1, 'R0049'); await runs.results(1); await runs.export(1, { executionStatus: 'error', verdict: 'none' }); await runs.remove(1)
|
||||||
await reports.list(); await reports.get(1)
|
await reports.list(); await reports.get(1)
|
||||||
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
|
expect(vi.mocked(api.request).mock.calls.map(([path]) => path)).toEqual([
|
||||||
'/health', '/providers', '/providers/target', '/providers', '/providers/target',
|
'/health', '/providers', '/providers/target', '/providers', '/providers/target',
|
||||||
'/providers/target/check', '/providers/target/models', '/providers/target',
|
'/providers/target/check', '/providers/target/models', '/providers/target',
|
||||||
'/runs?limit=50', '/runs/1', '/runs', '/runs/1', '/runs/1/resume',
|
'/runs?limit=50', '/runs/1', '/runs', '/runs/1', '/runs/1/resume',
|
||||||
'/runs/1/retry-errors', '/runs/1/results/R0049/retry', '/runs/1/results', '/runs/1', '/reports?limit=50', '/reports/1',
|
'/runs/1/retry-errors', '/runs/1/results/R0049/retry', '/runs/1/results', '/runs/1/export?execution_status=error&verdict=none', '/runs/1', '/reports?limit=50', '/reports/1',
|
||||||
])
|
])
|
||||||
expect(vi.mocked(api.request).mock.calls[10][1]).toMatchObject({ headers: { 'Idempotency-Key': idempotencyKey } })
|
expect(vi.mocked(api.request).mock.calls[10][1]).toMatchObject({ headers: { 'Idempotency-Key': idempotencyKey } })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ describe('ApiClient', () => {
|
|||||||
).rejects.toMatchObject({ kind: 'validation' })
|
).rejects.toMatchObject({ kind: 'validation' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns file responses without parsing them as JSON', async () => {
|
||||||
|
const blob = new Blob(['# report'], { type: 'text/markdown' })
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(blob))
|
||||||
|
|
||||||
|
await expect(new ApiClient('/api', () => 'token').request('/runs/1/export', { responseType: 'blob' })).resolves.toEqual(expect.any(Blob))
|
||||||
|
expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('Accept')).toBe('text/markdown')
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes and replays a business request once after 401', async () => {
|
it('refreshes and replays a business request once after 401', async () => {
|
||||||
let token = 'old-token'
|
let token = 'old-token'
|
||||||
const refresh = vi.fn(async (force?: boolean) => {
|
const refresh = vi.fn(async (force?: boolean) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user