Skip to content

Feature Anki

Ramiro Cantu edited this page May 30, 2026 · 1 revision

Feature: Anki

Summary: Gradient mirrors your AnKing Anki deck into Postgres and lets you drive a study-plan workflow on top of it. A scheduled, read-only sync pulls cards/notes/revlog from AnkiConnect; the AnKing tag shape is parsed into outline node_id links and UWorld qid references. On top of that mirror you can: schedule assignments that unsuspend a scope of cards on a date, push one-off review filtered decks, set a daily load budget and inspect adherence, and read a due-and-overdue review queue annotated with per-card retention and retrievability. Every AnkiConnect write goes through a closed allowlist that never touches the scheduler — see V13 below.

This page documents the data-layer, services, endpoints, and scheduler jobs under app/api/v1/anki*.py, app/services/anki/, and app/models/anki.py. The MCP tutor seam that consumes some of these reads lives in Intelligence Layer; the AnkiConnect-client boundary belongs to Core Library. See also Data Models, API Reference, and Function Index.


User flow

sequenceDiagram
    participant Sched as Scheduler
    participant Sync as sync_deck
    participant AC as AnkiConnect
    participant DB as Postgres
    participant API as Anki API
    participant UI as Dashboard / MCP

    Sched->>Sync: run_anki_sync (interval)
    Sync->>AC: findCards / cardsInfo / notesInfo / cardReviews (read-only, V13)
    AC-->>Sync: cards + note tags + revlog
    Sync->>DB: upsert anki_notes, anki_note_tags, anki_cards, anki_card_reviews
    UI->>API: POST /anki/assignments (scope + unlock date)
    API->>DB: snapshot card_ids/note_ids (V52)
    Sched->>AC: run_anki_assignment_unlock → unsuspend + addTags (V50/V55)
    Sched->>DB: run_anki_assignment_complete (all cards reviewed? → completed)
    UI->>API: POST /anki/reviews (card_ids + date)
    Sched->>AC: run_anki_review → createFilteredDeck + addTags (V50)
    UI->>API: GET /anki/review-queue / load-adherence (data-only reads)
Loading

The sync, unlock, complete, and review-push steps run on the scheduler; the assignment/review creation and all reads are on-demand HTTP.


Endpoints

All routes are mounted under /api/v1 with prefix /anki and require the X-Coach-Token header (verify_coach_token is a per-route dependency on each handler).

Method Path Auth Body / Response Purpose
POST /api/v1/anki/sync token — / dict (synced_cards, linked_qids, reviews_synced, error) Trigger a one-off sync of the configured deck. Stays soft on AnkiConnect being down (returns the §V4 error envelope, never a 500).
GET /api/v1/anki/review-queue token — / list[AnkiReviewQueueCardOut] Due-and-overdue cards soonest first, each annotated with grouped-query retention + computed retrievability (T43). Query: limit (default 50, 1–200).
GET /api/v1/anki/cards/by-qid/{qid} token — / list[AnkiCardOut] List cards whose note carries the #AK_MCAT_v2::#UWorld::{qid} tag.
POST /api/v1/anki/assignments token AnkiAssignmentCreateIn / AnkiAssignmentOut (201) Create an assignment and snapshot its resolved card_ids/note_ids (V52).
GET /api/v1/anki/assignments token — / list[AnkiAssignmentOut] List assignments ordered by scheduled_unlock_at, optional status + window_days (1–365) near-future filter.
PATCH /api/v1/anki/assignments/{assignment_id} token AnkiAssignmentPatchIn / AnkiAssignmentOut Apply one of the two human-driven transitions (mark_skipped / mark_completed_manual).
POST /api/v1/anki/reviews token AnkiReviewCreateIn / AnkiReviewOut (201) Create a standalone pending review for a set of AnKing card_ids on a date (V53 amended).
GET /api/v1/anki/reviews token — / list[AnkiReviewOut] List reviews ordered by review_date, optional status + window_days (1–365) filter.
GET /api/v1/anki/load-config token — / AnkiLoadConfigOut Read the singleton load-config; auto-seeds the V59 default row (200 cards, 60 minutes) on miss.
POST /api/v1/anki/load-config token AnkiLoadConfigIn / AnkiLoadConfigOut Upsert the singleton load-config (id=1) in place (V59).
GET /api/v1/anki/load-adherence token — / AnkiLoadAdherenceOut Deterministic V54 plan-adherence payload (data-only per V60) plus the T43 reviewed-count series. Query: window_days (default 30, 1–365).

Note: Two routes are intentionally commented out in app/api/v1/anki.py and are NOT mounted: GET /anki/cards?topic_id=N and GET /anki/performance. They consume the FENCED state/retention services and stay disabled until the node_id subtree rollup is ported (T18, V-RB2). Do not treat them as live.

Request/response shapes

# AnkiAssignmentCreateIn — POST /anki/assignments
scope_kind: Literal["cc", "topic"]
scope_value: str = Field(min_length=1, max_length=64)
scheduled_unlock_at: datetime
max_cards: Optional[int] = Field(default=None, gt=0)
priority: Literal["most_specific_first", "random", "mature_first", "young_first"] = "most_specific_first"

# AnkiReviewCreateIn — POST /anki/reviews
card_ids: list[int] = Field(min_length=1)   # AnKing-native BIGINT card ids
review_date: date

# AnkiLoadConfigIn — POST /anki/load-config
daily_card_review_budget: int = Field(gt=0)
daily_minutes_budget: Decimal = Field(gt=Decimal("0"))

Functions involved

Read-only sync (V13)

sync_anki (controller, app/api/v1/anki.py:76) → sync_deck (app/services/anki/sync.py:77) → AnkiConnect reads via AnkiConnectClient.{find_cards, cards_info, notes_info, card_reviews, deck_names} (app/services/anki/client.py).

sync_deck is note-as-unit (§V75): it upserts anki_notes first (content + model + deck) so the anki_cards.note_id FK is satisfiable, then replaces the per-note tag set, then upserts the per-card SRS rows, then appends revlog rows. Key internals:

  • _upsert_noteON CONFLICT (note_id) DO UPDATE refreshes content; row count stays stable.
  • _replace_tags — diffs the note's incoming AnkiConnect tag set against its stored source='schema_map' rows (§V43: llm/manual rows are scoped out of both the deletion sweep and the dedupe guard, so a sync never wipes LLM-resolved tags — that was [§B9]). Re-parses rows previously stored as 'unparsed' so an evolving regex catches up. Returns the per-note count of uworld_qid rows; SyncSummary.linked_qids sums them.
  • _upsert_card — upsert by (deck_name, anki_card_id) (§V1). When the mirrored fields match, data fields are not rewritten; sync_at bumps on every visit (bookkeeping, not Anki state). Mirrored fields are SRS state only: note_id, due_date, interval_days, ease, lapses, queue (§V75 — content/model live on the note).
  • _sync_reviews — incremental revlog append: startID = MAX(review_id) + 1 (0 on first run); ON CONFLICT (review_id) DO NOTHING makes re-runs idempotent (§T36/§V26). Drops rows whose card_id is not in this sync (defends the FK).
  • _compute_due_date — best-effort: review cards (queue 2) → today + interval; learning/relearning (queue 1/3) → today; new (0) and suspended (<0) → NULL.

Tag parsing (V-T2 / V-RB2)

parse_tag (app/services/anki/tag_parser.py:64) classifies one AnKing tag string into a ParsedTag(tag_raw, node_id, question_qid, parsed_kind). The empirical AnKing shapes (§B3, verified live):

