# AgentEvalTool 架构深化审查

2026-08-04 · 第三轮
module seam leakage deep module

扫描范围：最近 30 次提交的热点区域（探索式评测、周期对比、活动报告、前端活动页）。 识别 6 个深化机会，按推荐强度排序。

## A. 用例判定证据构建收敛到 case_verdict

强推荐
`evaluation/report.py` `web/routers/runs.py` `evaluation/case_verdict.py`

Before — 重复的证据构建

    flowchart LR
        subgraph report_py["report.py"]
            R1[generate_report] --> R2[构建 CaseEvidence]
            R2 --> R3[resolve_case_verdicts]
        end
        subgraph runs_py["runs.py"]
            U1[get_run_logs] --> U2[构建 CaseEvidence]
            U2 --> U3[resolve_case_verdicts]
        end
        style R2 fill:#fecaca,stroke:#dc2626
        style U2 fill:#fecaca,stroke:#dc2626

两处各 ~25 行，turns → evidence dict → CaseEvidence → verdicts

After — 单一落点

    flowchart LR
        subgraph case_verdict["case_verdict.py"]
            CV[build_evidence_and_verdicts]
            CV --> CV2[resolve_case_verdicts]
        end
        subgraph callers["调用方"]
            R[report.py] --> CV
            U[runs.py] --> CV
        end
        style CV fill:#0f172a,stroke:#0f172a,color:#fff

一处构建，两处调用；locality 回归

问题：CaseEvidence 构建 + resolve_case_verdicts 调用链在 report.py 和 runs.py 各写一遍（各 ~25 行），turns/results → evidence dict 的映射逻辑漂移风险高。

方案：在 case_verdict.py 新增 `build_evidence_and_verdicts(turns, results, summary)`，report.py 和 runs.py 各一行调用。

- locality：证据构建逻辑改一处，全局生效
- depth：case_verdict 从纯函数变深——接口不变，实现吸收编排
- 测试面：一处可测，不用在两处重复覆盖

## B. 活动报告读模型补完：load_campaign_view 统一出口

强推荐
`evaluation/report.py` `evaluation/comparison.py` `web/routers/campaigns.py` `exploration/summary.py`

Before — 9 次重复生成

    sequenceDiagram
        participant C as comparison.py
        participant R as load_campaign_report
        participant DB as SQLite
        C->>R: baseline report
        R->>DB: SELECT runs WHERE campaign_id
        DB-->>R: 50 runs
        R-->>C: report dict
        C->>R: current report
        R->>DB: SELECT runs WHERE campaign_id
        DB-->>R: 50 runs
        R-->>C: report dict
        Note over C,DB: 同一对比生成中重复 3-4 轮

comparison.py 内 9 次调用 load_campaign_report；markdown handler 7 步手动拼装

After — 单一 view 投影

    sequenceDiagram
        participant Caller
        participant V as load_campaign_view
        participant DB as SQLite
        Caller->>V: session, campaign
        V->>DB: 一次查询
        DB-->>V: runs + sessions + analysis
        V-->>Caller: {report, exploration, analysis, comparison}
        Note over Caller,DB: 所有出口走同一 view

一个接口，N 个投影（JSON / markdown / comparison）

问题：load_campaign_report 在 comparison.py 被调 9 次（同一对比生成中 baseline/current 各重复 3-4 轮）；markdown handler 手动拼装 7 个数据源；report + exploration 总是要一起取但没有统一入口。

方案：在 report.py 新增 `load_campaign_view(session, campaign)`，返回 `{report, exploration, analysis, comparison}`，内部做缓存避免重复查询。

- leverage：一个接口，5 个调用点统一
- locality：新增导出格式只需投影 view，不新增取数逻辑
- 性能：消除 comparison 生成中的重复报告构建

## C. 仪表盘聚合逻辑下沉到 metrics 模块

值得探索
`web/routers/stats.py` `evaluation/metrics.py`

Before — 业务逻辑在 HTTP 层

# stats.py router
def _settled(runs): ...
for r in runs:
if started.date() == today: ...
trigger_breakdown[...] += 1
for sid, sruns in by_scenario.items():
agg = aggregate_runs(sruns)
~40 行聚合逻辑 + _settled 过滤器

After — 接口薄、实现深

# metrics.py
def compute_dashboard(runs) -> DashboardStats:
# settled, per-scenario, trigger, today
# stats.py router
def dashboard(session):
runs = RunRepository(session).list_all()
return compute_dashboard(runs)
handler 缩到 3 行

