Files
siemens_ragas/docs/superpowers/plans/2026-07-02-advisor-comparison.md
T

1128 lines
44 KiB
Markdown
Raw Normal View History

# 优化建议历史对比 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 报告详情页「优化建议」区域自动显示"相比同场景上一次运行"的顾问诊断差异摘要(落地架构设计 §11 回归复测方法论)。
**Architecture:** 新增 webapp 层只读服务 `webapp/services/advisor_comparison.py`,现场调用已公开的 `rag_eval.advisor.diagnose()` 分别对当前 run 和"同 scenario_name 最近一次前序 run"的 `scores.csv` 重新计算诊断,按指标名分类差异状态(resolved/regressed/still_triggered/new_metric),挂载到 `ReportData.advisor_comparison`。零改动 `rag_eval/advisor/` 内部规则与三条写入入口;零新增持久化文件。
**Tech Stack:** Python 3.12、Pydantic v2、pandas(复用既有 `run_reader` helper)、pytest`-v` 详细输出)、原生 JS`report.js` IIFE 模块,复用既有 `MetricPresenter.deltaInfo()`)。
## Global Constraints
- 不新增任何持久化文件(不写 `diagnoses.json` 或类似文件);对比结果每次报告详情页请求时现场用 `diagnose()` 重新计算,始终反映当前代码里的最新阈值规则。
- 零改动 `rag_eval/advisor/rules.py``llm_analyzer.py``writer.py``__init__.py`
- 零改动三条写入入口:`rag_eval/execution/runner.py``webapp/services/score_job_manager.py``webapp/services/session_score_manager.py`
- 只对比"同 `scenario_name`、时间上最近的前一次运行"(不支持手动选任意两个 run,不做多跳历史链)。
- 对比区域只展示"有变化或仍有问题"的指标;两次都健康的指标不生成条目。
- 找不到上一次运行、数据缺失/损坏、`diagnose()` 内部异常等情况一律静默降级为 `None`,绝不影响报告详情页其余部分渲染或抛出异常。
- 设计依据:`docs/superpowers/specs/2026-07-02-advisor-comparison-design.md`(本计划每个任务对应该文档的具体章节,不要偏离)。
- 测试运行命令:`C:\software\Python312\python.exe -m pytest tests/<file> -v`pytest 9.0.3,本机无 venv,需用此绝对路径)。
---
### Task 1: `AdvisorComparisonEntry` / `AdvisorComparison` 模型 + `ReportData` 字段
**Files:**
- Modify: `webapp/models.py`
- Test: `tests/test_advisor_comparison_models.py`
**Interfaces:**
- Produces:
- `AdvisorComparisonEntry(metric: str, status: Literal["resolved","regressed","still_triggered","new_metric"], previous_score: float|None, previous_severity: str|None, current_score: float|None, current_severity: str|None)`
- `AdvisorComparison(previous_run_id: str, previous_finished_at: str, previous_judge_model: str, current_judge_model: str, judge_model_changed: bool, entries: list[AdvisorComparisonEntry])`
- `ReportData.advisor_comparison: AdvisorComparison | None` (default `None`)
- [ ] **Step 1: Write the failing test**
Create `tests/test_advisor_comparison_models.py`:
```python
"""Tests for the AdvisorComparison Pydantic models and their default wiring."""
from __future__ import annotations
from webapp.models import AdvisorComparison, AdvisorComparisonEntry, ReportData
def test_advisor_comparison_entry_accepts_all_statuses():
for status in ("resolved", "regressed", "still_triggered", "new_metric"):
entry = AdvisorComparisonEntry(
metric="faithfulness",
status=status,
previous_score=0.5,
previous_severity="warning",
current_score=0.9,
current_severity=None,
)
assert entry.status == status
def test_advisor_comparison_entry_rejects_unknown_status():
import pydantic
try:
AdvisorComparisonEntry(metric="faithfulness", status="unknown_status")
raised = False
except pydantic.ValidationError:
raised = True
assert raised
def test_advisor_comparison_defaults():
comparison = AdvisorComparison(
previous_run_id="run-1",
previous_finished_at="2026-01-01T00:00:00+00:00",
)
assert comparison.previous_judge_model == ""
assert comparison.current_judge_model == ""
assert comparison.judge_model_changed is False
assert comparison.entries == []
def test_report_data_defaults_advisor_comparison_to_none():
report = ReportData()
assert report.advisor_comparison is None
def test_report_data_accepts_advisor_comparison():
comparison = AdvisorComparison(previous_run_id="run-1", previous_finished_at="t")
report = ReportData(advisor_comparison=comparison)
assert report.advisor_comparison == comparison
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison_models.py -v`
Expected: FAIL with `ImportError: cannot import name 'AdvisorComparison' from 'webapp.models'`
- [ ] **Step 3: Write minimal implementation**
In `webapp/models.py`, find the import line:
```python
from typing import Any
```
Replace with:
```python
from typing import Any, Literal
```
Add these two new model classes right before `class ReportData(BaseModel):`:
```python
class AdvisorComparisonEntry(BaseModel):
"""One metric's diagnosis delta between the current run and its predecessor."""
metric: str
status: Literal["resolved", "regressed", "still_triggered", "new_metric"]
previous_score: float | None = None
previous_severity: str | None = None
current_score: float | None = None
current_severity: str | None = None
class AdvisorComparison(BaseModel):
"""Advisor-diagnosis comparison against the same scenario's previous run."""
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)
```
Then, inside the existing `ReportData` class, find its last field:
```python
token_usage: dict[str, dict[str, int]] = Field(
default_factory=dict,
description="按模型累计的 token 用量:{model: {input_tokens, output_tokens, calls}}。",
)
```
Replace with (adds the new field immediately after, still inside the class body):
```python
token_usage: dict[str, dict[str, int]] = Field(
default_factory=dict,
description="按模型累计的 token 用量:{model: {input_tokens, output_tokens, calls}}。",
)
advisor_comparison: AdvisorComparison | None = Field(
default=None,
description="相比同场景上一次运行的顾问诊断差异;无可比对象时为 None。",
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison_models.py -v`
Expected: `5 passed`
- [ ] **Step 5: Commit**
```powershell
git add webapp/models.py tests/test_advisor_comparison_models.py
git commit -m "feat(advisor-comparison): add AdvisorComparison models and ReportData field"
```
---
### Task 2: `find_previous_run()` — locate the same-scenario predecessor run
**Files:**
- Create: `webapp/services/advisor_comparison.py`
- Test: `tests/test_advisor_comparison.py`
**Interfaces:**
- Consumes: `webapp.services.run_reader.list_run_summaries(extra_roots=None) -> list[RunSummary]` (existing); `webapp.models.RunSummary` (existing, has `run_id`, `scenario_name`, `finished_at`, `judge_model`, `metrics`, `output_path`)
- Produces: `find_previous_run(scenario_name: str, current_run_id: str, current_finished_at: str, extra_roots: list[Path] | None = None) -> RunSummary | None`, used by Task 3.
- [ ] **Step 1: Write the failing test**
Create `tests/test_advisor_comparison.py`:
```python
"""Tests for webapp.services.advisor_comparison: same-scenario run comparison."""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
from webapp.services.advisor_comparison import find_previous_run
def _write_fake_run(
root: Path,
run_id: str,
scenario_name: str,
finished_at: str,
judge_model: str,
rows: list[dict],
) -> Path:
"""Write a minimal run directory discoverable by run_reader.list_run_summaries()."""
run_dir = root / run_id
run_dir.mkdir(parents=True, exist_ok=True)
pd.DataFrame(rows).to_csv(run_dir / "scores.csv", index=False)
metadata = {
"run_id": run_id,
"scenario_name": scenario_name,
"judge_model": judge_model,
"embedding_model": "embed-model",
"finished_at": finished_at,
"started_at": finished_at,
"valid_samples": len(rows),
"invalid_samples": 0,
}
(run_dir / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
return run_dir
class TestFindPreviousRun:
def test_finds_most_recent_prior_run_with_same_scenario(self, tmp_path: Path) -> None:
_write_fake_run(tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}])
_write_fake_run(tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.6}])
_write_fake_run(tmp_path, "r3", "scn-a", "2026-01-03T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.9}])
previous = find_previous_run(
"scn-a", "r3", "2026-01-03T00:00:00+00:00", extra_roots=[tmp_path]
)
assert previous is not None
assert previous.run_id == "r2"
def test_excludes_runs_with_different_scenario_name(self, tmp_path: Path) -> None:
_write_fake_run(tmp_path, "r1", "scn-other", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}])
_write_fake_run(tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.6}])
previous = find_previous_run(
"scn-a", "r2", "2026-01-02T00:00:00+00:00", extra_roots=[tmp_path]
)
assert previous is None
def test_returns_none_when_no_history(self, tmp_path: Path) -> None:
_write_fake_run(tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}])
previous = find_previous_run(
"scn-a", "r1", "2026-01-01T00:00:00+00:00", extra_roots=[tmp_path]
)
assert previous is None
def test_ignores_runs_at_or_after_current_time(self, tmp_path: Path) -> None:
_write_fake_run(tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}])
_write_fake_run(tmp_path, "r2", "scn-a", "2026-01-05T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.6}])
# Current run finished at 2026-01-02, i.e. AFTER r1 but BEFORE r2.
previous = find_previous_run(
"scn-a", "r-current", "2026-01-02T00:00:00+00:00", extra_roots=[tmp_path]
)
assert previous is not None
assert previous.run_id == "r1"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'webapp.services.advisor_comparison'`
- [ ] **Step 3: Write minimal implementation**
Create `webapp/services/advisor_comparison.py`:
```python
"""Advisor-diagnosis comparison against the same scenario's previous run.
The report detail page shows, for the run currently being viewed, whether
metrics flagged by the optimization advisor in the immediately preceding run
of the same scenario have since improved, regressed, or remain unresolved.
No new artifact files are written: diagnoses are recomputed on demand from
each run's scores.csv via the existing rag_eval.advisor.diagnose(), so the
comparison always reflects the current threshold rules in rules.py.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pandas as pd
from webapp.models import AdvisorComparison, AdvisorComparisonEntry, RunSummary
from webapp.services import run_reader
logger = logging.getLogger("webapp.services.advisor_comparison")
def find_previous_run(
scenario_name: str,
current_run_id: str,
current_finished_at: str,
extra_roots: list[Path] | None = None,
) -> RunSummary | None:
"""Return the most recent prior run with the same scenario_name, or None.
"Prior" means finished_at strictly earlier than current_finished_at, so a
run that finished later (e.g. a concurrent run) is never mistaken for a
historical baseline.
"""
candidates = [
summary
for summary in run_reader.list_run_summaries(extra_roots)
if summary.scenario_name == scenario_name
and summary.run_id != current_run_id
and (summary.finished_at or "") < (current_finished_at or "")
]
if not candidates:
return None
candidates.sort(key=lambda summary: summary.finished_at or "", reverse=True)
return candidates[0]
```
- [ ] **Step 4: Run test to verify it passes**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py -v`
Expected: `4 passed`
- [ ] **Step 5: Commit**
```powershell
git add webapp/services/advisor_comparison.py tests/test_advisor_comparison.py
git commit -m "feat(advisor-comparison): add find_previous_run same-scenario lookup"
```
---
### Task 3: `build_advisor_comparison()` — diagnosis diffing and status classification
**Files:**
- Modify: `webapp/services/advisor_comparison.py`
- Test: `tests/test_advisor_comparison.py` (extend)
**Interfaces:**
- Consumes: `find_previous_run(...)` from Task 2; `AdvisorComparison`/`AdvisorComparisonEntry` from Task 1; `rag_eval.advisor.diagnose(score_rows, metrics, top_low_samples=3) -> list[Diagnosis]` (existing, `Diagnosis` has `.metric`, `.mean_score`, `.severity`); `run_reader.read_scores_frame(run_dir) -> pd.DataFrame` (existing).
- Produces: `build_advisor_comparison(run_dir: Path, scenario_name: str, metrics: list[str], extra_roots: list[Path] | None = None) -> AdvisorComparison | None`, used by Task 4.
- [ ] **Step 1: Write the failing test**
In `tests/test_advisor_comparison.py`, find the existing import line:
```python
from webapp.services.advisor_comparison import find_previous_run
```
Replace with:
```python
from webapp.services.advisor_comparison import build_advisor_comparison, find_previous_run
```
Append these test classes to the end of the file:
```python
class TestBuildAdvisorComparison:
def test_resolved_status_when_previously_triggered_now_healthy(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}, {"sample_id": "s2", "faithfulness": 0.5}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.95}, {"sample_id": "s2", "faithfulness": 0.95}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
assert len(comparison.entries) == 1
entry = comparison.entries[0]
assert entry.metric == "faithfulness"
assert entry.status == "resolved"
assert entry.previous_score == 0.5
assert entry.current_score is None # not triggered now → no Diagnosis on current side
def test_regressed_status_when_previously_healthy_now_triggered(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.95}, {"sample_id": "s2", "faithfulness": 0.95}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}, {"sample_id": "s2", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
assert len(comparison.entries) == 1
entry = comparison.entries[0]
assert entry.status == "regressed"
assert entry.previous_score is None
assert entry.current_score == 0.5
def test_still_triggered_status_shows_score_and_severity_change(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.55}, {"sample_id": "s2", "faithfulness": 0.55}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.45}, {"sample_id": "s2", "faithfulness": 0.45}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
entry = comparison.entries[0]
assert entry.status == "still_triggered"
assert entry.previous_severity == "warning"
assert entry.current_severity == "critical"
assert entry.previous_score == 0.55
assert entry.current_score == 0.45
def test_new_metric_status_when_metric_not_measured_before(self, tmp_path: Path) -> None:
# Previous run only measured context_recall (healthy); faithfulness wasn't tracked at all.
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "context_recall": 0.95}, {"sample_id": "s2", "context_recall": 0.95}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}, {"sample_id": "s2", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
assert len(comparison.entries) == 1
entry = comparison.entries[0]
assert entry.metric == "faithfulness"
assert entry.status == "new_metric"
assert entry.previous_score is None
assert entry.current_score == 0.5
def test_metrics_healthy_in_both_are_omitted(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.95}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.96}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is None # nothing to show → overall None
def test_metric_dropped_from_current_scope_is_not_marked_resolved(self, tmp_path: Path) -> None:
# Previous run triggered on context_precision, but current run doesn't
# evaluate that metric at all — must NOT claim "resolved" without a
# fair current-side measurement.
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "context_precision": 0.3}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.95}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is None
def test_returns_none_when_no_previous_run(self, tmp_path: Path) -> None:
current_dir = _write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is None
def test_judge_model_changed_flag_set_when_models_differ(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-4o",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
assert comparison.judge_model_changed is True
assert comparison.previous_judge_model == "gpt-4o"
assert comparison.current_judge_model == "gpt-5"
def test_judge_model_changed_false_when_same(self, tmp_path: Path) -> None:
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is not None
assert comparison.judge_model_changed is False
def test_gracefully_returns_none_on_corrupt_previous_scores_csv(self, tmp_path: Path) -> None:
previous_dir = _write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
# Corrupt the previous run's scores.csv after it was written.
(previous_dir / "scores.csv").write_text("not,a,valid\ncsv,,,", encoding="utf-8")
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[{"sample_id": "s1", "faithfulness": 0.5}],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness"], extra_roots=[tmp_path]
)
assert comparison is None
def test_worse_statuses_sorted_before_resolved(self, tmp_path: Path) -> None:
"""regressed/still_triggered/new_metric surface above resolved for visibility."""
_write_fake_run(
tmp_path, "r1", "scn-a", "2026-01-01T00:00:00+00:00", "gpt-5",
[
{"sample_id": "s1", "faithfulness": 0.5, "context_recall": 0.95},
],
)
current_dir = _write_fake_run(
tmp_path, "r2", "scn-a", "2026-01-02T00:00:00+00:00", "gpt-5",
[
{"sample_id": "s1", "faithfulness": 0.95, "context_recall": 0.5},
],
)
comparison = build_advisor_comparison(
current_dir, "scn-a", ["faithfulness", "context_recall"], extra_roots=[tmp_path]
)
assert comparison is not None
statuses = [entry.status for entry in comparison.entries]
# context_recall regressed → must appear before faithfulness resolved.
assert statuses.index("regressed") < statuses.index("resolved")
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py -v`
Expected: FAIL with `ImportError: cannot import name 'build_advisor_comparison' from 'webapp.services.advisor_comparison'`
- [ ] **Step 3: Write minimal implementation**
In `webapp/services/advisor_comparison.py`, add these imports at the top (replacing the existing import block):
```python
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import pandas as pd
from rag_eval.advisor import diagnose
from webapp.models import AdvisorComparison, AdvisorComparisonEntry, RunSummary
from webapp.services import run_reader
logger = logging.getLogger("webapp.services.advisor_comparison")
# Worse-first ordering so regressions and unresolved issues surface above
# resolved ones in the rendered comparison list.
_STATUS_ORDER = {"regressed": 0, "still_triggered": 1, "new_metric": 2, "resolved": 3}
```
Keep the existing `find_previous_run` function unchanged, then append:
```python
def _rows_as_records(frame: pd.DataFrame) -> list[dict[str, Any]]:
"""Convert a scores dataframe into plain dict records, NaN -> None."""
if frame.empty:
return []
return frame.where(pd.notnull(frame), None).to_dict("records")
def build_advisor_comparison(
run_dir: Path,
scenario_name: str,
metrics: list[str],
extra_roots: list[Path] | None = None,
) -> AdvisorComparison | None:
"""Build the current run's advisor-diagnosis delta vs. its same-scenario predecessor.
Returns None when there is no predecessor, the predecessor's data cannot be
read, or there is nothing worth surfacing (no metric changed status). Never
raises — any failure degrades to None so the report detail page is
unaffected (mirrors run_advisor()'s defensive error handling).
"""
try:
metadata = run_reader._read_json(run_dir / "metadata.json")
current_run_id = str(metadata.get("run_id") or run_dir.name)
current_finished_at = str(metadata.get("finished_at") or "")
current_judge_model = str(metadata.get("judge_model", ""))
previous = find_previous_run(
scenario_name, current_run_id, current_finished_at, extra_roots
)
if previous is None:
return None
previous_dir = Path(previous.output_path)
previous_frame = run_reader.read_scores_frame(previous_dir)
if previous_frame.empty:
return None
previous_rows = _rows_as_records(previous_frame)
previous_metrics = previous.metrics
current_frame = run_reader.read_scores_frame(run_dir)
current_rows = _rows_as_records(current_frame)
previous_diagnoses = {d.metric: d for d in diagnose(previous_rows, previous_metrics)}
current_diagnoses = {d.metric: d for d in diagnose(current_rows, metrics)}
entries: list[AdvisorComparisonEntry] = []
for metric in sorted(set(previous_diagnoses) | set(current_diagnoses)):
prev_d = previous_diagnoses.get(metric)
curr_d = current_diagnoses.get(metric)
if prev_d is not None and curr_d is None:
if metric not in metrics:
# Dropped from the current scope entirely — cannot fairly
# claim "resolved" without a current-side measurement.
continue
status = "resolved"
elif prev_d is None and curr_d is not None:
status = "regressed" if metric in previous_metrics else "new_metric"
else:
status = "still_triggered"
entries.append(AdvisorComparisonEntry(
metric=metric,
status=status,
previous_score=prev_d.mean_score if prev_d else None,
previous_severity=prev_d.severity if prev_d else None,
current_score=curr_d.mean_score if curr_d else None,
current_severity=curr_d.severity if curr_d else None,
))
if not entries:
return None
entries.sort(key=lambda e: (_STATUS_ORDER.get(e.status, 99), e.metric))
previous_judge_model = previous.judge_model
return AdvisorComparison(
previous_run_id=previous.run_id,
previous_finished_at=previous.finished_at,
previous_judge_model=previous_judge_model,
current_judge_model=current_judge_model,
judge_model_changed=(
bool(previous_judge_model)
and bool(current_judge_model)
and previous_judge_model != current_judge_model
),
entries=entries,
)
except Exception as exc: # noqa: BLE001
logger.warning(
"[advisor_comparison] failed to build comparison for run_dir=%s: %s",
run_dir, exc,
)
return None
```
- [ ] **Step 4: Run test to verify it passes**
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py -v`
Expected: `15 passed` (4 from Task 2 + 11 new)
- [ ] **Step 5: Commit**
```powershell
git add webapp/services/advisor_comparison.py tests/test_advisor_comparison.py
git commit -m "feat(advisor-comparison): add build_advisor_comparison diagnosis diffing"
```
---
### Task 4: Wire into `report_builder.build_report()`
**Files:**
- Modify: `webapp/services/report_builder.py`
- Test: `tests/test_webapp_report_builder.py` (extend)
**Interfaces:**
- Consumes: `advisor_comparison.build_advisor_comparison(run_dir, scenario_name, metrics) -> AdvisorComparison | None` from Task 3.
- Produces: `ReportData.advisor_comparison` correctly populated by `build_report()`, consumed by Task 5's frontend rendering.
- [ ] **Step 1: Write the failing test**
Add to `tests/test_webapp_report_builder.py`. First add these imports at the top (extend the existing import block):
```python
import json
from webapp.models import AdvisorComparison, AdvisorComparisonEntry
from webapp.services import report_builder
```
(`report_builder` may already be imported — if the line `from webapp.services import report_builder` already exists, do not duplicate it; only add the `json` import and the `webapp.models` import.)
Append these two tests to the end of the file:
```python
def test_build_report_attaches_advisor_comparison(tmp_path: Path, monkeypatch) -> None:
"""build_report wires advisor_comparison.build_advisor_comparison() into ReportData."""
run_dir = tmp_path / "run"
_write_run_artifacts(run_dir)
(run_dir / "metadata.json").write_text(
json.dumps({"run_id": "run-1", "scenario_name": "my-scenario"}), encoding="utf-8"
)
fake_comparison = AdvisorComparison(
previous_run_id="prev-1",
previous_finished_at="2026-01-01T00:00:00+00:00",
entries=[
AdvisorComparisonEntry(
metric="faithfulness",
status="resolved",
previous_score=0.5,
previous_severity="warning",
current_score=None,
current_severity=None,
)
],
)
captured_args = {}
def _fake_build(run_dir_arg, scenario_name_arg, metrics_arg):
captured_args["scenario_name"] = scenario_name_arg
captured_args["metrics"] = metrics_arg
return fake_comparison
monkeypatch.setattr(
report_builder.advisor_comparison, "build_advisor_comparison", _fake_build
)
report = build_report(run_dir, ["faithfulness", "context_recall"])
assert report.advisor_comparison == fake_comparison
assert captured_args["scenario_name"] == "my-scenario"
assert captured_args["metrics"] == ["faithfulness", "context_recall"]
def test_build_report_advisor_comparison_none_when_no_previous_run(
tmp_path: Path, monkeypatch
) -> None:
"""build_report leaves advisor_comparison as None when no predecessor exists."""
run_dir = tmp_path / "run"
_write_run_artifacts(run_dir)
(run_dir / "metadata.json").write_text(
json.dumps({"run_id": "run-1", "scenario_name": "my-scenario"}), encoding="utf-8"
)
monkeypatch.setattr(
report_builder.advisor_comparison, "build_advisor_comparison", lambda *a, **k: None
)
report = build_report(run_dir, ["faithfulness", "context_recall"])
assert report.advisor_comparison is None
```
- [ ] **Step 2: Run test to verify it fails**
Run: `C:\software\Python312\python.exe -m pytest tests/test_webapp_report_builder.py -v -k advisor_comparison`
Expected: FAIL — `AttributeError: module 'webapp.services.report_builder' has no attribute 'advisor_comparison'` (not imported yet in `report_builder.py`).
- [ ] **Step 3: Write minimal implementation**
In `webapp/services/report_builder.py`, find the imports:
```python
from webapp.services import question_history, run_reader
```
Replace with:
```python
from webapp.services import advisor_comparison, question_history, run_reader
```
Find the `build_report` function's early-return branch:
```python
def build_report(run_dir: Path, metrics: list[str]) -> ReportData:
"""Build the full aggregated report payload for one run directory."""
frame = run_reader.read_scores_frame(run_dir)
summary_markdown = run_reader.read_summary_markdown(run_dir)
advice_markdown = run_reader.read_advice_markdown(run_dir)
metric_weights, doc_weights = _read_weights_from_snapshot(run_dir)
# Read once up front so both the empty-frame and full branches can surface it.
metadata = run_reader._read_json(run_dir / "metadata.json")
token_usage = metadata.get("token_usage") or {}
if frame.empty or not metrics:
return ReportData(
metrics=metrics,
metric_means={metric: None for metric in metrics},
summary_markdown=summary_markdown,
advice_markdown=advice_markdown,
metric_weights=metric_weights,
doc_weights=doc_weights,
token_usage=token_usage,
)
```
Replace with (adds `advisor_comparison=None` explicitly in the early-return branch — a run with no valid samples cannot fairly support a diagnosis comparison):
```python
def build_report(run_dir: Path, metrics: list[str]) -> ReportData:
"""Build the full aggregated report payload for one run directory."""
frame = run_reader.read_scores_frame(run_dir)
summary_markdown = run_reader.read_summary_markdown(run_dir)
advice_markdown = run_reader.read_advice_markdown(run_dir)
metric_weights, doc_weights = _read_weights_from_snapshot(run_dir)
# Read once up front so both the empty-frame and full branches can surface it.
metadata = run_reader._read_json(run_dir / "metadata.json")
token_usage = metadata.get("token_usage") or {}
if frame.empty or not metrics:
return ReportData(
metrics=metrics,
metric_means={metric: None for metric in metrics},
summary_markdown=summary_markdown,
advice_markdown=advice_markdown,
metric_weights=metric_weights,
doc_weights=doc_weights,
token_usage=token_usage,
advisor_comparison=None,
)
```
Find the final `return ReportData(...)` at the end of the function:
```python
return ReportData(
metrics=metrics,
metric_means=rounded_means,
distributions=distributions,
groupings=_groupings(frame, metrics),
lowest_samples=_lowest_samples(frame, metrics, history_index),
summary_markdown=summary_markdown,
advice_markdown=advice_markdown,
weighted_score_mean=_round_or_none(overall_ws),
metric_weights=metric_weights,
doc_weights=doc_weights,
token_usage=token_usage,
)
```
Replace with:
```python
scenario_name = str(metadata.get("scenario_name") or "")
comparison = (
advisor_comparison.build_advisor_comparison(run_dir, scenario_name, metrics)
if scenario_name
else None
)
return ReportData(
metrics=metrics,
metric_means=rounded_means,
distributions=distributions,
groupings=_groupings(frame, metrics),
lowest_samples=_lowest_samples(frame, metrics, history_index),
summary_markdown=summary_markdown,
advice_markdown=advice_markdown,
weighted_score_mean=_round_or_none(overall_ws),
metric_weights=metric_weights,
doc_weights=doc_weights,
token_usage=token_usage,
advisor_comparison=comparison,
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `C:\software\Python312\python.exe -m pytest tests/test_webapp_report_builder.py -v`
Expected: all pass (existing tests + 2 new)
Also re-run the full advisor-comparison suite to confirm no regression:
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py tests/test_advisor_comparison_models.py -v`
Expected: `20 passed` (15 + 5)
- [ ] **Step 5: Commit**
```powershell
git add webapp/services/report_builder.py tests/test_webapp_report_builder.py
git commit -m "feat(advisor-comparison): wire build_advisor_comparison into report_builder"
```
---
### Task 5: Report detail page UI — "相比上次运行" panel
**Files:**
- Modify: `webapp/static/index.html`
- Modify: `webapp/static/js/report.js`
- Modify: `webapp/static/css/app.css`
**Interfaces:**
- Consumes: `ReportData.advisor_comparison` (JSON field `advisor_comparison` from Task 4's API response); `MetricPresenter.deltaInfo(metric, current, previous)` (existing, in `metric_presenter.js`); `App.escape`, `App.shortMetric`, `App.shortTime` (existing, in `app.js`).
This task has no automated test — the repository has no JavaScript test harness (vanilla JS, no `package.json`/Jest). Verify via `node --check` syntax validation per Step 4 below.
- [ ] **Step 1: Add the panel container to `index.html`**
In `webapp/static/index.html`, find:
```html
<!-- ⑤ 优化建议(optimization_advisor: true 时显示) -->
<div id="advice-section" hidden>
```
Replace with:
```html
<!-- 相比上次运行(同 scenario_name 的顾问诊断对比,自动匹配,找不到则不显示) -->
<div id="advisor-comparison-section" hidden>
<div class="panel advisor-comparison-panel">
<div class="advisor-comparison-header">
<span class="section-label tight">相比上次运行</span>
<span class="muted tiny" id="advisor-comparison-meta"></span>
</div>
<div id="advisor-comparison-body"></div>
</div>
</div>
<!-- ⑤ 优化建议(optimization_advisor: true 时显示) -->
<div id="advice-section" hidden>
```
- [ ] **Step 2: Add `renderAdvisorComparison` to `report.js` and call it from `render()`**
In `webapp/static/js/report.js`, find:
```js
Report.renderAdvice(detail.summary, detail.report);
Report.renderTokenUsage(detail.report);
content.style.opacity = "1";
```
Replace with:
```js
Report.renderAdvisorComparison(detail.report);
Report.renderAdvice(detail.summary, detail.report);
Report.renderTokenUsage(detail.report);
content.style.opacity = "1";
```
Add the new method right after `renderTokenUsage` (which itself sits right after `_drawGroupTable` — insert this new method immediately after `renderTokenUsage`'s closing `},`):
```js
// 相比上次运行的顾问诊断对比(同 scenario_name 的最近一次前序运行,自动匹配)。
renderAdvisorComparison(report) {
const section = document.getElementById("advisor-comparison-section");
const meta = document.getElementById("advisor-comparison-meta");
const body = document.getElementById("advisor-comparison-body");
if (!section || !meta || !body) return;
const comparison = report.advisor_comparison;
if (!comparison || !comparison.entries || comparison.entries.length === 0) {
section.hidden = true;
return;
}
section.hidden = false;
meta.textContent = `对比:${comparison.previous_run_id}${App.shortTime(comparison.previous_finished_at)}`;
const STATUS_LABEL = {
resolved: "✅ 已改善",
regressed: "⚠️ 新触发",
still_triggered: "❌ 仍未解决",
new_metric: "🆕 新增指标",
};
const STATUS_CLASS = {
resolved: "delta-good",
regressed: "delta-bad",
still_triggered: "delta-bad",
new_metric: "delta-flat",
};
const fmt = (v) => (v === null || v === undefined ? "—" : Number(v).toFixed(2));
let rows = "";
comparison.entries.forEach((entry) => {
const label = STATUS_LABEL[entry.status] || entry.status;
const cls = STATUS_CLASS[entry.status] || "delta-flat";
const d = MetricPresenter.deltaInfo(entry.metric, entry.current_score, entry.previous_score);
const deltaHtml = d.hasData && d.delta !== 0
? ` <span class="hist-delta ${d.cls}">${d.arrow}${d.magnitude}</span>`
: "";
rows += `
<div class="advisor-comparison-row">
<span class="advisor-comparison-status ${cls}">${label}</span>
<span class="advisor-comparison-metric">${App.escape(App.shortMetric(entry.metric))}</span>
<span class="advisor-comparison-scores">${fmt(entry.previous_score)}${fmt(entry.current_score)}${deltaHtml}</span>
</div>`;
});
let caveat = "";
if (comparison.judge_model_changed) {
caveat = `<p class="muted tiny">⚠️ judge_model 不同(${App.escape(comparison.previous_judge_model)}${App.escape(comparison.current_judge_model)}),对比仅供参考。</p>`;
}
body.innerHTML = rows + caveat;
},
```
- [ ] **Step 3: Add minimal CSS**
In `webapp/static/css/app.css`, add this block right after the existing `.hist-delta.delta-flat { color: var(--slate-light); }` line (found via the existing `.hist-delta`/`.delta-good`/`.delta-bad`/`.delta-flat` rules already in the file):
```css
.advisor-comparison-panel { border-left: 3px solid #0ea5e9; }
.advisor-comparison-header {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
margin-bottom: 10px;
}
.advisor-comparison-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 0; border-bottom: 1px solid var(--line);
font-size: 13px;
}
.advisor-comparison-row:last-of-type { border-bottom: none; }
.advisor-comparison-status { font-weight: 600; white-space: nowrap; }
.advisor-comparison-metric { flex: 1; color: var(--slate); }
.advisor-comparison-scores { font-variant-numeric: tabular-nums; white-space: nowrap; }
```
- [ ] **Step 4: Verify JS syntax and run full backend regression**
Run: `node --check webapp/static/js/report.js`
Expected: no output (syntax OK)
Run: `C:\software\Python312\python.exe -m pytest tests/test_advisor_comparison.py tests/test_advisor_comparison_models.py tests/test_webapp_report_builder.py -v`
Expected: all pass
- [ ] **Step 5: Commit**
```powershell
git add webapp/static/index.html webapp/static/js/report.js webapp/static/css/app.css
git commit -m "feat(advisor-comparison): add 相比上次运行 panel to report detail page"
```
---
## Final Verification
- [ ] **Run the full test suite once all 5 tasks are complete**
Run: `C:\software\Python312\python.exe -m pytest tests/ -v`
Expected: All tests pass, including the new `test_advisor_comparison_models.py` (5), `test_advisor_comparison.py` (15), plus the 2 new tests in `test_webapp_report_builder.py`, and all pre-existing tests unchanged (the 6 pre-existing unrelated failures documented in prior sessions — `test_settings_defaults`, `test_normalize_sample_pdf_offline_smoke_row`, `test_evaluator_and_reporting_write_run_assets`, `test_question_generator_rejects_invalid_json`, `test_question_generator_rejects_non_list_samples`, `test_execute_dataset_build_job_directly` — remain as-is and are out of scope for this plan).
- [ ] **Spec coverage check**
Confirm every section of `docs/superpowers/specs/2026-07-02-advisor-comparison-design.md` maps to a task:
- §2 架构 (webapp-layer read-only comparison, reusing `diagnose()`) → Tasks 2, 3
- §3.1 `advisor_comparison.py` functions and 4-state classification → Task 3
- §3.2 `webapp/models.py` new models + `ReportData` field → Task 1
- §4 数据流 (find previous run → diagnose both → classify → attach to ReportData → render) → Tasks 2, 3, 4, 5
- §5 错误处理 (no previous run / corrupt data / diagnose() exception / empty entries) → covered inline in Task 3's tests (`test_returns_none_when_no_previous_run`, `test_gracefully_returns_none_on_corrupt_previous_scores_csv`, `test_metrics_healthy_in_both_are_omitted`)
- §6 测试策略 (new test file + report_builder wiring tests, zero changes to existing advisor tests) → Tasks 2, 3, 4 (no `test_advisor_rules.py`/`test_advisor_llm_analyzer.py`/`test_advisor_writer.py` modified anywhere in this plan)
- §7 非目标 (no manual run picker, no persisted diagnoses.json, no rules.py changes, no cross-scenario comparison, no multi-hop chain) → respected throughout; confirmed no task touches `rag_eval/advisor/rules.py`, `llm_analyzer.py`, or `writer.py`