utils/leetcode_coach/leetcode_coach/card_rules.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

93 lines
3.4 KiB
Python

"""The shape rules every /leetcode post-mortem card must pass.
Short by construction: a back longer than BACK_MAX_WORDS words or a front
longer than FRONT_MAX_WORDS fails the run and names the offender, so the model
has to shorten instead of shipping a paragraph. Shape is gated the same way: a
yes/no or either/or question is a coin flip, two questions on one front split
the recall, and a `{ ... }` back is a placeholder, not an answer.
Split out of `make_cards.py` (which keeps the delivery side) for the
250-line cap; the rules are unchanged.
"""
from __future__ import annotations
import html
import re
FRONT_MAX_WORDS = 20
BACK_MAX_WORDS = 8
MIN_CARDS, MAX_CARDS = 1, 6
YES_NO = re.compile(
r"(is|are|does|do|did|can|could|should|will|would|has|have|was|were)\b",
re.IGNORECASE,
)
EITHER_OR = re.compile(r"\bor\b", re.IGNORECASE)
PLACEHOLDER = re.compile(r"\{\s*(\.\.\.|…)\s*\}|…")
Card = dict[str, str]
def shape_errors(i: int, front: str, back: str) -> list[str]:
"""Coin-flip and placeholder cards: a pass on them proves no recall."""
errors: list[str] = []
if front.count("?") > 1:
errors.append(f"card {i}: two questions on one front; split it")
question = re.split(r"[:—]|\.\s", front)[-1].strip()
if question.endswith("?"):
if YES_NO.match(question):
errors.append(f"card {i}: yes/no front; ask for the thing itself")
elif EITHER_OR.search(question):
errors.append(f"card {i}: either/or front; the back is a coin flip")
if PLACEHOLDER.search(back):
errors.append(f"card {i}: placeholder in back; write the real code")
return errors
def validate(slug: str, cards: list[Card]) -> list[str]:
"""Return every rule violation as a human-readable line (empty = ok)."""
errors: list[str] = []
if not MIN_CARDS <= len(cards) <= MAX_CARDS:
errors.append(f"{len(cards)} cards; want {MIN_CARDS}-{MAX_CARDS}")
for i, c in enumerate(cards, 1):
front, back = str(c.get("front", "")).strip(), str(c.get("back", "")).strip()
if not front or not back:
errors.append(f"card {i}: empty front or back")
continue
if "\n" in front or "\n" in back:
errors.append(f"card {i}: newlines are not allowed")
if len(front.split()) > FRONT_MAX_WORDS:
errors.append(
f"card {i}: front is {len(front.split())} words (max {FRONT_MAX_WORDS})"
)
if (n := len(back.split())) > BACK_MAX_WORDS:
errors.append(
f"card {i}: back is {n} words (max {BACK_MAX_WORDS}): {back!r}"
)
errors += shape_errors(i, front, back)
if not slug:
errors.append("missing slug")
return errors
def _cell(text: str) -> str:
"""Markdown table cell: a bare pipe would split it in two."""
return text.strip().replace("|", "\\|")
def _esc(text: str) -> str:
"""HTML-escape one field (quotes are left alone)."""
return html.escape(text.strip(), quote=False)
def table(cards: list[Card]) -> str:
"""Markdown table for the chat reply, so the model relays instead of retyping."""
rows = ["| Front | Back |", "|---|---|"]
rows += [f"| {_cell(c['front'])} | {_cell(c['back'])} |" for c in cards]
return "\n".join(rows)
def escaped(cards: list[Card]) -> list[Card]:
"""Anki fields are HTML: escape so `a<b` or `&` survive rendering."""
return [{"front": _esc(c["front"]), "back": _esc(c["back"])} for c in cards]