P10: Layer B finding-quality eval + all cycle-3 hardening (clean replay of #11) - #13
P10: Layer B finding-quality eval + all cycle-3 hardening (clean replay of #11)#13OrenAshkenazy wants to merge 4 commits into
Conversation
Replaces closed PR #11 (cap-reached after 3 cycles with 4 actionable findings unaddressed). This PR ships the same eval suite with all 15 of Gemini's findings across PR #11's 3 cycles ALREADY applied, plus the 4 final cycle-3 findings the cap prevented from landing. What ships: - evals/judge.py — JudgeClient + OpenAI gpt-4o-mini judge. All API calls wrapped in try/except → JudgeError. resp.choices guarded. Non-dict JSON payloads rejected. reason field str-coerced before .strip(). - evals/run_eval.py — CLI runner. --samples validated >= 1 up-front. --report path parent dir created. UTF-8 explicit on all I/O. main() catches JudgeError → exit code 2 with clean stderr message. FileNotFoundError on --fixture prints available fixtures. - evals/fixtures/ — 4 hand-labeled fixtures (PR #6/#7/#8/#9) with 11 ground-truth findings (10 useful + 1 false-positive). - evals/test_eval.py — 32 hermetic tests. Mocked judge factories. Covers parsing/error paths, majority voting, agreement metrics, confusion matrix, summary rendering, --samples validation, JudgeError handling in main, and end-to-end against the real fixtures with calibrated/uncalibrated fakes. - evals/README.md — cross-vendor rationale, fixture format, calibration workflow, cost. - .github/workflows/eval-weekly.yml — Sundays 00:00 UTC. Posts to rolling per-quarter Issue. 80% agreement CI gate. Test count: 56 plugin + 32 eval = 88. All hermetic, no network.
There was a problem hiding this comment.
Code Review
This pull request introduces a Layer B finding-quality evaluation framework under the evals/ directory. It features an LLM-as-judge system (using OpenAI's gpt-4o-mini by default) to rate Gemini Code Assist findings and compare them against hand-labeled human ground-truth fixtures. The PR includes the judge client, a CLI runner, a comprehensive pytest suite, and several initial fixtures. The code reviewer provided valuable feedback to improve the robustness and formatting of the generated reports, including handling backticks in markdown excerpts, formatting list outputs cleanly, and preserving UTF-8 characters in JSON dumps.
- body_excerpt: strip backticks. The excerpt is rendered inside a markdown code-span in render_summary's disagreement block; an unescaped backtick prematurely closes the span and breaks layout on any finding whose body mentions code in backticks (i.e. most of them). - Disagreement line: ', '.join(judge_labels) instead of the raw Python list repr (['useful', 'useful']). Cleaner markdown output. - JSON report: ensure_ascii=False on json.dumps. We already write utf-8, so escaping non-ASCII characters as \uXXXX is just noise in the report file. This lets findings/reasons containing emoji/non-English text round-trip readably.
|
@gemini-code-assist please review the latest changes. |
There was a problem hiding this comment.
Code Review
This pull request introduces a Layer B finding-quality evaluation suite under evals/ that uses an LLM-as-judge (OpenAI gpt-4o-mini by default) to rate Gemini Code Assist findings against human ground-truth labels. It includes several PR fixtures, a CLI runner, a weekly CI workflow, and unit tests. The reviewer feedback highlights three valuable improvement opportunities: adding a defensive check in majority_judge_label to handle empty judge_labels gracefully, specifying a timeout for the OpenAI API call to prevent the runner from hanging, and catching JudgeError during individual finding evaluations to skip problematic findings instead of aborting the entire run.
- judge.py: JudgeClient gains request_timeout (default 30s) and the OpenAI call passes timeout=self.request_timeout. Prevents a single hung request from blocking a multi-fixture eval run indefinitely. - run_eval.py: EvaluatedFinding.majority_judge_label returns 'unknown' on empty judge_labels instead of IndexError on Counter.most_common( 1)[0]. main() already validates --samples >= 1, but this property is also reachable from tests and downstream consumers — defense in depth. - run_eval.py: evaluate_fixture wraps each per-finding client.judge() in try/except JudgeError. A transient parse/network failure on one finding now skips that finding (with a stderr warning) and the rest of the corpus still produces metrics. Pre-fix, one bad finding aborted the whole run. Two new tests: - TestMajorityLabelEmptyIsSafe: empty judge_labels -> 'unknown'. - TestPerFindingJudgeErrorTolerance: first call raises, fixture evaluation continues without aborting; warning lands on stderr. Test count: 88 -> 90.
|
@gemini-code-assist please review the latest changes. |
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive LLM-as-judge evaluation framework under evals/ to rate Gemini Code Assist findings and compare them against human-labeled ground-truth fixtures. The review feedback correctly identifies two potential runtime crashes in evals/run_eval.py: a KeyError in confusion_matrix when a judge label falls outside of VALID_LABELS (such as "unknown"), and an IndexError in render_summary when attempting to access judge_reasons[0] on an empty list.
Bundles PR #13's maintainer-side Layer B eval with a public-facing surfacing layer so end-users actually see the calibration numbers when evaluating whether to install the plugin. Eval suite (from PR #13, all cycle-3 hardening already baked in): - evals/judge.py — OpenAI gpt-4o-mini judge with try/except on the API call, request_timeout, structured-skip-when-no-key, isinstance payload check, str() coerce on reason, defensive resp.choices. - evals/run_eval.py — runner with --samples >= 1 validation, per- finding JudgeError tolerance, JudgeError caught in main → exit 2, empty-rows safe metrics, --fixture friendly-error, utf-8 explicit on read_text/write_text, ensure_ascii=False on dumps, --report parent dir created, ', '.join over raw list repr, backtick-stripped body excerpts. - evals/fixtures/ — 4 hand-labeled fixtures (PRs #6–#9) with 11 ground-truth findings (10 useful + 1 false-positive). - evals/test_eval.py — 32 hermetic tests (mocked judge factories, no network). - evals/README.md — cross-vendor rationale, fixture format, calibration workflow, cost analysis. Surfacing layer (new): - evals/results/latest.md — placeholder until first run. The weekly workflow overwrites this each Sunday with the rendered report. - evals/results/YYYY-QQ.md — per-quarter history, append-only. - .github/workflows/eval-weekly.yml — refactored to commit rendered reports back to main (via RELEASE_TOKEN PAT, same as release.yml). Tracking-Issue notification kept; now references the committed results file. - README "Skill calibration (is this loop worth your money?)" section at the top: useful-rate, severity recommendation, cycle economy (60/30/10 across observed PRs), cost-per-loop ranges by Claude model. Links to evals/results/latest.md for fresh numbers. - Explicit note that the eval is maintainer-only and never ships to end-user installs. Eliminates "do I need OpenAI to use this?" confusion. PR #13 stays open as a reference / alternative. This PR is the one to merge for end-user value. Test count: 56 plugin + 32 eval = 88. All hermetic.
* P11: eval suite + end-user surfacing layer Bundles PR #13's maintainer-side Layer B eval with a public-facing surfacing layer so end-users actually see the calibration numbers when evaluating whether to install the plugin. Eval suite (from PR #13, all cycle-3 hardening already baked in): - evals/judge.py — OpenAI gpt-4o-mini judge with try/except on the API call, request_timeout, structured-skip-when-no-key, isinstance payload check, str() coerce on reason, defensive resp.choices. - evals/run_eval.py — runner with --samples >= 1 validation, per- finding JudgeError tolerance, JudgeError caught in main → exit 2, empty-rows safe metrics, --fixture friendly-error, utf-8 explicit on read_text/write_text, ensure_ascii=False on dumps, --report parent dir created, ', '.join over raw list repr, backtick-stripped body excerpts. - evals/fixtures/ — 4 hand-labeled fixtures (PRs #6–#9) with 11 ground-truth findings (10 useful + 1 false-positive). - evals/test_eval.py — 32 hermetic tests (mocked judge factories, no network). - evals/README.md — cross-vendor rationale, fixture format, calibration workflow, cost analysis. Surfacing layer (new): - evals/results/latest.md — placeholder until first run. The weekly workflow overwrites this each Sunday with the rendered report. - evals/results/YYYY-QQ.md — per-quarter history, append-only. - .github/workflows/eval-weekly.yml — refactored to commit rendered reports back to main (via RELEASE_TOKEN PAT, same as release.yml). Tracking-Issue notification kept; now references the committed results file. - README "Skill calibration (is this loop worth your money?)" section at the top: useful-rate, severity recommendation, cycle economy (60/30/10 across observed PRs), cost-per-loop ranges by Claude model. Links to evals/results/latest.md for fresh numbers. - Explicit note that the eval is maintainer-only and never ships to end-user installs. Eliminates "do I need OpenAI to use this?" confusion. PR #13 stays open as a reference / alternative. This PR is the one to merge for end-user value. Test count: 56 plugin + 32 eval = 88. All hermetic. * fix: 3 cycle-0 medium defensive findings on PR #14 - run_eval.py: discover_fixtures sorts fixtures NUMERICALLY by PR number instead of alphabetically. Pre-fix, pr-10 sorted before pr-6 (lex order); post-fix, _pr_sort_key extracts the int after 'pr-' and falls back to 0 for unparseable stems. Pure UX fix — intuitive ordering in the eval summary and per-fixture iteration. - run_eval.py: render_summary guards r.judge_reasons[0] access. judge_reasons can be empty if every sample raised JudgeError (per-finding tolerance path), and the disagreement-rendering loop would IndexError without this guard. Falls back to a "(no judge reason recorded)" sentinel. - judge.py: distinguish None vs falsy in payload.get("reason"). The prior `payload.get("reason") or ""` collapsed 0 / False / [] / empty-string into "", which is wrong if a model happens to return a numeric reason field. New form: check None explicitly, then str().strip() — preserves falsy-but-valid values while keeping the defensive type coercion. * P12: optional end-user OpenAI judge (--judge-mode), opt-in, read-only End-users can now opt into a per-finding OpenAI judge that labels each Gemini finding (valid_actionable / false_positive / needs_human / explanation_only / duplicate / already_addressed) plus severity_override and recommended_action. The label appears next to each finding in the markdown output and is included in JSON loopStatus.judge + judgeResults. Design principles (per the design feedback round): - DEFAULT OFF. Nothing sent to OpenAI until user opts in. - SCRIPT IS SOURCE OF TRUTH for preferences. The agent only writes the prefs file once during first-run setup; the script reads on every invocation. No LLM drift between runs. - JUDGE IS READ-ONLY. Cannot resolve threads, post comments, or push. Enforced by judge.py not importing subprocess/gh/GraphQL paths, and by a test that grep-asserts the absence. - PRIVACY DISCLOSED at the first-run prompt. SKILL.md documents the exact wording the agent uses. - GRACEFUL SKIP. No OPENAI_API_KEY / no openai SDK → JudgeClient.judge() returns a structured JudgeResult(status="skipped", skip_reason=...) instead of raising. The loop continues unchanged. What ships: - plugins/.../scripts/judge.py — new module. JudgeClient with cross- vendor OpenAI default (gpt-4o-mini), request_timeout (30s), call_fn injection for tests. Preferences helpers (load/save/path) backed by ~/.config/gh-gemini-review-loop/preferences.json. should_judge_run() encapsulates the dispatch logic so the agent doesn't replicate it. - fetch_gemini_threads.py — new flags --judge-mode {off,on-cycle, on-complete,once}, --judge-phase {cycle,complete}, --judge-model. After page_warnings, script reads prefs + judge_phase, decides whether to run judge, iterates threads, embeds results into the markdown header (> **Judge:** <verdict> ... line) and the JSON output (loopStatus.judge + top-level judgeResults keyed by thread_id). sys.path inserts the script's own directory so `from judge import ...` works under /plugin install. - SKILL.md — new "Optional OpenAI Judge" section with the first-run setup prompt (privacy disclosure mandatory). Variations table gains 4 rows for run-once / off / change-preference / default flow. - README — new section explains the feature, the default-off policy, cost (~$0.001/finding with gpt-4o-mini), and that the judge is read-only. - CHANGELOG entry under [Unreleased] / Added. Tests: 34 new hermetic pytest cases in tests/test_judge.py. Covers: - prefs file missing / valid / corrupt / non-dict / unknown-mode / unknown-schema-version, save/load roundtrip, save validation, parent-dir creation - should_judge_run dispatch matrix (off, once, on-cycle, on-complete, unknown mode × cycle/complete/None phase) - JudgeClient readiness (skips when no key, returns structured skipped result, ready with call_fn) - JudgeClient parse paths (happy, invalid verdict, invalid JSON, non-dict payload, invalid severity_override → "none", invalid recommended_action → "ignore", confidence clamping, falsy reason preserved via explicit None check) - build_user_prompt (omits/includes diff block, handles missing fields) - Judge invariant: no mutation methods on the class; judge.py source does not import subprocess / gh api / GraphQL mutation strings. Test count: 56 plugin + 32 eval + 34 judge = 124. All hermetic, no network. * fix: 2 cycle-2 medium defensive findings (hand-edited fixture defenses) PR #14 cycle-2 review caught two real edge cases in the hand-labeled fixture path: - evals/run_eval.py: validate human_label against VALID_LABELS up-front in evaluate_fixture. Maintainers hand-edit the .label.json files; a typo like 'usefull' / 'false-postiive' previously caused a KeyError in confusion_matrix on the line `matrix[r.human_label][...]`. Now skips with a stderr warning that names the invalid label and the comment_id, so the maintainer can fix the typo. - evals/run_eval.py: main() catches json.JSONDecodeError on fixture load and exits 1 with a clean message ('failed to parse JSON in fixture <stem>: <decode error>'). Previously a stray comma in any fixture or label file bubbled up as a raw traceback. The friendly message points the maintainer at the broken file by name. Two new pytest cases cover both paths via tmp_path-injected fixtures (monkeypatch FIXTURES_DIR), so the tests run hermetically without modifying the real evals/fixtures/. Test count: 124 -> 126. * fix: 4 cycle-3 medium defensive findings (post-cap follow-on) PR #14 cap reached at 3/3 cycles. Final Gemini review surfaced 4 new real defensive issues + 2 stale re-flags + 1 duplicate. Applying the 4 new ones as a post-cap follow-on commit (skill spec: doc/code commits after cap-reached don't resume the loop; no re-review ping). - plugins/.../scripts/judge.py is_ready(): probes for `from openai import OpenAI` (not just `import openai`) so older v0.x installs return False with a clear "need v1.0.0+" message instead of letting _openai_call ImportError on the OpenAI class import later. - plugins/.../scripts/judge.py _openai_call(): unified try/except now covers import + client construction + API call, so any of (stale SDK / auth failure during OpenAI() init / transient network) surfaces uniformly as JudgeError to the runner. - evals/judge.py _openai_call(): same consolidation. ImportError branch retained as a sub-clause with the "need v1.0.0+" hint. - tests/test_judge.py: read_text(encoding="utf-8") on judge.py source so the invariant test doesn't UnicodeDecodeError on Windows CP1252 or older CI locales (judge.py has em-dashes / arrows / non-ASCII characters in docstrings). Stale-thread cleanup: replied ADDRESSED_BY_REPLY to: - run_eval.py:127 human-label validation (already in f5b5a7b) - run_eval.py:374 JSONDecodeError handling (already in f5b5a7b) - test_judge.py:331 encoding duplicate (addressed in this commit) * fix: judge_mode values use snake_case + 4-option first-run prompt Per the agreed UX: first-run prompt offers four mutually exclusive choices (Every cycle / At completion only / Just this once / Off) and the persisted data field uses snake_case (on_cycle / on_complete / off / once), matching the agreed schema: { "schema_version": 1, "judge_mode": "on_complete" } Changes: - VALID_JUDGE_MODES rewritten to ("off","on_cycle","on_complete","once"). - should_judge_run dispatch comparisons updated. - --judge-mode CLI choices updated. - SKILL.md first-run prompt rewritten to the 4-option script the user agreed on, with explicit "Just this once" handling (do NOT persist; pass --judge-mode once for this invocation only). - Variations table + cost note updated to new spelling. - 4 doc files bulk-replaced (SKILL.md, README.md, CHANGELOG.md, tests/test_judge.py): on-cycle → on_cycle, on-complete → on_complete. - All 126 tests still pass; ruff clean. Note: CLI flag name stays kebab-case (--judge-mode) per Python argparse convention; only the *values* and stored field use snake_case. Post-cap follow-on (PR #14 at 3/3 cycles); no re-review ping. * fix: natural-language judge invocation bypasses first-run prompt Two UX gaps in the judge onboarding flow: 1. First-run prompt fired even when the user's invocation already expressed a judge mode ("with judge eval at completion" etc.). The agent now detects inline mode expressions and applies them directly (save + run) without an AskUserQuestion detour. 2. Variations table had no rows for the primary natural-language patterns. Added four new rows: - "with judge eval at completion" → save on_complete + --judge-phase complete - "with judge eval on every cycle" → save on_cycle + --judge-phase cycle - "with judge eval just this once" → --judge-mode once, no save - "enable judge eval" (no mode) → trigger first-run prompt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: rewrite judge eval UX — tip-based discovery, no proactive prompt Previous design asked users about judge mode on first loop invocation, which violated the privacy principle ("never enable silently") by front-loading a consent question before users understood the feature. New design: - Normal loop: judge off, no prompt, no interruption. - After first successful loop completion: one-time passive tip emitted to chat ("optional judge eval is available..."). Persisted via judge_tip_shown in prefs so it never repeats. - Prompt only fires when user explicitly requests eval without a mode ("enable judge eval" / "use judge eval" / "turn on eval"). - Natural language phrasings with inline mode bypass the prompt entirely: "with judge eval at completion" → save on_complete + run; "with judge eval on every cycle" → save on_cycle + run; "just this once" → run once, no save. - "what is judge eval?" → explain only, never enable. - Preference file gains judge_tip_shown boolean field. Variations table updated to match; first-run-setup section removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: judge_tip_shown prefs field + mark_tip_shown() helper Supports the new discoverability UX: after first successful loop, agent emits one passive tip and calls mark_tip_shown() so it never repeats. Fields added: - load_preferences() returns judge_tip_shown (bool, default False) - save_preferences() preserves existing judge_tip_shown value - mark_tip_shown() flips judge_tip_shown=True without touching judge_mode or other fields - _default_prefs() initializes judge_tip_shown: False 4 new tests: roundtrip includes tip_shown, save preserves tip_shown across mode changes, mark_tip_shown idempotent, default is False. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: move judge eval tip to after-fetch — fires when user sees findings Previous: tip fired after loop completion (easy to scroll past, user already done thinking about findings). New: tip fires on first cycle with actionable findings, immediately after the findings narration line and before any fixes are applied. This is the moment the user is focused on what Gemini found — the most contextually relevant time to surface judge eval. Tip still appears exactly once (judge_tip_shown persists via mark_tip_shown()). Narration table updated with the reference. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: OPENAI_API_KEY setup — macOS Keychain best practice Added to both README and SKILL.md: store the key in macOS Keychain (security add-generic-password) and load it in ~/.zshrc so it's available to all apps including Claude Code plugins. Avoids plaintext key in dotfiles. SKILL.md also covers Linux alternative. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: sharpen plugin positioning * docs: clarify judge eval value * docs: align changelog with PR scope --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Address Gemini Code Assist review on PR #13: - confusion_matrix: skip labels not in VALID_LABELS so a "unknown" majority label (empty judge_labels) can't KeyError mid-run. - render_summary: fall back to "N/A" when judge_reasons is empty instead of IndexError on [0]. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@gemini-code-assist please review the latest changes. |
There was a problem hiding this comment.
Code Review
This pull request introduces a Layer B finding-quality evaluation framework under evals/ that uses an LLM-as-judge (OpenAI gpt-4o-mini) to rate Gemini Code Assist findings and compare them against human-labeled ground-truth fixtures. It includes the judge client, a CLI runner, unit tests, and initial fixtures. The review feedback focuses on defensive hardening of the runner, suggesting safer JSON parsing and file I/O error handling, explicit type validation of nested configuration keys, and explicit None checks instead of falsy checks when coercing external payload values.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def load_fixture_pair(pr_id: str) -> tuple[dict, dict]: | ||
| """Load (findings, labels) for a fixture, keyed on filename stem like 'pr-6'.""" | ||
| findings_path = FIXTURES_DIR / f"{pr_id}.json" | ||
| labels_path = FIXTURES_DIR / f"{pr_id}.label.json" | ||
| if not findings_path.exists(): | ||
| raise FileNotFoundError(f"Missing findings fixture: {findings_path}") | ||
| if not labels_path.exists(): | ||
| raise FileNotFoundError( | ||
| f"Missing human-label sidecar: {labels_path}. " | ||
| "Every fixture needs a `.label.json` for the eval to compute agreement." | ||
| ) | ||
| # Explicit utf-8 encoding so platforms with non-UTF8 locale defaults (some | ||
| # Windows + older CI runners) don't decode-error on Unicode in finding bodies. | ||
| return ( | ||
| json.loads(findings_path.read_text(encoding="utf-8")), | ||
| json.loads(labels_path.read_text(encoding="utf-8")), | ||
| ) |
There was a problem hiding this comment.
When reading and parsing JSON files, it is important to catch both OSError and ValueError (which covers json.JSONDecodeError and UnicodeDecodeError) to safely handle file system issues and decoding/parsing errors. Additionally, specifying errors='replace' when reading text files prevents crashes on invalid UTF-8 bytes.
| def load_fixture_pair(pr_id: str) -> tuple[dict, dict]: | |
| """Load (findings, labels) for a fixture, keyed on filename stem like 'pr-6'.""" | |
| findings_path = FIXTURES_DIR / f"{pr_id}.json" | |
| labels_path = FIXTURES_DIR / f"{pr_id}.label.json" | |
| if not findings_path.exists(): | |
| raise FileNotFoundError(f"Missing findings fixture: {findings_path}") | |
| if not labels_path.exists(): | |
| raise FileNotFoundError( | |
| f"Missing human-label sidecar: {labels_path}. " | |
| "Every fixture needs a `.label.json` for the eval to compute agreement." | |
| ) | |
| # Explicit utf-8 encoding so platforms with non-UTF8 locale defaults (some | |
| # Windows + older CI runners) don't decode-error on Unicode in finding bodies. | |
| return ( | |
| json.loads(findings_path.read_text(encoding="utf-8")), | |
| json.loads(labels_path.read_text(encoding="utf-8")), | |
| ) | |
| def load_fixture_pair(pr_id: str) -> tuple[dict, dict]: | |
| """Load (findings, labels) for a fixture, keyed on filename stem like 'pr-6'.""" | |
| findings_path = FIXTURES_DIR / f"{pr_id}.json" | |
| labels_path = FIXTURES_DIR / f"{pr_id}.label.json" | |
| if not findings_path.exists(): | |
| raise FileNotFoundError(f"Missing findings fixture: {findings_path}") | |
| if not labels_path.exists(): | |
| raise FileNotFoundError( | |
| f"Missing human-label sidecar: {labels_path}. " | |
| "Every fixture needs a `.label.json` for the eval to compute agreement." | |
| ) | |
| # Explicit utf-8 encoding so platforms with non-UTF8 locale defaults (some | |
| # Windows + older CI runners) don't decode-error on Unicode in finding bodies. | |
| try: | |
| return ( | |
| json.loads(findings_path.read_text(encoding="utf-8", errors="replace")), | |
| json.loads(labels_path.read_text(encoding="utf-8", errors="replace")), | |
| ) | |
| except (OSError, ValueError) as exc: | |
| raise JudgeError(f"Failed to read or parse fixture files for {pr_id}: {exc}") from exc |
References
- When parsing JSON files, catch ValueError (which covers json.JSONDecodeError) and OSError to safely handle both decoding/parsing errors and file system issues.
- When reading text files that may contain invalid UTF-8 bytes, use errors='replace' and catch both OSError and ValueError (which covers UnicodeDecodeError) to prevent crashes.
| findings_doc, labels_doc = load_fixture_pair(stem) | ||
| pr = findings_doc["pr"] | ||
| label_by_id = {lbl["comment_id"]: lbl for lbl in labels_doc["human_labels"]} | ||
|
|
||
| out: list[EvaluatedFinding] = [] | ||
| for finding in findings_doc["findings"]: | ||
| cid = finding["comment_id"] | ||
| human = label_by_id.get(cid) | ||
| if human is None: | ||
| print( | ||
| f"warning: PR #{pr} finding {cid} has no human label; skipping.", | ||
| file=sys.stderr, | ||
| ) | ||
| continue |
There was a problem hiding this comment.
When parsing configuration files (such as JSON), explicitly validate the types of nested keys before performing membership checks or type-specific operations to prevent TypeError crashes. Additionally, when validating that a dictionary field is a non-empty list, use isinstance(val, list) and val to prevent type errors from non-list truthy values while safely excluding empty lists.
findings_doc, labels_doc = load_fixture_pair(stem)
pr = findings_doc.get("pr")
if not isinstance(pr, int):
raise JudgeError(f"Fixture {stem} is missing a valid integer 'pr' field.")
human_labels = labels_doc.get("human_labels")
if not (isinstance(human_labels, list) and human_labels):
raise JudgeError(f"Fixture sidecar {stem}.label.json is missing a non-empty 'human_labels' list.")
findings = findings_doc.get("findings")
if not (isinstance(findings, list) and findings):
raise JudgeError(f"Fixture {stem}.json is missing a non-empty 'findings' list.")
label_by_id = {}
for lbl in human_labels:
if isinstance(lbl, dict) and "comment_id" in lbl:
label_by_id[lbl["comment_id"]] = lbl
out: list[EvaluatedFinding] = []
for finding in findings:
if not isinstance(finding, dict):
continue
cid = finding.get("comment_id")
if not cid:
continue
human = label_by_id.get(cid)
if human is None:
print(
f"warning: PR #{pr} finding {cid} has no human label; skipping.",
file=sys.stderr,
)
continueReferences
- When parsing configuration files (such as JSON), explicitly validate the types of nested keys before performing membership checks or type-specific operations to prevent TypeError crashes.
- When validating that a dictionary field is a non-empty list, use isinstance(val, list) and val to prevent type errors from non-list truthy values (like True or non-empty strings) while safely excluding empty lists.
| out.append( | ||
| EvaluatedFinding( | ||
| pr=pr, | ||
| comment_id=cid, | ||
| severity=finding.get("severity"), | ||
| path=finding.get("path"), | ||
| line=finding.get("line"), | ||
| human_label=human["label"], | ||
| judge_labels=[r.label for r in judge_results], | ||
| judge_confidences=[r.confidence for r in judge_results], | ||
| judge_reasons=[r.reason for r in judge_results], | ||
| # Strip backticks: the excerpt is rendered inside backticks in | ||
| # render_summary's disagreement block, and an unescaped backtick | ||
| # would prematurely close the markdown code span. | ||
| body_excerpt=(finding.get("body") or "")[:120].replace("\n", " ").replace("`", "'"), | ||
| ) | ||
| ) |
There was a problem hiding this comment.
When defaulting or coercing values from external payloads (such as JSON findings), check for None explicitly rather than using falsy checks (e.g., val or "") to preserve valid falsy values like 0, False, or empty strings, while still allowing defensive type coercion.
severity = finding.get("severity")
path = finding.get("path")
body_val = finding.get("body")
label_val = human.get("label")
out.append(
EvaluatedFinding(
pr=pr,
comment_id=cid,
severity=str(severity) if severity is not None else None,
path=str(path) if path is not None else None,
line=finding.get("line"),
human_label=str(label_val) if label_val is not None else "unknown",
judge_labels=[r.label for r in judge_results],
judge_confidences=[r.confidence for r in judge_results],
judge_reasons=[r.reason for r in judge_results],
# Strip backticks: the excerpt is rendered inside backticks in
# render_summary's disagreement block, and an unescaped backtick
# would prematurely close the markdown code span.
body_excerpt=str(body_val)[:120].replace("\n", " ").replace("`", "'") if body_val is not None else "",
)
)References
- When defaulting or coercing values from external payloads (such as LLM responses), check for None explicitly rather than using falsy checks (e.g., val or "") to preserve valid falsy values like 0, False, or empty strings, while still allowing defensive type coercion.
| if args.report: | ||
| report = { | ||
| "metrics": m, | ||
| "rows": [dataclasses.asdict(r) for r in rows], | ||
| "summary_md": summary, | ||
| } | ||
| report_path = pathlib.Path(args.report) | ||
| # Create parent dirs so users can pass nested paths like /tmp/eval/report.json. | ||
| report_path.parent.mkdir(parents=True, exist_ok=True) | ||
| # ensure_ascii=False lets non-ASCII characters in findings/reasons | ||
| # write through as utf-8 instead of \uXXXX escapes. Cheaper to read. | ||
| report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") | ||
| print(f"\nReport written to {args.report}", file=sys.stderr) |
There was a problem hiding this comment.
When wrapping best-effort operations (such as disk I/O) in a try-except block, ensure the exception handler returns early (e.g., returning a success or default status) instead of falling through if subsequent code references variables that would remain unbound, thereby preventing a NameError.
if args.report:
report = {
"metrics": m,
"rows": [dataclasses.asdict(r) for r in rows],
"summary_md": summary,
}
report_path = pathlib.Path(args.report)
try:
# Create parent dirs so users can pass nested paths like /tmp/eval/report.json.
report_path.parent.mkdir(parents=True, exist_ok=True)
# ensure_ascii=False lets non-ASCII characters in findings/reasons
# write through as utf-8 instead of \uXXXX escapes. Cheaper to read.
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\nReport written to {args.report}", file=sys.stderr)
except OSError as exc:
print(f"error: failed to write report to {args.report}: {exc}", file=sys.stderr)
return 1References
- When wrapping best-effort operations (such as disk I/O) in a try-except block, ensure the exception handler returns early (e.g., returning a success or default status) instead of falling through if subsequent code references variables that would remain unbound, thereby preventing a NameError.
Summary
Clean replacement for closed PR #11. The original P8 PR cycled through 3 reviews on its own eval-suite code, hit the cap, and stopped with 4 actionable defensive-hardening findings unaddressed. This PR ships the exact same eval suite with all 15 of those findings already applied — and adds the 4 final cycle-3 hardening fixes the cap blocked.
Net: zero unaddressed Gemini findings on day-one merge.
What ships
evals/— finding-quality evalLLM-as-judge over Gemini's findings. Cross-vendor (OpenAI judges Gemini) to reduce self-eval bias. Default model
gpt-4o-mini(~$0.02/run), overridable viaOPENAI_JUDGE_MODEL.evals/judge.py—JudgeClientwraps OpenAI chat completions. All API calls wrapped intry/except → JudgeErrorso transient API/auth/network failures get a clean stderr message instead of a raw SDK traceback.resp.choicesnon-empty check. Non-dict JSON payloads rejected.reasonfieldstr()-coerced before.strip().evals/run_eval.py— CLI runner.--samplesvalidated>= 1up-front (was:0or negative would IndexError inmajority_judge_label).main()catchesJudgeError, exits with code2+ clean stderr message. UTF-8 explicit on everyread_text/write_text.--reportpath parent dir created if missing.--fixturetypo prints available stems instead of a raw traceback.evals/test_eval.py— 32 hermetic pytest cases. Mocked judge factories (fake_constant,fake_sequence). Covers: JudgeClient parsing/error paths, majority voting, agreement metrics, confusion matrix, summary rendering, end-to-end against real fixtures with calibrated/uncalibrated fakes,--samplesvalidation,JudgeErrorhandling inmain().evals/fixtures/— 4 hand-labeled fixtures (PRs P3: --min-severity flag, Variations table, fast-track README #6/P4: --sticky-receipt for live PR-comment visibility #7/P5: auto version bump + tag + GitHub Release on PR merge #8/P6: use RELEASE_TOKEN PAT in release workflow (unblocks v0.1.1) #9) with 11 ground-truth findings (10 useful + 1 false-positive — PR P5: auto version bump + tag + GitHub Release on PR merge #8's CHANGELOG misread).evals/README.md— cross-vendor rationale, fixture format, calibration workflow, cost analysis (~$0.02 per run), miscalibration sanity checks..github/workflows/eval-weekly.yml— weekly CIworkflow_dispatch.OPENAI_API_KEYrepo secret + optionalOPENAI_JUDGE_MODELvar.Why a clean replay instead of merging #11
PR #11 was a meta-PR: an eval suite reviewed by the bot it evaluates. Defensive Python in API-calling code attracts maximum Gemini scrutiny. Each round of hardening yields fresh suggestions for more hardening. After 3 cycles the loop is supposed to stop and hand to a human — and it did. Rather than merge with 4 known follow-ups, this PR rolls them in cleanly.
The 4 cycle-3 findings now applied:
judge.py:135— Wrap OpenAI API call in try/except so transient failures raiseJudgeError(uniform error surface for the runner).run_eval.py:27— ImportJudgeErrorfor catching inmain().run_eval.py:286— Validate--samples >= 1. 0/negative previously crashedmajority_judge_labelwithIndexError.run_eval.py:314— CatchJudgeErrorinmain(), exit code 2 with stderr message.Test plan
pytest evals/ tests/→ 88/88 pass (was 56 plugin + 32 eval; was 56+28 in P8: Layer B finding-quality eval (LLM-as-judge, cross-vendor) #11)ruff check plugins/ tests/ evals/→ cleanpython3 -m evals.run_eval --judge fake --samples 0→ exit 1, friendly messagepython3 -m evals.run_eval --judge fake --fixture pr-8→ exit 0, sensible metricsOPENAI_API_KEYrepo secret, manually triggerWeekly finding-quality evalonce to confirm end-to-end against the real OpenAI API + tracking-issue creation. Subsequent runs fire automatically every Sunday.Release
Default patch bump → v0.1.4 on merge.
🤖 Generated with Claude Code