Tag shape parsed_kind Resolution
#AK_MCAT_v2::#UWorld::<digits> uworld_qid question_qid set, node_id=None
#AK_MCAT_v2::#AAMC::Concepts::<SECTION>::Foundational_Concept_NN::<CC>-... aamc_cc reconstructs path "<SECTION> >> FC<NN> >> <CC>", resolved via OutlineLookup.node_id_by_path (V-O4)
#AK_MCAT_v2::#AAMC::Skills::Skill_N-... aamc_skill recognized but maps to no outline node (node_id=None)
anything else unparsed node_id=None

Section slashes normalize (C/P→CP, B/B→BB, P/S→PS, CARS passes through). An AAMC-shaped tag whose CC path fails to resolve — or with no OutlineLookup (the AAMC outline is unseeded) — is demoted to 'unparsed' rather than guessed at, so sync stays soft (§V4). node_id is the only persisted resolution target (V-T1); parsed_kind is the plugin's provenance claim. The retired PoC 3-target (topic_id/content_category_id/skill) is gone (§B4).

Assignment lifecycle (V51 / V52 / V55)

Controllers in app/api/v1/anki_assign.py are thin adapters that translate service exceptions to HTTP (terminal → 409, invalid scope → 422, not-found → 404). The service is app/services/anki/assignment.py:

  • create_assignment (→ _resolve_targets_fetch_candidates + _apply_priority) inserts a pending row with empty arrays, flushes for the PK (needed as the deterministic seed when priority='random'), resolves the scope, and UPDATEs card_ids/note_ids in place (V52). Card-id resolution is note-scoped (§V75): a suspended card (queue=-1) matches iff its note carries an in-scope tag; both lists are deduped order-preserving (V64).
  • mark_skipped / mark_completed_manual — the two non-AnkiConnect transitions; both refuse terminal rows (completed/skipped/failed).
  • run_unlock_due (T63) — the scheduler-side pending → unlocked transition: unsuspend_cards(card_ids) then chain add_tags(note_ids, ["coach::assignment:{id}"]) (V50/T75). Per-assignment commit. On unsuspend failure: audit a failed anki_writes row, bump failure_count; at ≥ 3 flip status='failed' (V55 retry cap). addTags failure is audit-only — it never reverts unlocked or bumps the counter.
  • run_complete_unlocked (T64) — flips unlocked → completed once every snapshot card has a review after actual_unlock_at. No AnkiConnect call — purely from anki_card_reviews. Empty card_ids completes vacuously.

Note: _fetch_candidates (_CANDIDATE_SQL_TOPIC / _CANDIDATE_SQL_CC) still references the legacy topics / content_categories tables and anki_note_tags.topic_id / content_category_id / parsed_kind IN ('aamc_topic', 'aamc_cc'). These columns/tables do not exist in the live node_id-only schema (V-T1, §B4), so assignment creation will fail at the SQL layer until this resolver is ported to the node_id subtree rollup. The lifecycle transitions (mark_skipped, mark_completed_manual, run_unlock_due, run_complete_unlocked) operate on already-stored card_ids/note_ids and are unaffected. Flagging as stale.

Review push (V50 / V53 amended)

create_review_route (app/api/v1/anki_review.py:30) → create_review (app/services/anki/review.py:78). A two-flush dance assigns the PK then sets deck_name = "<ANKI_DECK_PREFIX>::review::{id}" (unique by construction, so no (date, *) UNIQUE constraint — V53 amended accepts duplicate same-day reviews; idempotency lives in UI debounce). Parent note_ids are derived for the push-time addTags target. run_review_due (T76, scheduler) loops pending reviews where review_date ≤ today, calls create_filtered_deck(deck_name, card_ids), flips status='pushed', then chains add_tags(note_ids, ["coach::review:{id}"]). Same V55 retry-cap + audit-only addTags contract as the unlock job.

Review metrics & retention (T43, V13)

