160 lines
8.4 KiB
Markdown
160 lines
8.4 KiB
Markdown
# 优化建议历史对比设计(Advisor Comparison)
|
||
|
||
**日期**: 2026-07-02
|
||
**状态**: 已批准,待实现
|
||
**范围**: 报告详情页「优化建议」区域新增"相比上次运行"的自动对比摘要,落地架构设计 §11"单变量变更、回归复测"方法论。仅涉及 webapp 读取/展示层,不改动 `rag_eval/advisor/` 的任何写入逻辑。
|
||
|
||
---
|
||
|
||
## 1. 背景与目标
|
||
|
||
架构设计 §11 明确优化方法论:
|
||
|
||
> 优化由评估结果驱动,遵循**单变量变更、回归复测**,避免一次性引入多项变更导致归因困难……每批经回归复测确认后再推进下一批。
|
||
|
||
当前平台已有两类"跨 run 对比"能力:
|
||
- **Dashboard**(`dashboard.js`):多 run 指标均值趋势线图 + 阈值柱状图,用户手动勾选 run 对比
|
||
- **问题历史**(`question_history.py`):同一问题在不同 run 中的分数对比,展示在最低分样本详情里
|
||
|
||
但**顾问诊断层面**没有对比能力:`Diagnosis`(metric/severity/threshold)目前只以自由文本嵌入 `optimization_advice.md`,从未结构化保留,无法程序化判断"上次标记的问题这次是否真的解决了"。
|
||
|
||
**成功标准**:
|
||
- 打开报告详情页时,若存在同 `scenario_name` 的上一次运行,优化建议区域自动显示一个精简的"相比上次运行"摘要,只列出有变化或仍有问题的指标
|
||
- 找不到上一次运行(首次评测、或 `/api/score/async`/`session_async` 这类每次 scenario_name 唯一的场景)时,静默不显示,不影响现有优化建议区域
|
||
- 不新增任何持久化文件;对比结果始终基于当前代码里的最新阈值规则重新计算
|
||
- 现有 `rag_eval/advisor/` 三个文件(rules.py / llm_analyzer.py / writer.py)与三条写入入口(runner.py / score_job_manager.py / session_score_manager.py)零改动
|
||
|
||
---
|
||
|
||
## 2. 架构
|
||
|
||
新增的对比逻辑完全是 **webapp 层的只读附加计算**,对齐 `question_history.py` 的既有模式:
|
||
|
||
```
|
||
report_builder.build_report(run_dir, metrics)
|
||
├── 现有逻辑:读取 scores.csv、构建 metric_means / distributions / groupings / lowest_samples
|
||
├── 现有逻辑:question_history.build_question_history_index(...)
|
||
└── 新增:advisor_comparison.build_advisor_comparison(run_dir, scenario_name, metrics)
|
||
├── find_previous_run(...) → 复用 run_reader.list_run_summaries()
|
||
├── 读取上一次 run 的 scores.csv + metrics(复用 run_reader 既有 helper)
|
||
├── 对两次分别调用 rag_eval.advisor.diagnose()(已公开,零修改)
|
||
└── 按指标名分类差异状态,返回 AdvisorComparison | None
|
||
```
|
||
|
||
不引入新的持久化文件;每次报告详情页请求都会现场重新计算,天然保证"始终用最新阈值规则重新评估历史数据"。
|
||
|
||
---
|
||
|
||
## 3. 数据模型
|
||
|
||
### 3.1 `webapp/services/advisor_comparison.py`(新文件)
|
||
|
||
```python
|
||
def find_previous_run(
|
||
scenario_name: str,
|
||
current_run_id: str,
|
||
current_finished_at: str,
|
||
) -> RunSummary | None:
|
||
"""在所有 run 中找到同 scenario_name、时间上最近的前一次运行。"""
|
||
|
||
def build_advisor_comparison(
|
||
run_dir: Path,
|
||
scenario_name: str,
|
||
metrics: list[str],
|
||
) -> AdvisorComparison | None:
|
||
"""构建当前 run 相对上一次同名 run 的顾问诊断差异,找不到上一次 run 时返回 None。"""
|
||
```
|
||
|
||
内部指标状态分类(4 种,两次都健康的指标不生成条目):
|
||
|
||
| 状态 | 含义 |
|
||
|---|---|
|
||
| `resolved` | 上次触发诊断,本次不再触发 |
|
||
| `regressed` | 上次未触发(该指标当时也被评测),本次新触发 |
|
||
| `still_triggered` | 两次都触发,展示分数与严重度变化 |
|
||
| `new_metric` | 上次运行未评测该指标(无基线),本次触发——不算回归,只是新指标 |
|
||
|
||
### 3.2 `webapp/models.py` 新增
|
||
|
||
```python
|
||
class AdvisorComparisonEntry(BaseModel):
|
||
metric: str
|
||
status: Literal["resolved", "regressed", "still_triggered", "new_metric"]
|
||
previous_score: float | None = None
|
||
previous_severity: str | None = None # "critical" | "warning" | "low" | None
|
||
current_score: float | None = None
|
||
current_severity: str | None = None
|
||
|
||
|
||
class AdvisorComparison(BaseModel):
|
||
previous_run_id: str
|
||
previous_finished_at: str
|
||
previous_judge_model: str = ""
|
||
current_judge_model: str = ""
|
||
judge_model_changed: bool = False
|
||
entries: list[AdvisorComparisonEntry] = Field(default_factory=list)
|
||
```
|
||
|
||
`ReportData` 新增字段:
|
||
|
||
```python
|
||
advisor_comparison: AdvisorComparison | None = Field(
|
||
default=None,
|
||
description="相比同场景上一次运行的顾问诊断差异;无可比对象时为 None。",
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 数据流
|
||
|
||
1. 用户打开报告详情页,run R(`scenario_name=S`、`run_id=R_id`、`finished_at=T`、`metrics=M`)
|
||
2. `build_report()` 调用 `find_previous_run(S, R_id, T)`:
|
||
- 复用现有 `run_reader.list_run_summaries()`
|
||
- 过滤 `scenario_name == S 且 run_id != R_id 且 finished_at < T`
|
||
- 按 `finished_at` 倒序,取第一个(时间上最近的前一次)
|
||
- 无匹配 → 返回 `None`
|
||
3. 若找到上一次 run P:
|
||
- 读取 P 的 `scores.csv`(`run_reader.read_scores_frame`)与其 metrics 列表(快照或推断,复用现有 helper)
|
||
- 对 P 和 R 分别调用 `rag_eval.advisor.diagnose(score_rows, metrics)`(现有函数,零修改)
|
||
- 取两次触发指标名的并集,按 §3.1 表格分类状态,跳过两次都未触发的指标
|
||
- 记录两次的 `judge_model`/`embedding_model`(来自各自的 `metadata.json`),设置 `judge_model_changed`
|
||
4. 返回 `AdvisorComparison`(或 `None`),挂到 `ReportData.advisor_comparison`
|
||
5. 前端 `report.js` 新增 `renderAdvisorComparison(report)`:
|
||
- `advisor_comparison` 为 `None` 或 `entries` 为空 → 不渲染任何内容
|
||
- 否则在「⑤ 优化建议」区域上方渲染"相比上次运行(run_id,时间)"精简列表,每条用 `MetricPresenter.deltaInfo()` 计算涨跌箭头,复用 `.delta-good`/`.delta-bad` 样式;`new_metric`/`regressed` 状态额外给一个醒目标记
|
||
- `judge_model_changed=true` 时追加一行小字提示"judge_model 不同(X → Y),对比仅供参考"
|
||
|
||
---
|
||
|
||
## 5. 错误处理
|
||
|
||
| 情况 | 处理 |
|
||
|---|---|
|
||
| 找不到同 scenario_name 的历史 run(含 `/api/score/async`、`session_async` 这类 scenario_name 唯一的场景) | `find_previous_run` 返回 `None`,整个对比区域不渲染 |
|
||
| 上一次 run 的 `scores.csv` 缺失/损坏/无法解析 | 捕获异常,记录日志,`build_advisor_comparison` 返回 `None`(不影响报告详情页其余部分) |
|
||
| 两次指标完全不重合、且都没有 `new_metric` 触发 | `entries` 为空列表,前端按"无内容"处理,不显示对比区域 |
|
||
| `diagnose()` 内部抛出任何异常 | 在 `build_advisor_comparison` 顶层 try/except 兜底,返回 `None`,绝不让报告详情页 500 |
|
||
|
||
设计原则与 `run_advisor()` 一致:辅助性/可观测性功能失败必须静默降级,不能影响核心报告渲染。
|
||
|
||
---
|
||
|
||
## 6. 测试策略
|
||
|
||
- `tests/test_advisor_comparison.py`(新文件):
|
||
- `find_previous_run`:同名匹配、排除自身、按时间取最近一次、无匹配返回 `None`、多个候选按时间正确排序
|
||
- `build_advisor_comparison`:用 `tmp_path` 构造两个假 run 目录(`scores.csv` + `metadata.json` + `scenario.snapshot.yaml`,复用 `tests/test_webapp_report_builder.py` 里已有的构造模式),覆盖 4 种状态分类、`judge_model_changed` 检测、无上一次 run 时返回 `None`、两次都健康时 `entries` 为空
|
||
- 扩展 `tests/test_webapp_report_builder.py`:验证 `build_report()` 正确挂载 `advisor_comparison` 字段(有上一次 run / 无上一次 run 两种情形)
|
||
- 不修改任何现有 `test_advisor_*.py`(rules/llm_analyzer/writer 行为完全不变,零回归风险)
|
||
|
||
---
|
||
|
||
## 7. 非目标(本轮明确不做)
|
||
|
||
- 不支持手动选择任意两个 run 做对比(只做"自动对比同场景上一次")
|
||
- 不持久化结构化诊断数据(`diagnoses.json` 或类似文件)——每次现场用 `diagnose()` 重新计算
|
||
- 不改动 `rag_eval/advisor/` 内部规则、LLM 分析 prompt 或写入逻辑
|
||
- 不支持跨 scenario_name 的对比(不同场景之间的指标不具可比性)
|
||
- 不做多跳历史链(只对比"最近一次",不做完整时间序列的顾问诊断趋势图——这块需求已由 Dashboard 的原始指标趋势线覆盖)
|