# 优化建议历史对比设计(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 的原始指标趋势线覆盖)