mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 20:50:59 +01:00
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
93 lines
3.4 KiB
Python
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]
|