-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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)
The sync, unlock, complete, and review-push steps run on the scheduler; the assignment/review creation and all reads are on-demand HTTP.
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.pyand are NOT mounted:GET /anki/cards?topic_id=NandGET /anki/performance. They consume the FENCEDstate/retentionservices and stay disabled until thenode_idsubtree rollup is ported (T18, V-RB2). Do not treat them as live.
# 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"))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_note—ON CONFLICT (note_id) DO UPDATErefreshes content; row count stays stable. -
_replace_tags— diffs the note's incoming AnkiConnect tag set against its storedsource='schema_map'rows (§V43:llm/manualrows 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 ofuworld_qidrows;SyncSummary.linked_qidssums them. -
_upsert_card— upsert by(deck_name, anki_card_id)(§V1). When the mirrored fields match, data fields are not rewritten;sync_atbumps 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(0on first run);ON CONFLICT (review_id) DO NOTHINGmakes re-runs idempotent (§T36/§V26). Drops rows whosecard_idis 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.
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).
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 apendingrow with empty arrays, flushes for the PK (needed as the deterministic seed whenpriority='random'), resolves the scope, and UPDATEscard_ids/note_idsin 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-sidepending → unlockedtransition:unsuspend_cards(card_ids)then chainadd_tags(note_ids, ["coach::assignment:{id}"])(V50/T75). Per-assignment commit. On unsuspend failure: audit a failedanki_writesrow, bumpfailure_count; at≥ 3flipstatus='failed'(V55 retry cap). addTags failure is audit-only — it never revertsunlockedor bumps the counter. -
run_complete_unlocked(T64) — flipsunlocked → completedonce every snapshot card has a review afteractual_unlock_at. No AnkiConnect call — purely fromanki_card_reviews. Emptycard_idscompletes vacuously.
Note:
_fetch_candidates(_CANDIDATE_SQL_TOPIC/_CANDIDATE_SQL_CC) still references the legacytopics/content_categoriestables andanki_note_tags.topic_id/content_category_id/parsed_kind IN ('aamc_topic', 'aamc_cc'). These columns/tables do not exist in the livenode_id-only schema (V-T1, §B4), so assignment creation will fail at the SQL layer until this resolver is ported to thenode_idsubtree rollup. The lifecycle transitions (mark_skipped,mark_completed_manual,run_unlock_due,run_complete_unlocked) operate on already-storedcard_ids/note_idsand are unaffected. Flagging as stale.
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.
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).Nonewhen a card has no qualifying reviews. -
retrievability— pure card math, no extra query: the forgetting-curve estimateR = 0.9 ** (elapsed / interval), clamped to[0,1].0.9on the due date, higher before, lower once overdue.Nonewhen interval is unknown/non-positive.
Note:
app/services/anki/state.pyandretention.pyare 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.
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.
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. |
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.
-
V13 — read-only sync + closed write allowlist.
AnkiConnectClientexposes 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 markerscoach::assignment:{id}/coach::review:{id}), andcreateFilteredDeckconstrained to the<ANKI_DECK_PREFIX>::review::*namespace withreschedule=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 raiseAnkiUnreachableError;sync_deckreturns{synced_cards: 0, linked_qids: 0, error: "anki_not_running"}. An emptyfindCardsfor the configured deck returnserror="deck_empty_or_misspelled"and logs the known deck names (§V20 loud-fail). The/anki/syncroute never raises a 500. -
V55 — retry-with-cap. Unlock/review jobs retry transient AnkiConnect write failures on the next tick;
failure_countincrements per failed attempt, and≥ 3flips the row to terminalfailed.unsuspend/createFilteredDeckare 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. Emptynote_idsskips both the call and the audit row (no falsesucceededwrite). -
V64 — id de-duplication. AnkiConnect builds an internal
search_cidstable withUNIQUE(cid), so duplicate ids blow the whole batch.unsuspend_cardsandcreate_filtered_deckdedup card ids (order-preserving) before the call;add_tagsdedups note ids. -
§B11 — native vs local ids.
card_ids/note_idssnapshots store AnKing-native BIGINT ids (passed straight to AnkiConnect). The complete-check SQL bridgesanki_card_reviews.card_id(local SERIAL) →anki_cards.id→anki_cards.anki_card_id(native). -
Assignment transitions refuse terminal rows:
PATCH /anki/assignments/{id}toskipped/completedreturns 409 (AssignmentTerminalError) if alreadycompleted/skipped/failed; 404 if not found; 422 on invalid scope fromcreate. -
Validation limits.
review-queuelimit1–200;assignments/reviewswindow_days1–365;load-adherencewindow_days1–365;scope_value1–64 chars;card_idsmin-length 1; budgets strictly positive.
Note: The response schema
AnkiCardTagOut(used byAnkiCardOut/AnkiReviewQueueCardOut) still exposes atopic_idfield. The liveAnkiNoteTagmodel has notopic_idcolumn (it was retired fornode_idper V-T1/§B4), so this field always serializes asnull. It is vestigial; the canonical resolution target isnode_id(not currently surfaced on these read schemas). Flagging as stale.
Reference
Features
Design Notes