Files
Edward Cheng-I Wu e90a98d321 ci(eval-harness): render PR comment as verdict + table, fold raw JSON (#479)
* ci(eval-harness): render PR comment as verdict + table, fold raw JSON

The eval-harness workflow pasted the entire eval_report.json into every PR
comment as one raw fenced block. Replace that with a unit-tested display
module (scripts/render_eval_comment.py): a one-line verdict (measured
passed/total + pending count), a per-task markdown table (metric, value,
threshold, result), and the full JSON folded into <details>.

Display layer only — run_evals, scripts._eval_threshold_gate, the gate step,
and the [eval-regression-acknowledged] ack contract are untouched. The row
verdict mirrors the gate's failure signal (aggregate AND per-class, #328) so
the table never shows a clean pass on a run the gate blocks.

Registered in scripts/_ci_pytest_manifest.toml; workflow test extended to
pin the renderer so the comment can't silently regress to a raw dump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jcH7gEkVTQ1baMbVedSZp

* fix(eval-comment): escape pipes/newlines in table cells (codex P2)

task_name / metric / comparison come from eval manifests; a `|` or newline
in them would break the markdown table or spoof extra rows/results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jcH7gEkVTQ1baMbVedSZp

* refactor(eval-comment): /simplify pass — \r line boundaries, fd hygiene, gate-agreement pin

- _cell now normalizes ALL line boundaries via splitlines() (codex re-review:
  \r could still split a table row), not just \n.
- main() reads the report via a context manager (ResourceWarning hygiene).
- New test pins that _task_failures agrees with _eval_threshold_gate.failed_tasks
  on which tasks fail, so the deliberate display-side mirror of the gate's
  failure rule drifts loudly (CI fail) instead of silently rendering green on
  a blocked run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jcH7gEkVTQ1baMbVedSZp

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:33:41 +08:00

131 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Render a ``run_evals`` report as the eval-harness PR comment markdown.
Display layer ONLY (#184 follow-up): the workflow used to paste the whole
``eval_report.json`` into the PR comment as one raw fenced block. This module
renders the same report as a one-line verdict + a per-task table, with the
full JSON folded into a ``<details>`` block. The threshold verdict itself is
computed by ``scripts._eval_threshold_gate`` — this renderer never gates.
Kept out of the workflow YAML (no inline heredoc) per the same house rule as
the gate module; see scripts/test_eval_harness_workflow.py.
CLI::
python -m scripts.render_eval_comment <report.json>
prints the comment markdown to stdout.
"""
from __future__ import annotations
import json
import sys
from typing import Any
_COMPARISON_SYMBOLS = {">=": "", "<=": "", "==": "="}
_PENDING_RESULT = "⏸️ pending"
def _fmt_value(value: Any) -> str:
if isinstance(value, (int, float)):
return f"{value:.2f}"
return str(value)
def _cell(text: Any) -> str:
"""Markdown-table-safe cell: report strings (task_name, metric, comparison)
come from eval manifests, so a `|` or any line boundary (\\n, \\r, U+2028…)
would break the table or spoof extra rows/results."""
return " ".join(str(text).splitlines()).replace("|", "\\|")
def _task_failures(task: dict[str, Any]) -> tuple[bool, bool]:
"""(aggregate_failed, per_class_failed) — mirrors the gate's failure signal."""
agg = task.get("aggregate_metric") or {}
agg_failed = agg.get("passed") is False
pc_failed = any(pc.get("passed") is False for pc in task.get("per_class", []))
return agg_failed, pc_failed
def _table_row(task: dict[str, Any]) -> str:
name = _cell(task.get("task_name", "?"))
if task.get("status", "measured") != "measured":
return f"| {name} | — | — | — | {_PENDING_RESULT} |"
agg = task.get("aggregate_metric") or {}
metric = _cell(agg.get("metric", ""))
value = _cell(_fmt_value(agg["value"])) if "value" in agg else ""
if "threshold_value" in agg:
comparison = agg.get("comparison", ">=")
symbol = _COMPARISON_SYMBOLS.get(comparison, comparison)
threshold = _cell(f"{symbol} {_fmt_value(agg['threshold_value'])}")
else:
threshold = ""
agg_failed, pc_failed = _task_failures(task)
if agg_failed:
result = ""
elif pc_failed:
# Aggregate met its threshold but a per-class metric did not — the gate
# still blocks (#328), so the row must not show a clean pass.
result = "❌ (per-class)"
else:
result = ""
return f"| {name} | {metric} | {value} | {threshold} | {result} |"
def render_comment(report: dict[str, Any], raw_json: str) -> str:
tasks = report.get("per_task", [])
measured = [t for t in tasks if t.get("status", "measured") == "measured"]
pending = [t for t in tasks if t.get("status", "measured") != "measured"]
passed = [t for t in measured if not any(_task_failures(t))]
if not measured:
verdict = "⏸️ no measured tasks"
else:
emoji = "" if len(passed) == len(measured) else ""
verdict = f"{emoji} {len(passed)}/{len(measured)} measured tasks passed"
if pending:
verdict += f" · {len(pending)} pending (not wired)"
lines = [
"## Eval harness results",
"",
verdict,
"",
"| Task | Metric | Value | Threshold | Result |",
"| --- | --- | --- | --- | --- |",
]
lines.extend(_table_row(t) for t in measured + pending)
lines.extend(
[
"",
"<details>",
"<summary>Raw JSON</summary>",
"",
"```json",
raw_json.rstrip("\n"),
"```",
"",
"</details>",
"",
]
)
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
if len(args) != 1:
print("usage: python -m scripts.render_eval_comment <report.json>",
file=sys.stderr)
return 2
with open(args[0], encoding="utf-8") as fh:
raw_json = fh.read()
report = json.loads(raw_json)
print(render_comment(report, raw_json))
return 0
if __name__ == "__main__":
sys.exit(main())