问题：`_settled` 过滤器和 per-scenario 聚合在 router 文件里定义，是领域概念却锁在 HTTP 层——无法复用、无法测试。

方案：抽 `compute_dashboard(runs) -> DashboardStats` 到 metrics.py，router 只做 session 生命周期和序列化。

- locality：聚合规则改一处
- 可测性：纯函数，输入 runs 输出 stats
- leverage：未来趋势端点复用同一函数

## D. Campaigns.tsx 巨型组件拆解

值得探索
`frontend/web/src/pages/Campaigns.tsx`

Before — 1122 行单体

createOpen, submitting, form
reportOpen, reportLoading, report
reportRuns, reportTimeline
analysis, analysisBusy
comparison, expandedIds, timelines
4x usePolling
fetchReport: 5 并行 API 调用

15+ useState，接口宽如实现

After — 深 hook + 薄页面

useCampaignReport(id)
useCampaignList()
useCampaignCreate()
Campaigns.tsx: ~200 行

hook 深、页面薄；状态按职责分组

问题：1122 行组件、15+ 状态变量、fetchReport 一次发 5 个并行请求。添加报告抽屉新功能（如探索详情）需向已超载的组件再加状态。

方案：抽 `useCampaignReport(campaignId)` hook 封装报告 + 分析 + 对比 + 时间线的数据获取；页面组件只负责渲染和交互。

- depth：hook 接口小（一个 id 进，完整 view 出）
- locality：报告数据逻辑集中，不再散落
- 可测性：hook 可独立测试，不依赖页面渲染

## E. 分析 / 对比 Repository 双子合并

推测性
`storage/repository.py`

Before — 近乎相同的双子

AnalysisRepo
get_by_campaign
upsert(...)
mark_orphans_failed
ComparisonRepo
get_by_campaign
upsert(...)
mark_orphans_failed

~60 行重复；mark_orphans_failed 在 RunRepo 还有一份

After — 泛型基类

# 基类
class AsyncJobRepo(BaseRepo):
get_by_campaign()
upsert()
mark_orphans_failed()
# 子类只提供表名和字段映射
class AnalysisRepo(AsyncJobRepo): ...
class ComparisonRepo(AsyncJobRepo): ...

子类 ~5 行，差异在配置不在代码

问题：CampaignAnalysisRepository 和 CampaignPeriodComparisonRepository 结构近乎相同（get_by_campaign / upsert / mark_orphans_failed），加上 RunRepository.mark_orphans_failed 共三份拷贝。

方案：抽 AsyncJobRepository 泛型基类，子类只提供表类型和字段映射。

- locality：孤儿清理策略改一处
- leverage：新增异步任务类型只需 5 行子类
- 风险：引入继承层次，需权衡是否值得

## F. settlement.py 吸收进 Repository

推测性
`exploration/settlement.py` `storage/repository.py`

Before — 浅模块独立成文件

# settlement.py (30 行)
def settle_campaign_sessions(...):
for s in repo.list_by_campaign(...):
if s.status == RUNNING:
s.status = EXPIRED
repo.update(s)
接口宽 = 实现，无深度

After — Repository 方法

# ExplorationSessionRepository
def expire_running(self, campaign_id) -> int:
# 批量 UPDATE ... WHERE status=RUNNING
# campaign_runner.py
repo.expire_running(campaign_id)
删除 settlement.py，调用方一行

问题：settlement.py 是 30 行的浅模块——接口（一个函数）几乎和实现（一个循环）一样复杂。理解结算需要跨三个文件跳转。

方案：将 `settle_campaign_sessions` 吸收为 `ExplorationSessionRepository.expire_running(campaign_id)`，删除 settlement.py。

- 删除测试通过：删掉它，复杂度回到 repository（本就在那里）
- locality：会话生命周期操作集中在 repository
- 减少一个文件跳转

## 首选深化机会

### A. 用例判定证据构建收敛到 case_verdict

这是最干净的深化：两处 ~25 行的重复逻辑收敛到一个函数，接口不变、实现变深。 不涉及数据模型变更、不影响 API 契约、不触碰前端——纯粹的 locality 回归。 完成后，runs.py 的 handler 从 60 行缩到 10 行，report.py 的证据构建消失。

为什么先做它：成本最低（半天）、风险最小（纯重构）、收益最直接（删除 50 行重复）。 做完后 CaseEvidence 的构建逻辑有了唯一落点，后续任何判定相关的改动只改一处。

度量

- 删除行数：~50
- 影响文件：3
- 测试新增：1 个
- API 变更：无
- 前端变更：无