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

141 lines
4.8 KiB
Python

"""Push cards straight into the running Anki desktop via AnkiConnect.
Stdlib only. Anki must be open with the AnkiConnect add-on (2055492159);
`available()` is the probe, everything else assumes it. Notes are matched by
(deck, Problem, Front) so a re-run edits instead of duplicating, mirroring the
stable-guid behaviour of the .apkg path; `replace` also deletes dropped notes.
`sync()` pushes to AnkiWeb, which is what gets the cards onto AnkiDroid at its
next open.
"""
from __future__ import annotations
import html
import json
import logging
from typing import Any
import urllib.error
import urllib.request
_LOG = logging.getLogger(__name__)
URL = "http://127.0.0.1:8765"
class AnkiConnectError(RuntimeError):
"""AnkiConnect answered with an error field."""
def __init__(self, action: str, error: object) -> None:
"""Name the action and the error AnkiConnect returned."""
super().__init__(f"{action}: {error}")
def _call(action: str, **params: object) -> Any:
"""One AnkiConnect request; its `result`, or AnkiConnectError."""
body = json.dumps({"action": action, "version": 6, "params": params}).encode()
req = urllib.request.Request(
URL, data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as resp: # fixed localhost URL
payload = json.load(resp)
if payload.get("error"):
raise AnkiConnectError(action, payload["error"])
return payload.get("result")
def available() -> bool:
"""Whether Anki desktop is open with AnkiConnect answering."""
try:
return _call("version") is not None
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
AnkiConnectError,
) as exc:
_LOG.warning("AnkiConnect is not available: %s", exc)
return False
def ensure_model(name: str, fields: list[str], qfmt: str, afmt: str, css: str) -> None:
"""Create the note type unless the collection already has it."""
if name in _call("modelNames"):
return
_call(
"createModel",
modelName=name,
inOrderFields=fields,
css=css,
cardTemplates=[{"Name": "Card 1", "Front": qfmt, "Back": afmt}],
)
def _term(field: str, value: str) -> str:
"""Quote the WHOLE `field:value` term; quotes after the colon match nothing."""
return '"' + field + ":" + value.replace('"', '\\"') + '"'
def _existing_by_front(deck: str, slug: str) -> dict[str, int]:
r"""Map unescaped Front -> note id for every note of this problem.
Diffing in Python, not via a Front search: Anki search treats `_ * \`
specially and fields are stored HTML-escaped, so a search misses notes.
"""
ids = _call("findNotes", query=f"{_term('deck', deck)} {_term('Problem', slug)}")
infos = _call("notesInfo", notes=ids) if ids else []
return {html.unescape(n["fields"]["Front"]["value"]): n["noteId"] for n in infos}
def _add(deck: str, model: str, slug: str, front: str, back: str) -> None:
"""Add one note and make sure it sits in `deck`."""
note_id = _call(
"addNote",
note={
"deckName": deck,
"modelName": model,
"fields": {"Front": front, "Back": back, "Problem": slug},
"tags": ["leetcode", slug],
"options": {"allowDuplicate": False, "duplicateScope": "deck"},
},
)
# With Anki's "add to current deck" preference on, addNote lands in the
# current deck regardless of deckName (seen on 25.09.2). Move explicitly.
_call("changeDeck", cards=_call("findCards", query=f"nid:{note_id}"), deck=deck)
def sync_notes(
deck: str, model: str, slug: str, cards: list[dict[str, str]], *, replace: bool
) -> tuple[int, int, int]:
"""Add or update one note per card (fields already HTML-escaped).
With `replace`, notes of this problem whose Front is not in `cards` are
deleted, so a rewritten deck does not keep the cards it dropped.
Returns (added, updated, deleted).
"""
_call("createDeck", deck=deck)
existing = _existing_by_front(deck, slug)
added = updated = 0
keep: set[int] = set()
for c in cards:
front, back = c["front"], c["back"]
note_id = existing.get(html.unescape(front))
if note_id is not None:
_call(
"updateNoteFields",
note={"id": note_id, "fields": {"Front": front, "Back": back}},
)
keep.add(note_id)
updated += 1
continue
_add(deck, model, slug, front, back)
added += 1
stale = [i for i in existing.values() if i not in keep] if replace else []
if stale:
_call("deleteNotes", notes=stale)
return added, updated, len(stale)
def sync() -> None:
"""Ask Anki to sync with AnkiWeb."""
_call("sync")