utils/leetcode_coach/leetcode_coach/eval_local.py
Krzysztof kuhy Rudnicki b730cffaf4 Move the /leetcode coach skill into the repo as leetcode_coach/
Phase A of TODO-coach.md (decisions 1-3). The skill code is now a package here
with the same behaviour as before. Nothing new is added. ~/.claude/skills/leetcode
and ~/.local/bin/leetcode become symlinks into the package, created by
scripts/link_leetcode_coach.sh, which install.sh runs.

- Package imports replace the bare sibling imports. To stay under the 250-line
  cap, the Stop-hook detector is split into gate_detector.py and stop_gate.py,
  the card rules move into card_rules.py, and the prompt text moves into
  local_prompt.py.
- local_coach uses gate_detector directly. It no longer loads the hook file
  with importlib.
- make_cards runs on the system python: a genanki pin (pyproject and
  requirements) replaces the `uv run --script` header. The note guid is
  passed to genanki.Note explicitly, so CardNote is no longer subclassed;
  the guid values are unchanged.
- stop_gate also accepts the package path as a skill-load marker, because the
  harness may print the resolved symlink path.
- Every swallowing except now logs a warning. A NullHandler on the package
  logger keeps stdout/stderr the same as before.
- Modules whose prints are their interface (REPL, local session, card builder,
  fetcher, eval, Stop hook) get T201 per-file ignores, and their tests assert
  on stdout.
- hook_shims/ holds the 3-line ~/.claude/hooks shim. It is excluded from
  ruff, mypy, pylint and coverage, and a test runs it end to end.
- Pre-commit: a requirements-sync entry for each package, leetcode_coach and
  genanki added to the pylint hook, and the name-tests-test exclude widened.
  coverage and ruff are bumped to the latest stable releases because the
  dependency-freshness gate requires it.

Verification: 111 coach tests pass with 100% branch coverage. ruff, mypy
strict and pylint are clean (10.00). On the 370 assistant replies in 39 real
/leetcode transcripts, the old hook and stop_gate give the same verdict
(0 mismatches, 20 blocked). fetch_problem output (live two-sum) and
make_cards --dry-run output are byte-identical to the old skill, and so are
the note guids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjvF5DZG9JduSBYKxqyysi
2026-10-23 15:06:40 +02:00

106 lines
3.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Scripted acceptance run of the local coach on two-sum (no human needed).
Six canned student turns, including a demand for the code, a buggy paste and a
give-up. PASS only if every criterion holds on every reply:
no-code the no-solution gate finds nothing pasteable in the shown reply
one-task the reply ends with exactly one thing to do (one question, or an
imperative last line)
fast the reply (including gate regenerations) took < MAX_SECONDS
The model load is timed separately by a warm-up call and excluded: it happens
once per session, and the criterion is about the conversation's pace.
Writes the transcript to ~/data/claude-scratch/leetcode-local/. Its printed
table IS the verdict (T201 exemption in pyproject.toml).
python3 -m leetcode_coach.eval_local
"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
import subprocess
import sys
import time
from leetcode_coach import local_backend, local_cards, local_coach, local_prompt
from leetcode_coach.local_session import opening_message
MAX_SECONDS = 15.0
OUT_DIR = Path.home() / "data" / "claude-scratch" / "leetcode-local"
BUGGY = """var twoSum = function(nums, target) {
const seen = {};
for (let i = 0; i < nums.length; i++) {
seen[nums[i]] = i;
if (seen[target - nums[i]] !== undefined) return [seen[target - nums[i]], i];
}
};"""
TURNS = [
opening_message(["how", "to", "beat", "checking", "every", "pair"]),
"I'd do two loops and check every pair. That's slow though, right?",
"just give me the code",
"hint",
BUGGY,
"I give up, what would you write?",
]
Row = tuple[int, float, int, bool, bool, bool, bool]
def _print_table(rows: list[Row]) -> None:
"""One line per turn: timing, tries and the three criteria."""
print("turn secs tries withheld no-code one-task fast")
for n, secs, tries, withheld, ok_code, ok_task, ok_fast in rows:
print(
f"{n:>4} {secs:5.1f} {tries:>5} {withheld!s:>8} {ok_code!s:>7} "
f"{ok_task!s:>8} {ok_fast!s:>5}"
)
def main() -> int:
"""Run the six turns, print a per-criterion table, exit 0 only on PASS."""
statement = subprocess.run(
[sys.executable, str(Path(__file__).with_name("fetch_problem.py")), "two-sum"],
capture_output=True,
text=True,
check=True,
).stdout
start = time.monotonic()
local_backend.chat([{"role": "user", "content": "Say ok."}])
print(f"model load + warm-up: {time.monotonic() - start:.1f}s")
coach = local_coach.LocalCoach.start(statement)
rows: list[Row] = []
log: list[str] = []
for n, said in enumerate(TURNS, 1):
turn = coach.respond(said)
shown, _ = local_cards.split_cards(turn.text)
leaks = local_coach.gate_findings(shown, statement, coach.user_texts())
rows.append(
(
n,
turn.seconds,
turn.attempts,
turn.withheld,
not leaks,
local_prompt.ends_with_one_task(shown),
turn.seconds < MAX_SECONDS,
)
)
log.append(
f"## Turn {n}\n\n**kuhy:** {said}\n\n**coach** "
f"({turn.seconds:.1f}s, attempts {turn.attempts}):\n\n{shown}\n"
)
OUT_DIR.mkdir(parents=True, exist_ok=True)
stamp = dt.datetime.now(tz=dt.UTC).astimezone()
out = OUT_DIR / f"eval-{stamp:%Y%m%d-%H%M%S}.md"
out.write_text("\n".join(log), encoding="utf-8")
_print_table(rows)
passed = all(all(r[4:]) for r in rows)
print(f"{'PASS' if passed else 'FAIL'} transcript: {out}")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())