Skip to content

P10: Layer B finding-quality eval + all cycle-3 hardening (clean replay of #11) - #13

Open
OrenAshkenazy wants to merge 4 commits into
mainfrom
p10/eval-suite-hardened
Open

P10: Layer B finding-quality eval + all cycle-3 hardening (clean replay of #11)#13
OrenAshkenazy wants to merge 4 commits into
mainfrom
p10/eval-suite-hardened

Conversation

@OrenAshkenazy

Copy link
Copy Markdown
Owner

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 eval

LLM-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 via OPENAI_JUDGE_MODEL.

  • evals/judge.pyJudgeClient wraps OpenAI chat completions. All API calls wrapped in try/except → JudgeError so transient API/auth/network failures get a clean stderr message instead of a raw SDK traceback. resp.choices non-empty check. Non-dict JSON payloads rejected. reason field str()-coerced before .strip().
  • evals/run_eval.py — CLI runner. --samples validated >= 1 up-front (was: 0 or negative would IndexError in majority_judge_label). main() catches JudgeError, exits with code 2 + clean stderr message. UTF-8 explicit on every read_text / write_text. --report path parent dir created if missing. --fixture typo 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, --samples validation, JudgeError handling in main().
  • 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 CI

  • Sundays 00:00 UTC + workflow_dispatch.
  • 3 samples per finding for variance measurement.
  • Posts summary to a rolling per-quarter GitHub Issue.
  • Fails the run if judge↔human agreement drops below 80% (calibration gate).
  • Catches drift in Gemini's output format + judge model regressions over time.
  • Needs OPENAI_API_KEY repo secret + optional OPENAI_JUDGE_MODEL var.

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:

  1. judge.py:135 — Wrap OpenAI API call in try/except so transient failures raise JudgeError (uniform error surface for the runner).
  2. run_eval.py:27 — Import JudgeError for catching in main().
  3. run_eval.py:286 — Validate --samples >= 1. 0/negative previously crashed majority_judge_label with IndexError.
  4. run_eval.py:314 — Catch JudgeError in main(), 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/ → clean
  • python3 -m evals.run_eval --judge fake --samples 0 → exit 1, friendly message
  • python3 -m evals.run_eval --judge fake --fixture pr-8 → exit 0, sensible metrics
  • After merge: set OPENAI_API_KEY repo secret, manually trigger Weekly finding-quality eval once 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

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread evals/run_eval.py Outdated
Comment thread evals/run_eval.py
Comment thread evals/run_eval.py Outdated
- 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.
@OrenAshkenazy

Copy link
Copy Markdown
Owner Author

@gemini-code-assist please review the latest changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread evals/run_eval.py
Comment thread evals/judge.py
Comment thread evals/run_eval.py Outdated
- 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.
@OrenAshkenazy

Copy link
Copy Markdown
Owner Author

@gemini-code-assist please review the latest changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread evals/run_eval.py
Comment thread evals/run_eval.py Outdated
OrenAshkenazy added a commit that referenced this pull request May 29, 2026
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.
OrenAshkenazy added a commit that referenced this pull request May 29, 2026
* 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>
@OrenAshkenazy

Copy link
Copy Markdown
Owner Author

@gemini-code-assist please review the latest changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread evals/run_eval.py
Comment on lines +65 to +81
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")),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
  1. When parsing JSON files, catch ValueError (which covers json.JSONDecodeError) and OSError to safely handle both decoding/parsing errors and file system issues.
  2. 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.

Comment thread evals/run_eval.py
Comment on lines +102 to +115
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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,
            )
            continue
References
  1. 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.
  2. 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.

Comment thread evals/run_eval.py
Comment on lines +137 to +153
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("`", "'"),
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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.

Comment thread evals/run_eval.py
Comment on lines +367 to +379
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 1
References
  1. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant