From 8bc5aa697916b6239fba0edc7e6a7f57fe57ea55 Mon Sep 17 00:00:00 2001 From: sinohqb Date: Fri, 31 Jul 2026 16:49:04 +0800 Subject: [PATCH] feat(campaign): add per-Run timeline seam + endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_campaign_timeline flattens a campaign's child Runs into offset-sorted per-Run entries (distinct from the report's 12-bucket aggregation), reusing a shared _run_window_offset口径 so both views place a run identically. Exposes GET /campaigns/{id}/timeline and the api.ts type/call. (v0.6 ticket 06) --- .../issues/06-campaign-timeline-endpoint.md | 14 ++ .../v0.6/issues/07-reverse-timescale-input.md | 14 ++ .../issues/08-shared-timeline-component.md | 13 ++ .../v0.6/issues/09-plan-timeline-preview.md | 12 ++ .../v0.6/issues/10-expandable-row-timeline.md | 14 ++ .scratch/v0.6/spec-ux.md | 86 ++++++++++ backend/agenteval/evaluation/report.py | 61 ++++++- backend/agenteval/web/routers/campaigns.py | 16 +- frontend/web/src/api.ts | 13 ++ tests/integration/test_campaigns_api.py | 37 +++++ tests/unit/test_campaign_timeline.py | 156 ++++++++++++++++++ 11 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 .scratch/v0.6/issues/06-campaign-timeline-endpoint.md create mode 100644 .scratch/v0.6/issues/07-reverse-timescale-input.md create mode 100644 .scratch/v0.6/issues/08-shared-timeline-component.md create mode 100644 .scratch/v0.6/issues/09-plan-timeline-preview.md create mode 100644 .scratch/v0.6/issues/10-expandable-row-timeline.md create mode 100644 .scratch/v0.6/spec-ux.md create mode 100644 tests/unit/test_campaign_timeline.py diff --git a/.scratch/v0.6/issues/06-campaign-timeline-endpoint.md b/.scratch/v0.6/issues/06-campaign-timeline-endpoint.md new file mode 100644 index 0000000..d59a5a1 --- /dev/null +++ b/.scratch/v0.6/issues/06-campaign-timeline-endpoint.md @@ -0,0 +1,14 @@ +# 06 — 后端时间轴接缝 + `GET /campaigns/{id}/timeline` + +**What to build:** 一个纯函数把某活动的全部子 Run 映射成「逐 Run 时间轴条目」——每条含 run_id、场景 id/名、加速后窗口偏移(`(started_at − campaign.started_at) × time_scale`,clamp 到 `[0, window_seconds]`)、状态、通过率(取子 Run summary,只读不重算)、平均时延、started_at;未开始/取消的子 Run 在函数内有明确定义的处理。再暴露一个只读端点 `GET /campaigns/{id}/timeline` 返回 `{entries: [...]}`(逐 Run 扁平数组,区别于报告的 12 桶聚合),并在前端 `api.ts` 加对应类型与调用。curl / 测试即可验证。 + +**Blocked by:** 无 —— 可立即开始。 + +**Status:** ready-for-agent + +- [ ] 新增纯函数 `build_campaign_timeline(campaign, runs)`(放 `evaluation/report.py` 或紧邻模块),偏移口径复用现有报告的 `× time_scale` + clamp。 +- [ ] 单测覆盖:正常偏移(含 time_scale 压缩)、clamp 到窗口边界、未开始子 Run、取消/失败子 Run、字段映射(pass_rate 取自 summary、场景名映射)、空活动。仿 `test_campaign_report.py`。 +- [ ] 新增 `GET /campaigns/{id}/timeline` → `{entries}`;404 当活动不存在。 +- [ ] 端点集成测试(200 字段形状 + 404),仿现有 campaigns router 测试。 +- [ ] `api.ts` 增加 `CampaignTimelineEntry` 类型与 `campaignsApi.timeline(id)`。 +- [ ] `pytest` 全绿、`ruff check backend/` 通过。 diff --git a/.scratch/v0.6/issues/07-reverse-timescale-input.md b/.scratch/v0.6/issues/07-reverse-timescale-input.md new file mode 100644 index 0000000..05123fe --- /dev/null +++ b/.scratch/v0.6/issues/07-reverse-timescale-input.md @@ -0,0 +1,14 @@ +# 07 — ①时间倍速反向输入 + +**What to build:** 让用户在新增活动时按「耗时」而非「倍速」思考。表单把裸的「时间倍速」输入换成「窗口长度(预设 6/12/24/48/72h)」+「希望加速后多久跑完」两项,系统实时派生并只读展示倍速与「加速后约 X 完成」;提交时前端把派生出的 `time_scale` 放进现有 payload(**后端 API 契约与 `time_scale` 语义完全不变**)。「实时」开关一键设为真实墙钟(倍速 ×1、禁用目标耗时输入)。目标耗时若 > 窗口(倍速 <1)表单校验拦截。活动列表/详情处也改用「加速后约 X 完成」展示,取代裸 `×N`。 + +**Blocked by:** 无 —— 可立即开始(不依赖后端改动)。 + +**Status:** ready-for-agent + +- [ ] 新增纯 util(如 `campaignTime.ts`):`deriveTimeScale(windowSeconds, targetSeconds)` 与 `acceleratedDuration(windowSeconds, timeScale)`,作为唯一口径。 +- [ ] 新增表单:窗口长度下拉 + 目标加速耗时(数值+单位,默认分钟)+「实时」开关;只读展示派生倍速与「加速后约 X 完成」。 +- [ ] 校验:目标耗时 ≤ 窗口长度(倍速 ≥1),违反时拦截并给出提示(US-4)。 +- [ ] 提交仍发 `time_scale`,`POST /campaigns` 契约不变。 +- [ ] 活动列表与详情用 `acceleratedDuration` 展示「加速后约 X 完成」,替换裸倍速列。 +- [ ] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证派生/实时开关/校验/列表展示。 diff --git a/.scratch/v0.6/issues/08-shared-timeline-component.md b/.scratch/v0.6/issues/08-shared-timeline-component.md new file mode 100644 index 0000000..15b2755 --- /dev/null +++ b/.scratch/v0.6/issues/08-shared-timeline-component.md @@ -0,0 +1,13 @@ +# 08 — 共享横向时间轴组件(预制) + +**What to build:** 一个可复用的只读横向时间轴展示组件,作为②计划预览与④过程时间轴的共同底座。它接受「一条代表窗口的水平轴长度 + 一组带窗口偏移的标记」,把标记按偏移定位在轴上,支持按分类键(如场景 id)稳定分配颜色、每个标记可带角标/计数、hover 显示自定义内容、标记可点击回调。组件本身不关心数据来自计划条目还是子 Run —— 两个调用方各自把领域数据适配成「带偏移的标记数组」。这是本批唯一较有设计含量的前端接缝,先把输入接口设计干净。 + +**Blocked by:** 无 —— 可立即开始(纯展示组件,做一个 Storybook 式的本地示例即可自证)。 + +**Status:** ready-for-agent + +- [ ] 定义组件输入接口:`windowSeconds`(轴长度)+ `markers: { key, offsetSeconds, colorKey, label?, badge?, tooltip, onClick? }[]`。 +- [ ] 轴渲染:按窗口长度画刻度(如按小时/自适应),标记按 `offsetSeconds` 定位,越界 clamp 到轴端。 +- [ ] 颜色:按 `colorKey` 稳定分配(同一 key 同色),图例可选。 +- [ ] 交互:hover 显 `tooltip`、点击触发 `onClick`。 +- [ ] 视觉与设计 token(`tokens.ts`)一致;`npx tsc --noEmit` + `npm run build` 通过;本地示例页/临时挂载验证渲染。 diff --git a/.scratch/v0.6/issues/09-plan-timeline-preview.md b/.scratch/v0.6/issues/09-plan-timeline-preview.md new file mode 100644 index 0000000..dc97dd6 --- /dev/null +++ b/.scratch/v0.6/issues/09-plan-timeline-preview.md @@ -0,0 +1,12 @@ +# 09 — ②活动计划时间轴预览 + +**What to build:** 在新增活动表单的计划编辑区上方,加一条只读时间轴预览:把当前表单里的每个计划条目按其窗口偏移画在代表窗口的水平轴上,颜色按场景稳定区分、标注重复次数,随用户编辑表单(增删条目、改偏移/场景/次数、改窗口长度)实时刷新。数据完全来自表单本地状态,无后端改动。复用 08 的共享时间轴组件,把计划条目适配成「带偏移的标记数组」。 + +**Blocked by:** 08(共享横向时间轴组件)。 + +**Status:** ready-for-agent + +- [ ] 把表单的 `plan` 条目 + `window_seconds` + 场景名映射适配成 08 组件的 `markers`(offset=条目偏移、colorKey=scenario_id、badge=count、tooltip=场景名+偏移+次数)。 +- [ ] 预览随表单状态实时重渲染(增删/改条目/改窗口即时反映)。 +- [ ] 空计划时给出占位提示,不报错。 +- [ ] `npx tsc --noEmit` + `npm run build` 通过;浏览器实操验证边编辑边看、颜色分场景、次数角标。 diff --git a/.scratch/v0.6/issues/10-expandable-row-timeline.md b/.scratch/v0.6/issues/10-expandable-row-timeline.md new file mode 100644 index 0000000..48f90a6 --- /dev/null +++ b/.scratch/v0.6/issues/10-expandable-row-timeline.md @@ -0,0 +1,14 @@ +# 10 — ④活动列表行展开过程时间轴 + +**What to build:** 让活动列表的每一行可展开,展开区显示该活动的完整过程时间轴:调 `GET /campaigns/{id}/timeline` 取逐 Run 条目,用 08 的共享组件把每个子 Run 按「加速后窗口偏移」定位成节点,颜色表状态(完成/失败/运行中/取消),hover 显场景/通过率/时延,点击节点跳到该子 Run 的报告(`/reports?run={run_id}`)。展开的是进行中活动时,随现有 5s 轮询刷新,时间轴随新派生的子 Run 实时长出。 + +**Blocked by:** 06(timeline 端点 + 类型)、08(共享横向时间轴组件)。 + +**Status:** ready-for-agent + +- [ ] 活动列表行改为 `expandable`;展开时按需拉取 `campaignsApi.timeline(id)`。 +- [ ] 把 timeline 条目适配成 08 组件的 `markers`(offset=offset_seconds、colorKey/颜色=status、tooltip=场景+通过率+时延、onClick=跳报告)。 +- [ ] 节点点击跳 `/reports?run={run_id}`(复用现有跳转方式)。 +- [ ] 展开进行中活动时随现有 5s 轮询刷新时间轴;已完成活动只拉一次。 +- [ ] 空/无子 Run 活动给占位提示,不报错。 +- [ ] `npx tsc --noEmit` + `npm run build` 通过;浏览器验证展开、状态配色、hover、跳转、进行中刷新。 diff --git a/.scratch/v0.6/spec-ux.md b/.scratch/v0.6/spec-ux.md new file mode 100644 index 0000000..bec62b8 --- /dev/null +++ b/.scratch/v0.6/spec-ux.md @@ -0,0 +1,86 @@ +# v0.6 Spec(UX 升级)— 评估活动页 UX 升级 + +**状态**: ready-for-agent +**领域词汇**: 见 CONTEXT.md「周期评估」章节(评估活动 / 服务周期窗口 / 活动计划 / 可用性) +**关键决策**: ADR-0002(通过率含执行失败)、ADR-0003(活动分期);本批不改判定/调度/报告聚合语义 +**批次说明**: 本 spec 是 v0.6 评估活动功能线的 **UX 升级批次**(①倍速反向输入 ②计划时间轴预览 ④展开行时间轴),与 `spec.md`(v1 静态地基)并存。线③「活动分析 Agent」属新功能,另立 **v0.7** spec。 +**工单**: 本 spec 派生 v0.6/issues/06–10。 + +## Problem Statement + +评估活动(Campaign)的能力在 v0.6 静态地基已落地,但**新增/查看活动的页面对用户不友好**: + +1. **时间倍速是裸数字**:新增活动时「时间倍速」是一个 `InputNumber`(默认 1,仅一句 tooltip),用户得自己心算「填多大倍速能让 24h 窗口几分钟跑完」。用户真正关心的是「加速后多久跑完」,而不是抽象的倍速值。 +2. **活动计划不直观**:计划是一组 `场景 + 偏移小时 + 次数` 的表单行,看不出「在这个窗口的哪些时段、跑哪个场景、跑几次」的时间分布全貌。 +3. **看不到活动过程**:活动列表每行不可展开;跑完只能进报告抽屉看聚合曲线,无法直观回看「整个活动过程中,各个子 Run 在时间上如何依次发生、各自成败」。 + +## Solution + +对活动页做三项 UX 升级,**不触碰**判定、调度、报告聚合的任何语义: + +- **① 倍速反向输入**:新增活动时用户填「窗口长度」+「希望加速后多久跑完」,系统自动派生并只读展示时间倍速;正式线用「实时」开关(×1)。用户从此按「耗时」思考,而非「倍速」。 +- **② 计划时间轴预览**:保留(优化后的)行编辑器做精确录入,其上方增加一条**只读时间轴预览**,把计划条目按偏移画在代表窗口的水平轴上、颜色区分场景、标注次数,随表单实时刷新。 +- **④ 展开行时间轴**:活动列表每行可展开为一条**横向窗口时间轴**,把该活动的子 Run 按「加速后窗口偏移」定位成节点、颜色表状态、hover 显详情、点击跳该 Run 报告;进行中活动随现有轮询实时长出新节点。②与④共用同一套时间轴视觉语言。 + +## User Stories + +1. 作为质量负责人,我想在新增活动时直接填「希望这个 24 小时窗口在多久内跑完」(如 1 小时),系统替我算好倍速,以便我不必心算倍速。 +2. 作为质量负责人,我想在填写时实时看到「加速后约 X 跑完」,以便确认压缩节奏符合预期。 +3. 作为质量负责人,我想在正式线一键选「实时」(真实墙钟、倍速 ×1),以便采到真实的凌晨/高峰表现。 +4. 作为质量负责人,当我把目标耗时填得比窗口还长(意味着倍速 <1、比真实还慢)时,我想被拦下或纠正,以便不产生无意义的减速活动。 +5. 作为质量负责人,我想在编辑活动计划时看到一条时间轴预览,把每个计划条目按其窗口偏移画出来,以便一眼看清「哪些时段跑哪个场景、跑几次」的分布。 +6. 作为质量负责人,我想计划预览用颜色区分不同场景、并标注每个条目的次数,以便区分密集与稀疏时段。 +7. 作为质量负责人,我想计划预览随我编辑表单实时更新,以便边填边看。 +8. 作为质量负责人,我想在活动列表里展开任意一行,看到这个活动的完整过程时间轴,以便回看整个服务周期里发生了什么。 +9. 作为质量负责人,我想过程时间轴把每个子 Run 按其「加速后窗口偏移」定位成节点,以便看清子 Run 在窗口内的时间分布。 +10. 作为质量负责人,我想每个节点用颜色表示状态(完成/失败/运行中/取消),以便一眼识别问题时段。 +11. 作为质量负责人,我想 hover 节点看到场景、通过率、时延,以便无需展开报告即可速览。 +12. 作为质量负责人,我想点击节点直接跳到该子 Run 的报告,以便下钻排查。 +13. 作为质量负责人,当我展开一个进行中的活动时,我想时间轴随轮询长出新派生的子 Run,以便实时观察活动推进。 +14. 作为质量负责人,我想活动列表/详情处也用「加速后约 X 完成」而非裸倍速展示,以便和新增表单的心智一致。 + +## Implementation Decisions + +### ① 倍速反向输入(前端为主,API 契约不变) +- 新增活动表单把「时间倍速」输入替换为两项:**窗口长度**(沿用预设下拉 6/12/24/48/72h)+ **目标加速耗时**(数值 + 单位,默认分钟)。 +- 前端派生 `time_scale = window_seconds / target_real_seconds`,作为**只读**展示(如「倍速 ×24 · 加速后约 1 小时跑完」),提交时仍在 payload 里发 `time_scale`。**后端 `POST /campaigns` 契约与 `Campaign.time_scale` 语义完全不变**。 +- 「实时」开关:选中即 `target_real_seconds = window_seconds` → `time_scale = 1.0`,并禁用目标耗时输入。 +- 约束:目标耗时必须 ≤ 窗口长度(即 `time_scale ≥ 1`);违反时表单校验拦截(对应 US-4)。 +- 派生与反算(由 scale+window 反推「加速后耗时」用于列表/详情展示)抽成**纯前端 util**(如 `campaignTime.ts` 的 `deriveTimeScale` / `acceleratedDuration`),集中口径、便于复用。 + +### ② 计划时间轴预览(纯前端) +- 新增一个**只读时间轴预览组件**,输入 = 表单当前的 `window_seconds` + `plan` 条目 + 场景名映射,输出 = 一条水平窗口轴,条目按 `offset_seconds` 定位,颜色按 `scenario_id` 稳定分配,标注 `count`。 +- 不涉及任何后端改动;数据完全来自表单本地状态,随编辑实时重渲染。 +- 与④的过程时间轴共享底层展示(见下「共享组件」)。 + +### ④ 展开行时间轴(新后端纯接缝 + 前端展开行) +- **新后端纯函数接缝** `build_campaign_timeline(campaign, runs) -> list[CampaignTimelineEntry]`(放 `evaluation/report.py` 或紧邻的模块),每条含:`run_id / scenario_id / scenario_name / offset_seconds(加速后窗口偏移,clamp 到 [0, window])/ status / pass_rate / avg_latency_ms / started_at`。 + - 偏移计算复用现有报告口径:`offset = (run.started_at - campaign.started_at).total_seconds() * time_scale`,clamp 到 `[0, window_seconds]`;未开始的子 Run(无 started_at)与取消 Run 的处理在此接缝内明确定义。 + - `pass_rate` 直接取子 Run `summary.pass_rate`(权威,只读不重算,沿 v0.5/v0.6 约定)。 +- **新端点** `GET /campaigns/{id}/timeline` → `{entries: CampaignTimelineEntry[]}`,返回逐 Run 扁平数组(区别于报告的 12 桶聚合曲线)。 +- **前端**:活动列表行改为 `expandable`,展开区渲染横向窗口时间轴:子 Run 节点按 `offset_seconds` 定位、颜色=状态、hover 显场景/通过率/时延、点击跳 `/reports?run={run_id}`。展开进行中活动时,随现有 5s 轮询刷新时间轴数据。 + +### 共享组件 +- ②的计划预览与④的过程时间轴共用一个**底层横向时间轴展示组件**(窗口轴 + 定位标记 + 颜色 + hover),两处用不同数据适配:②喂「计划条目」,④喂「子 Run」。保证视觉语言一致,避免两套实现。 + +### API 契约(新增项) +- `GET /campaigns/{id}/timeline` → `{ "entries": [ { "run_id", "scenario_id", "scenario_name", "offset_seconds", "status", "pass_rate", "avg_latency_ms", "started_at" } ] }`。 +- 其余活动端点、`Campaign` 模型、`time_scale` 语义均不变。 + +## Testing Decisions + +- **好测试只测外部行为**:接缝喂构造好的输入、断言输出,不测内部实现细节。 +- **主接缝:`build_campaign_timeline`(纯函数)** —— 唯一新增的可测后端接缝。单测覆盖:正常子 Run 的偏移计算(含 `time_scale` 压缩)、clamp 到窗口边界、未开始/取消/失败子 Run 的处理、字段映射(pass_rate 取自 summary、场景名映射)、空活动。**Prior art**:`tests/unit/test_report.py` / `test_campaign_report.py`(同样喂 campaign+runs 断言聚合 dict 的模式)。 +- **端点集成测试**:`GET /campaigns/{id}/timeline` 的 200/404、字段形状。**Prior art**:`tests/integration/test_runs_api.py`、现有 campaigns router 测试。 +- **前端无测试框架**:①倍速反向输入(派生/反算/实时开关/≤窗口校验)、②计划预览、④展开时间轴,均以 `npx tsc --noEmit` + `npm run build` + 浏览器实操验证(含进行中活动的轮询刷新)。`deriveTimeScale`/`acceleratedDuration` 写成纯函数,便于将来接入前端测试。 + +## Out of Scope + +- **线③ 活动分析 Agent + 服务质量改善建议**:新功能,另立 **v0.7** spec(含新增「分析」模型岗位、全局默认分析模型 + 活动可覆盖、多步分析 Agent、结构化分析报告、按需生成+缓存)。 +- **计划的拖拽编辑**:本批时间轴预览为**只读**;拖拽调偏移/点击空白新增等交互不做。 +- **跨活动对比**、活动列表分页/筛选增强,均不在本批。 + +## Further Notes + +- ②预览与④过程时间轴的共享底层组件是本批的关键复用点,也是唯一较有设计含量的前端接缝——先把它的输入接口设计干净(喂「带窗口偏移的标记数组」),两处适配即可。 +- 列表/详情的「加速后约 X 完成」展示与新增表单的派生口径必须走同一个纯 util,避免两处算法漂移(呼应 v0.6 起「口径收敛到一处」的做法)。 diff --git a/backend/agenteval/evaluation/report.py b/backend/agenteval/evaluation/report.py index 1e3adf6..6cd91ab 100644 --- a/backend/agenteval/evaluation/report.py +++ b/backend/agenteval/evaluation/report.py @@ -215,6 +215,23 @@ def _to_utc(dt: Optional[datetime]) -> Optional[datetime]: return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt +def _run_window_offset(campaign: Campaign, run: EvalRun) -> float: + """Position a Run within the (time-scaled) service window, in seconds. + + ``(started_at − campaign.started_at) × time_scale`` clamped to + ``[0, window_seconds]`` — the single口径 shared by the report's bucketing and + the flat timeline, so a compressed dev run lands identically in both. Unstarted + Runs (either timestamp missing) sit at offset 0. + """ + window = float(campaign.window_seconds) + campaign_start = _to_utc(campaign.started_at) + run_start = _to_utc(run.started_at) + if campaign_start is None or run_start is None: + return 0.0 + offset = (run_start - campaign_start).total_seconds() * campaign.time_scale + return max(0.0, min(offset, window)) + + def summarize_campaign_progress(campaign: Campaign, runs: list[EvalRun]) -> dict[str, Any]: """Compact list-row progress: completed vs *planned* total, plus pass_rate. @@ -231,6 +248,42 @@ def summarize_campaign_progress(campaign: Campaign, runs: list[EvalRun]) -> dict } +def build_campaign_timeline( + campaign: Campaign, + runs: list[EvalRun], + *, + scenario_names: Optional[dict[str, str]] = None, +) -> list[dict[str, Any]]: + """Flatten a campaign's child Runs into per-Run timeline entries. + + Unlike ``generate_campaign_report`` (12-bucket aggregation), this returns one + entry per Run, sorted by window offset, for a process-timeline view. The + offset reuses the report's口径 — ``(started_at − campaign.started_at) × + time_scale`` clamped to ``[0, window_seconds]`` — so a compressed dev run + lands at the same window position it reports. ``pass_rate`` / ``avg_latency_ms`` + are read straight from each Run's summary (no re-aggregation); unstarted Runs + sit at offset 0. + """ + scenario_names = scenario_names or {} + + entries = [] + for run in runs: + offset = _run_window_offset(campaign, run) + summary = run.summary + entries.append({ + "run_id": run.id, + "scenario_id": run.scenario_id, + "scenario_name": scenario_names.get(run.scenario_id, (run.scenario_id or "")[:8]), + "offset_seconds": round(offset, 3), + "status": run.status.value, + "pass_rate": summary.pass_rate if summary is not None else None, + "avg_latency_ms": summary.avg_latency_ms if summary is not None else None, + "started_at": iso_utc(run.started_at), + }) + entries.sort(key=lambda e: e["offset_seconds"]) + return entries + + def generate_campaign_report( campaign: Campaign, runs: list[EvalRun], @@ -252,17 +305,11 @@ def generate_campaign_report( scenario_names = scenario_names or {} window = float(campaign.window_seconds) bucket_seconds = window / bucket_count if bucket_count else window - campaign_start = _to_utc(campaign.started_at) # ── Axis 1: time trend ──────────────────────────────────────────────── buckets: dict[int, list[EvalRun]] = defaultdict(list) for run in runs: - run_start = _to_utc(run.started_at) - if campaign_start is None or run_start is None: - offset = 0.0 - else: - offset = (run_start - campaign_start).total_seconds() * campaign.time_scale - offset = max(0.0, min(offset, window)) + offset = _run_window_offset(campaign, run) idx = min(int(offset / bucket_seconds), bucket_count - 1) if bucket_seconds else 0 buckets[idx].append(run) diff --git a/backend/agenteval/web/routers/campaigns.py b/backend/agenteval/web/routers/campaigns.py index 055c3a9..a60bcc0 100644 --- a/backend/agenteval/web/routers/campaigns.py +++ b/backend/agenteval/web/routers/campaigns.py @@ -12,7 +12,11 @@ from pydantic import BaseModel, Field from sqlmodel import Session from agenteval.evaluation.campaign_runner import campaign_progress, request_cancel, start_campaign -from agenteval.evaluation.report import generate_campaign_report, summarize_campaign_progress +from agenteval.evaluation.report import ( + build_campaign_timeline, + generate_campaign_report, + summarize_campaign_progress, +) from agenteval.evaluation.report_render import render_campaign_markdown from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus from agenteval.storage.db import utc_now @@ -119,6 +123,16 @@ async def get_campaign_report_markdown(campaign_id: str, session: Session = Depe ) +@router.get("/{campaign_id}/timeline") +async def get_campaign_timeline(campaign_id: str, session: Session = Depends(get_db)) -> dict: + campaign = CampaignRepository(session).get(campaign_id) + if not campaign: + raise HTTPException(status_code=404, detail="campaign not found") + runs = RunRepository(session).list_by_campaign(campaign_id) + scenario_names = {s.id: s.name for s in ScenarioRepository(session).list_all()} + return {"entries": build_campaign_timeline(campaign, runs, scenario_names=scenario_names)} + + @router.get("/{campaign_id}") async def get_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict: campaign = CampaignRepository(session).get(campaign_id) diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts index 291749b..b859e93 100644 --- a/frontend/web/src/api.ts +++ b/frontend/web/src/api.ts @@ -380,6 +380,17 @@ export interface CampaignReport { capability_summary: CampaignCapability[] } +export interface CampaignTimelineEntry { + run_id: string + scenario_id: string + scenario_name: string + offset_seconds: number + status: string + pass_rate: number | null + avg_latency_ms: number | null + started_at: string | null +} + export interface CreateCampaignPayload { name: string target_id: string @@ -394,6 +405,8 @@ export const campaignsApi = { create: (data: CreateCampaignPayload) => api.post('/campaigns', data), cancel: (id: string) => api.post(`/campaigns/${id}/cancel`), report: (id: string) => api.get(`/campaigns/${id}/report`), + timeline: (id: string) => + api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`), downloadReport: async (id: string) => { const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) const url = URL.createObjectURL(res.data as Blob) diff --git a/tests/integration/test_campaigns_api.py b/tests/integration/test_campaigns_api.py index ac9d134..3b47403 100644 --- a/tests/integration/test_campaigns_api.py +++ b/tests/integration/test_campaigns_api.py @@ -295,3 +295,40 @@ async def test_campaign_report_markdown_export(client, seeded_db): assert "# 活动周期报告" in resp.text assert "## 时间趋势" in resp.text assert "## 能力汇总" in resp.text + + +# ── campaign timeline endpoint (ticket 06) ─────────────────────────────────── + +async def test_campaign_timeline_structure(client, seeded_db): + from agenteval.models import EvalRun, RunStatus + + campaign_id = (await client.post("/api/campaigns", json=_valid_payload())).json()["id"] + + repo = RunRepository(seeded_db) + repo.create(EvalRun( + target_id="t-1", scenario_id="s-1", campaign_id=campaign_id, + status=RunStatus.COMPLETED, + summary={"total_cases": 1, "passed_cases": 1, "pass_rate": 1.0, "avg_latency_ms": 100}, + )) + repo.create(EvalRun( + target_id="t-1", scenario_id="s-1", campaign_id=campaign_id, + status=RunStatus.FAILED, + )) + + body = (await client.get(f"/api/campaigns/{campaign_id}/timeline")).json() + assert "entries" in body + entries = body["entries"] + assert len(entries) == 2 + for e in entries: + assert set(e) >= { + "run_id", "scenario_id", "scenario_name", "offset_seconds", + "status", "pass_rate", "avg_latency_ms", "started_at", + } + assert e["scenario_name"] == "mock-scenario" + passed = next(e for e in entries if e["status"] == "completed") + assert passed["pass_rate"] == 1.0 + + +async def test_campaign_timeline_missing_404(client, seeded_db): + resp = await client.get("/api/campaigns/nope/timeline") + assert resp.status_code == 404 diff --git a/tests/unit/test_campaign_timeline.py b/tests/unit/test_campaign_timeline.py new file mode 100644 index 0000000..81b7824 --- /dev/null +++ b/tests/unit/test_campaign_timeline.py @@ -0,0 +1,156 @@ +"""Unit tests for build_campaign_timeline (per-Run flat timeline for a campaign).""" + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from agenteval.evaluation.report import build_campaign_timeline +from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus, EvalRun, RunStatus +from agenteval.storage.repository import CampaignRepository, RunRepository + +T0 = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + +@pytest.fixture() +def timeline_session(tmp_path): + from agenteval.storage.db import ( # noqa: F401 + CampaignDB, EvalResultDB, EvalRunDB, EvalTargetDB, FileCategoryDB, + FileRecordDB, ScenarioDB, TurnDB, + ) + engine = create_engine( + f"sqlite:///{tmp_path / 'campaign_timeline.db'}", + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + session = Session(engine) + try: + yield session + finally: + session.close() + engine.dispose() + + +def _seed_campaign(session, *, window_seconds=12, time_scale=1.0) -> Campaign: + campaign = CampaignRepository(session).create(Campaign( + name="cycle", target_id="t-1", window_seconds=window_seconds, time_scale=time_scale, + plan=[CampaignPlanEntry(scenario_id="s-a", offset_seconds=0, count=1)], + )) + campaign.status = CampaignStatus.RUNNING + campaign.started_at = T0 + return CampaignRepository(session).update(campaign) + + +def _seed_child(session, campaign_id, scenario_id, status, offset, *, pass_rate=None, latency=None) -> EvalRun: + summary = None + if status == RunStatus.COMPLETED: + summary = { + "total_cases": 1, + "passed_cases": 1 if (pass_rate or 0) >= 1 else 0, + "pass_rate": pass_rate, + "avg_latency_ms": latency, + } + return RunRepository(session).create(EvalRun( + target_id="t-1", scenario_id=scenario_id, campaign_id=campaign_id, + status=status, started_at=T0 + timedelta(seconds=offset) if offset is not None else None, + summary=summary, + )) + + +def _entry(timeline, run_id): + return next(e for e in timeline if e["run_id"] == run_id) + + +def test_offset_status_and_fields(timeline_session): + campaign = _seed_campaign(timeline_session) + r0 = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.COMPLETED, 0, pass_rate=1.0, latency=100) + r6 = _seed_child(timeline_session, campaign.id, "s-b", RunStatus.COMPLETED, 6, pass_rate=0.5, latency=300) + + timeline = build_campaign_timeline( + campaign, RunRepository(timeline_session).list_by_campaign(campaign.id), + scenario_names={"s-a": "夜间问诊", "s-b": "白班"}, + ) + + assert len(timeline) == 2 + e0 = _entry(timeline, r0.id) + assert e0["scenario_id"] == "s-a" + assert e0["scenario_name"] == "夜间问诊" + assert e0["offset_seconds"] == 0.0 + assert e0["status"] == "completed" + assert e0["pass_rate"] == 1.0 + assert e0["avg_latency_ms"] == 100 + assert e0["started_at"] is not None + + e6 = _entry(timeline, r6.id) + assert e6["offset_seconds"] == 6.0 + assert e6["scenario_name"] == "白班" + assert e6["pass_rate"] == 0.5 + + +def test_time_scale_scales_offset_only(timeline_session): + # window 12s, scale 10 → a run 0.6s in maps to offset 6.0; numbers untouched. + campaign = _seed_campaign(timeline_session, window_seconds=12, time_scale=10.0) + r = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.COMPLETED, 0.6, pass_rate=0.8, latency=120) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + e = _entry(timeline, r.id) + assert e["offset_seconds"] == 6.0 + assert e["pass_rate"] == 0.8 + assert e["avg_latency_ms"] == 120 + + +def test_offset_clamped_to_window(timeline_session): + campaign = _seed_campaign(timeline_session, window_seconds=12) + r = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.COMPLETED, 999, pass_rate=1.0, latency=50) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + assert _entry(timeline, r.id)["offset_seconds"] == 12.0 + + +def test_pending_run_field_mapping(timeline_session): + campaign = _seed_campaign(timeline_session) + r = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.PENDING, 0) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + e = _entry(timeline, r.id) + assert e["offset_seconds"] == 0.0 + assert e["status"] == "pending" + assert e["pass_rate"] is None + assert e["avg_latency_ms"] is None + + +def test_missing_started_at_defaults_offset_zero(timeline_session): + # Defensive: a Run with no started_at (never persisted) sits at offset 0. + campaign = _seed_campaign(timeline_session) + run = EvalRun(target_id="t-1", scenario_id="s-a", campaign_id=campaign.id, + status=RunStatus.PENDING, started_at=None) + timeline = build_campaign_timeline(campaign, [run]) + assert timeline[0]["offset_seconds"] == 0.0 + assert timeline[0]["started_at"] is None + + +def test_failed_run_has_no_summary_numbers(timeline_session): + campaign = _seed_campaign(timeline_session) + r = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.FAILED, 6) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + e = _entry(timeline, r.id) + assert e["status"] == "failed" + assert e["pass_rate"] is None + assert e["avg_latency_ms"] is None + + +def test_entries_sorted_by_offset(timeline_session): + campaign = _seed_campaign(timeline_session) + late = _seed_child(timeline_session, campaign.id, "s-a", RunStatus.COMPLETED, 9, pass_rate=1.0, latency=10) + early = _seed_child(timeline_session, campaign.id, "s-b", RunStatus.COMPLETED, 1, pass_rate=1.0, latency=10) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + assert [e["run_id"] for e in timeline] == [early.id, late.id] + + +def test_scenario_name_falls_back_to_id_prefix(timeline_session): + campaign = _seed_campaign(timeline_session) + _seed_child(timeline_session, campaign.id, "scenario-xyz-123", RunStatus.COMPLETED, 0, pass_rate=1.0, latency=10) + timeline = build_campaign_timeline(campaign, RunRepository(timeline_session).list_by_campaign(campaign.id)) + assert timeline[0]["scenario_name"] == "scenario"[:8] + + +def test_empty_campaign(timeline_session): + campaign = _seed_campaign(timeline_session) + assert build_campaign_timeline(campaign, []) == []