Read-only derived numbers in app/services/anki/review_metrics.py, consumed by get_review_queue (app/api/v1/anki.py:110):

  • retention_by_card — one grouped query (⊥ N+1) over the queue's cards: lifetime "true retention" = pass / total over non-learn reviews, where pass = ease ∈ {2,3,4} (§V26/§V27 cohort). None when a card has no qualifying reviews.
  • retrievability — pure card math, no extra query: the forgetting-curve estimate R = 0.9 ** (elapsed / interval), clamped to [0,1]. 0.9 on the due date, higher before, lower once overdue. None when interval is unknown/non-positive.

Note: app/services/anki/state.py and retention.py are FENCED (T18, V-RB2): the windowed per-CC/per-topic state and retention functions return empty results and only exist to keep imports from crashing. They are not on any live route. Per-card metrics above are the live retention surface; do not confuse the two.

Load budget & adherence (V54 / V59 / V60)

get_load_adherence_route (app/api/v1/anki_load.py:93) → compute_load_adherence (app/services/anki/load_adherence.py:194). Pure computation — no LLM, no cache, no recommendations (V60). The model is auditable from the dashboard chip:

base_daily_load   = 14d rolling mean reviews/day (excludes type='learn')
upcoming_per_day  = sum(len(card_ids) for pending assignments unlocking in
                        [now, now+window]) / window_days
projected_load    = base_daily_load + upcoming_per_day
projected_minutes = projected_load × mean_seconds_per_review / 60
headroom_pct      = (budget - projected) / budget × 100

The worse-axis headroom governs status_label: feasible (both ≥ 0), overload (worse < −15), else tight. The payload also carries reviewed_series — a dense, zero-filled per-day reviewed count over window_days (T43), so the chart needs no client-side gap-filling. load-config is the V59 singleton (id=1 enforced by CHECK); the GET handler _get_or_seeds the defaults (200, 60) on miss so the chip renders even before the migration runs.


Data models touched

See Data Models for full column detail; all live in app/models/anki.py.

Model Table Role
AnkiNote anki_notes One row per Anki note (§V75 note-as-unit): content (fields_json), model_name, indicative deck_name; native note_id BIGINT PK. Tag/node-resolution target.
AnkiCard anki_cards One row per card; identity (deck_name, anki_card_id) (§V1). SRS state only (due_date, interval_days, ease, lapses, queue); FKs to anki_notes; tags is a viewonly relationship through the shared note_id.
AnkiNoteTag anki_note_tags Canonical tag (V-T1) on a note; sole target node_id (nullable). source ∈ {schema_map, llm, manual} (V-T2); confidence required iff source='llm' (V-T3); retains plugin provenance (tag_raw, parsed_kind, question_qid).
AnkiCardReview anki_card_reviews Append-only revlog mirror (§V26/§V27); review_id (Anki unix-ms) PK for idempotent incremental sync; backs retention math.
AnkiAssignment anki_assignments V51 lifecycle pending → unlocked → (completed|skipped|failed); card_ids/note_ids snapshot (V52); scope_kind ∈ {cc, topic}; failure_count for V55 cap.
AnkiReview anki_reviews One-off filtered-deck review for a card set on a date (V53 amended); status ∈ {pending, pushed, failed}; deck_name derived from PK; standalone (⊥ FK to assignments).
AnkiWrite anki_writes Append-only AnkiConnect write audit (V50/V55): one row per write attempt; action, status ∈ {succeeded, failed}, source ∈ {mcp, scheduler, manual, test}, optional FK to assignment/review.
AnkiLoadConfig anki_load_config Singleton (CHECK id=1) daily card + minutes budget (V59); both budgets CHECK-positive.

Scheduler jobs

Started in the app lifespan iff settings.SCHEDULER_ENABLED. Each writes a TaskRun row, is guarded by a module-level in-flight set + lock (skips if already running), and follows V41 partial-success discipline (the run is marked succeeded even when individual items fail — the per-row counters are the real durability signal). Defined in app/scheduler.py.

