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 变更:无
  • 前端变更:无