Job Trigger Service Behavior
run_anki_sync interval ANKI_SYNC_INTERVAL_MINUTES sync_deck Read-only mirror. AnkiConnect-unreachable is not a job failure (V4): TaskRun is succeeded with error_text carrying the soft error so /admin can hint without alerting.
run_anki_assignment_unlock interval ANKI_ASSIGNMENT_UNLOCK_INTERVAL_MINUTES run_unlock_due Unsuspend + addTags due pending assignments (V51/V55).
run_anki_assignment_complete cron ANKI_ASSIGNMENT_COMPLETE_CRON_HOUR:MINUTE run_complete_unlocked DB-only; flips fully-reviewed unlocked assignments to completed. No AnkiConnect call.
run_anki_review interval ANKI_REVIEW_PUSH_INTERVAL_MINUTES run_review_due createFilteredDeck + addTags due pending reviews (V53 amended/V55).

These four are the only jobs nudgeable via POST /api/v1/admin/jobs/{job_name}/trigger (the _VALID_JOBS allowlist). See Function Index for the controller→service→data-layer chains.


Edge cases & business rules

  • V13 — read-only sync + closed write allowlist. AnkiConnectClient exposes reads (findCards, cardsInfo, notesInfo, cardReviews, deckNames) plus exactly three writes: unsuspend (queue −1 → 0, does NOT mutate intervals/ease), addTags (note-scoped, write-only audit markers coach::assignment:{id} / coach::review:{id}), and createFilteredDeck constrained to the <ANKI_DECK_PREFIX>::review::* namespace with reschedule=False (cram mode, no scheduling perturbation). Anything else — suspend, removeNotes, forgetCards, deleteDecks — is forbidden. The sync path never writes.
  • V4 — soft on AnkiConnect down. find_cards/etc. transport failures raise AnkiUnreachableError; sync_deck returns {synced_cards: 0, linked_qids: 0, error: "anki_not_running"}. An empty findCards for the configured deck returns error="deck_empty_or_misspelled" and logs the known deck names (§V20 loud-fail). The /anki/sync route never raises a 500.
  • V55 — retry-with-cap. Unlock/review jobs retry transient AnkiConnect write failures on the next tick; failure_count increments per failed attempt, and ≥ 3 flips the row to terminal failed. unsuspend/createFilteredDeck are idempotent so retries are safe.
  • V50 addTags is audit-only. A failed addTags after a successful unsuspend/createFilteredDeck never reverts the lifecycle status or bumps failure_count. Empty note_ids skips both the call and the audit row (no false succeeded write).
  • V64 — id de-duplication. AnkiConnect builds an internal search_cids table with UNIQUE(cid), so duplicate ids blow the whole batch. unsuspend_cards and create_filtered_deck dedup card ids (order-preserving) before the call; add_tags dedups note ids.
  • §B11 — native vs local ids. card_ids/note_ids snapshots store AnKing-native BIGINT ids (passed straight to AnkiConnect). The complete-check SQL bridges anki_card_reviews.card_id (local SERIAL) → anki_cards.idanki_cards.anki_card_id (native).
  • Assignment transitions refuse terminal rows: PATCH /anki/assignments/{id} to skipped/completed returns 409 (AssignmentTerminalError) if already completed/skipped/failed; 404 if not found; 422 on invalid scope from create.
  • Validation limits. review-queue limit 1–200; assignments/reviews window_days 1–365; load-adherence window_days 1–365; scope_value 1–64 chars; card_ids min-length 1; budgets strictly positive.

Note: The response schema AnkiCardTagOut (used by AnkiCardOut/AnkiReviewQueueCardOut) still exposes a topic_id field. The live AnkiNoteTag model has no topic_id column (it was retired for node_id per V-T1/§B4), so this field always serializes as null. It is vestigial; the canonical resolution target is node_id (not currently surfaced on these read schemas). Flagging as stale.

Clone this wiki locally