-
Notifications
You must be signed in to change notification settings - Fork 0
API Reference
The complete HTTP surface of gradient-server. Every route is a JSON API
under /api/v1/* (plus the root /healthz liveness probe and the root
/media/* asset server). There is no in-repo UI — this backend is consumed by
the Chrome capture extension, the desktop SPA, the MCP tutor host, and the
scheduler (SPEC §C, V-D1).
This page is generated from live source under app/api/v1/. Where it disagrees
with docs/openapi.json or docs/BACKEND_CORE.md, the code wins. For the
function-level index (handler signatures + service seams), see
Function Index; for the underlying tables, see
Data Models.
Note:
docs/openapi.jsonis the machine-readable contract. Earlier notes claimed it was missing anadmin/.../recategorizeroute — there is no such route in the code, so nothing is missing on that front. Regenerate the contract after any router change:uv run python -c "import json; from app.main import app; \ open('docs/openapi.json','w').write(json.dumps(app.openapi(), indent=2)+'\n')"Interactive docs (server running):
http://localhost:8000/docs.
| Symbol | Meaning |
|---|---|
| token | Requires the X-Coach-Token header. Checked constant-time against settings.COACH_TOKEN by app/api/deps.py:verify_coach_token; a miss returns 401 {"detail": "invalid coach token"}. Applied per-route or as a whole-router dependency. |
| open | No auth dependency at all. Reachable by anyone who can hit the port. |
| localhost-only (effective) | No verify_coach_token and no in-code localhost guard — the route is effectively open at the application layer. The only gate is that the service is bound to localhost in deployment. Flagged inline where it applies. |
Note: Two admin mutation routes —
POST /api/v1/admin/questions/{question_id}/tagsandDELETE /api/v1/admin/tags/{tag_id}— carry noverify_coach_tokendependency and no in-code localhost guard.admin.py's module docstring says "No auth — localhost-only service," but that is a deployment assumption, not an enforced guard. Treat them as effectively open at the app layer. The read/job admin routes (/jobs,/jobs/{job_name}/trigger,/status) are token-gated per-route.
All /api/v1/* routes are mounted via an intermediate APIRouter in
app/main.py. CORS is restricted via allow_origin_regex to
chrome-extension://* and http(s)://localhost. A RequestValidationError
handler logs capture-path 422s.
Grouped by feature. "Body" names the Pydantic model (or notes form/none);
"Response" is the declared response_model or the actual returned shape when
none is declared.
Capture & attempts → Feature: Capture
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| POST | /api/v1/captures |
token | CapturePayload |
IngestResponse |
Ingest a question capture from the extension into question/attempt/media tables. |
| GET | /api/v1/attempts/{attempt_id}/notes |
token | — | list[NoteOut] |
List notes on an attempt. |
| POST | /api/v1/attempts/{attempt_id}/notes |
token | NoteIn |
NoteOut (201) |
Create an attempt note, optionally flagged for review. |
| DELETE | /api/v1/attempts/notes/{note_id} |
token | — | — (204) | Delete an attempt note by id. |
Outline / domain model → Feature: Outline
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| GET | /api/v1/courses |
token | — | list[dict] |
List all courses (slug-ordered) for the course picker. |
| POST | /api/v1/courses |
token | CreateCourseBody |
dict (201) |
Create a course from slug/name/description. |
| POST | /api/v1/courses/{course_id}/outline:import |
token | raw outline dict
|
dict |
Validate-then-materialize an outline schema atomically (V-O2/V-O3). |
| GET | /api/v1/courses/{course_id}/outline |
token | — | dict |
Materialized outline node tree for a course. |
| GET | /api/v1/outline/nodes/{node_id}/mastery |
token | — | dict |
Per-node/subtree mastery rollup (T44, V-O5). |
| GET | /api/v1/outline/courses/{course_id}/mastery |
token | — | dict |
Course-level mastery rollup (T44). |
Knowledge base → Feature: Knowledge Base
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| POST | /api/v1/pdf/ingest |
token | multipart form (course_id, file) |
PdfIngestResponse |
Upload a lecture-notes PDF and run it synchronously through vision-ingest. |
| GET | /api/v1/concept-edges |
token | — | list[dict] |
Concept-edge feed for the connections view (T45, V-E2). |
| GET | /api/v1/atomic-facts |
token | — | list[dict] |
Grounded atomic facts filtered by node or PDF source (T46, V-KB1). |
| GET | /api/v1/notion/pages |
token | — | list[dict] |
Notion pointer index (T47, V-N1/V-N2). |
| GET | /api/v1/pdf-sources |
token | — | list[dict] |
Ingested-PDF inbox with per-source fact counts (T48). |
| GET | /media/{file_path:path} |
open | — | FileResponse |
Serve PDF/image assets from MEDIA_ROOT (path-traversal guarded). |
Tutor / MCP → Feature: Tutor MCP
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| GET | /api/v1/tutor/questions/by-qid/{qid} |
token | — | dict |
Fetch a question by UWorld qid. |
| GET | /api/v1/tutor/questions/by-attempt-id/{attempt_id} |
token | — | dict |
Fetch the question tied to an attempt. |
| GET | /api/v1/tutor/captures/recent |
token | — | list[dict] |
N most recent captures. |
| GET | /api/v1/tutor/sessions/latest |
token | — | dict |
Latest session test_id (or null). |
| GET | /api/v1/tutor/sessions/recent |
token | — | list[dict] |
N most recent sessions. |
| GET | /api/v1/tutor/sessions/{test_id}/summary |
token | — | dict |
Per-topic accuracy + attempt breakdown for one session. |
| GET | /api/v1/tutor/attempts/flagged |
token | — | list[dict] |
Flagged attempts for the review queue. |
| GET | /api/v1/tutor/outline/nodes/search |
token | — | list[dict] |
Search outline nodes by name, optionally scoped to a course. |
| GET | /api/v1/tutor/outline |
token | — | dict |
Full outline tree for a course (by slug). |
| GET | /api/v1/tutor/outline/nodes/{node_id}/subtree |
token | — | dict |
A node + its descendant subtree. |
| GET | /api/v1/tutor/healthz |
token | — | dict |
Token-gated DB-touching health check for the MCP seam. |
| POST | /api/v1/pkm/discriminators |
token | DiscriminatorIn |
DiscriminatorOut |
Persist-only (V-M1) discriminator factor write, append-only deduped (V-M3). |
Anki → Feature: Anki
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| POST | /api/v1/anki/sync |
token | — |
dict (error envelope) |
One-off sync of the configured deck against AnkiConnect. |
| GET | /api/v1/anki/review-queue |
token | — | list[AnkiReviewQueueCardOut] |
Due/overdue cards, soonest first, with retention + retrievability (T43). |
| GET | /api/v1/anki/cards/by-qid/{qid} |
token | — | list[AnkiCardOut] |
Cards carrying the uworld::qid::{qid} tag. |
| POST | /api/v1/anki/assignments |
token | AnkiAssignmentCreateIn |
AnkiAssignmentOut (201) |
Create an assignment + snapshot resolved card_ids (V52). |
| GET | /api/v1/anki/assignments |
token | — | list[AnkiAssignmentOut] |
List assignments by unlock time; status/window filters. |
| PATCH | /api/v1/anki/assignments/{assignment_id} |
token | AnkiAssignmentPatchIn |
AnkiAssignmentOut |
Human-driven transition: mark skipped / completed (V51). |
| POST | /api/v1/anki/reviews |
token | AnkiReviewCreateIn |
AnkiReviewOut (201) |
Create a standalone pending review for AnKing card_ids on a date (V53). |
| GET | /api/v1/anki/reviews |
token | — | list[AnkiReviewOut] |
List reviews by date; status/window filters. |
| GET | /api/v1/anki/load-config |
token | — | AnkiLoadConfigOut |
Read singleton load-config; auto-seeds V59 default on miss. |
| POST | /api/v1/anki/load-config |
token | AnkiLoadConfigIn |
AnkiLoadConfigOut |
Upsert the singleton load-config (V59). |
| GET | /api/v1/anki/load-adherence |
token | — | AnkiLoadAdherenceOut |
Deterministic V54 adherence payload + T43 reviewed-count series. |
Platform / admin → Feature: Platform Admin
| Method | Path | Auth | Body | Response | Description |
|---|---|---|---|---|---|
| GET | /healthz |
open | — | {"status":"ok"} |
Root liveness probe; no DB, no auth. |
| POST | /api/v1/admin/questions/{question_id}/tags |
open (effective)¹ | ManualTagBody |
dict (201) |
Create a manual override tag (exactly one of topic_id/content_category_id/skill). |
| DELETE | /api/v1/admin/tags/{tag_id} |
open (effective)¹ | — | — (204) | Hard-delete a manual tag / soft-override an llm tag; 403 otherwise. |
| GET | /api/v1/admin/jobs |
token | — | list[dict] |
Snapshot of scheduler job state. |
| POST | /api/v1/admin/jobs/{job_name}/trigger |
token | — |
dict (202) |
Nudge a named scheduler job to run now (allowlisted). |
| GET | /api/v1/admin/status |
token | — | dict |
Probe Anki/OpenAI/Notion + fold last TaskRun per job (T39). |
¹ No verify_coach_token, no localhost guard — effectively open at the app
layer. See the auth-legend note above.
Detail subsections for the non-trivial routes (those with body params, filters,
or notable error contracts). Trivial fixed-shape reads (/healthz, the simple
tutor reads) are fully described by the master table above. Each handler links
to its entry in Function Index.
Ingest a UWorld (or other-source) question capture from the Chrome extension.
-
Auth: token. Depends:
get_session,verify_coach_token. -
Handler:
post_capture—app/api/v1/captures.py:17. -
Body model:
CapturePayload(app/schemas/captures.py,extra="forbid").
| Field | Type | Notes |
|---|---|---|
source |
str |
Source discriminator (§A); defaults to "uworld". |
course_slug |
str | None |
Resolved to course_id via courses.slug at ingest (V-CAP2). NULL falls back to the single-course tagging rule. |
qid |
str |
Required. |
uworld_test_id |
str | None |
|
captured_at |
datetime |
Required. |
html |
str |
Raw capture HTML. |
parsed |
ParsedCapture |
Stem/choices/explanation/correctness/timing (nested model). |
media |
list[MediaCapture] |
Default []. |
parse_warnings |
list[ParseWarning] |
Default []. |
extension_version |
str |
Required. |
Success — 200 IngestResponse:
{
"capture_id": 412,
"question_id": 95,
"attempt_id": 738,
"passage_id": null,
"media_ids": [1201, 1202]
}Errors:
| Status | Condition |
|---|---|
422 |
UnknownSourceError / UnknownCourseError from ingest_capture, re-raised as HTTPException with str(exc) detail. |
422 |
RequestValidationError on malformed CapturePayload (extra="forbid"); logged by the capture-path handler in main.py. |
401 |
Invalid/missing X-Coach-Token. |
-
Auth: token. Handler:
add_note—app/api/v1/attempts.py:44. -
Body model:
NoteIn(local toattempts.py).
| Field | Type | Notes |
|---|---|---|
note_text |
str |
Required, non-blank (field_validator). |
flag_for_review |
bool |
Default False. |
source |
Literal["user","mcp"] |
Default "user". |
Success — 201 NoteOut:
{
"id": 88,
"attempt_id": 738,
"note_text": "Mixed up Le Chatelier direction.",
"flag_for_review": true,
"source": "user",
"created_at": "2026-05-30T14:02:11Z"
}Errors: 404 AttemptNotFoundError ("attempt not found"); 422 blank
note_text or invalid source literal.
The
GET …/notes(list,200 list[NoteOut]) andDELETE …/notes/{note_id}(204;404"note not found") routes share this model family; seeget_notes(app/api/v1/attempts.py:35) andremove_note(:63).
Upload a lecture-notes PDF for an explicit course and run it synchronously
through the vision-ingest pipeline (atomic facts with node_id NULL).
-
Auth: token. Handler:
post_pdf_ingest—app/api/v1/pdf.py:33. -
Body:
multipart/form-data— no Pydantic model.
| Part | Type | Notes |
|---|---|---|
course_id |
Form(int) |
The course this PDF belongs to. |
file |
File(UploadFile) |
The PDF. |
Success — 200 PdfIngestResponse:
{
"pdf_source_id": 17,
"pages": 12,
"new_facts": 34,
"dup_facts": 5,
"reused_pdf": false,
"extractor_version": "vision-ingest-v3",
"input_tokens": 18422,
"output_tokens": 2210,
"cached_tokens": 0
}Errors: 404 "course id={course_id} not found"; 422 missing/invalid
form course_id or file.
The outline router is whole-router token-gated (dependencies=[verify_coach_token])
and tagged outline. None of these declare a response_model; the shapes below
are the actual returned dicts.
POST /api/v1/courses → create_course (app/api/v1/outline.py:65), 201.
Body CreateCourseBody (local): slug and name required (min_length=1),
description optional. Returns {id, slug, name, description}.
{ "id": 3, "slug": "biochem-101", "name": "Biochemistry I", "description": null }Errors: 409 CONFLICT "course slug {slug} already exists (id=...)"; 422
missing slug/name.
POST /api/v1/courses/{course_id}/outline:import → import_outline (:87), 200.
Body is a raw dict[str, Any] in the §I shape
{course: {slug, name, description?}, nodes: [{path, kind, name, position?}]},
validated by validate_outline_schema (not a Pydantic body model). Returns the
course payload plus a nodes_imported count. Atomic validate-then-materialize:
it wipes the existing outline for the course and inserts the new tree (V-O2/V-O3).
Errors:
| Status | Condition |
|---|---|
404 |
"course id={course_id} not found". |
409 CONFLICT |
Upload course.slug mismatch vs the course_id's slug (checked twice — pre-validate on body_course.slug, post-validate on validated.course.slug). |
422 UNPROCESSABLE_ENTITY |
{ "errors": [...] } from OutlineSchemaValidationError (whole-upload rejection, V-O2). |
500 INTERNAL_SERVER_ERROR |
OutlineImportError during materialize. |
GET /api/v1/courses/{course_id}/outline → read_outline (:164), 200.
Returns {course, nodes: [{id, parent_id, kind, name, depth, position}]}
ordered by depth/position/id. 404 if the course is missing.
GET /api/v1/outline/nodes/{node_id}/mastery → node_mastery (:188) and
GET /api/v1/outline/courses/{course_id}/mastery → course_mastery (:199)
return compute_node_mastery / compute_course_mastery dicts (T44, V-O5 set
rollup). 404 NodeNotFoundError / CourseNotFoundError respectively.
The kb-reads router is whole-router token-gated and tagged kb-reads. All
three filtered lists share a limit query param: int, default 100,
ge=1 le=500 (out-of-range → 422).
GET /api/v1/concept-edges → list_concept_edges (app/api/v1/kb_reads.py:54).
Query: node_id (int|None, matches src or dst), kind (str|None,
"similarity"|"manual"), limit. Returns rows of:
{
"id": 51,
"from": { "node_id": 12, "name": "Glycolysis" },
"to": { "node_id": 19, "name": "Gluconeogenesis" },
"kind": "similarity",
"score": 0.83,
"created_at": "2026-05-29T09:12:00Z"
}GET /api/v1/atomic-facts → list_atomic_facts (:92). Query: node_id
(int|None), pdf_source_id (int|None), limit. Rows:
{
"id": 904,
"text": "Hexokinase phosphorylates glucose using ATP.",
"node_id": 12,
"page": 4,
"pdf_source": { "id": 17, "filename": "lecture-03.pdf" },
"created_at": "2026-05-29T09:10:00Z"
}GET /api/v1/notion/pages → list_notion_pages (:131). Query: node_id
(int|None). Reads our notion_pages rows only — never reads back Notion
content (T47, V-N1/V-N2). Rows: {node_id, title, url, notion_page_id, tags, last_synced_at}. No declared errors beyond auth.
GET /api/v1/pdf-sources → list_pdf_sources (:162). Query: course_id
(int|None), status (str|None), limit. Rows: {id, filename, sha256, status, facts_count, course_id, ingested_at, created_at} (T48).
All tutor routes are token-gated (get_session, verify_coach_token) and
return service-layer dicts (no declared response_model). The MCP seam detail
lives in Intelligence Layer; these are the data-exposure
endpoints behind it (V-M1).
| Route | Handler | Query | Notable errors |
|---|---|---|---|
GET questions/by-qid/{qid} |
get_question_by_qid (tutor.py:32) |
— | 404 {reason:"question_not_found", qid} |
GET questions/by-attempt-id/{attempt_id} |
get_question_by_attempt_id (:44) |
— |
404 {reason:"attempt_not_found"…}, 404 {reason:"question_not_found"…}
|
GET captures/recent |
get_recent_captures (:61) |
n (default 5, ge=1 le=50) |
422 n out of range |
GET sessions/latest |
get_latest_session_id (:70) |
— | — (returns {"test_id": null} if none) |
GET sessions/recent |
get_recent_sessions (:78) |
n (default 5, ge=1 le=50) |
422 n out of range |
GET sessions/{test_id}/summary |
get_session_summary (:87) |
— | 404 {reason:"session_not_found", test_id} |
GET attempts/flagged |
get_flagged_attempts (:99) |
limit (default 20, ge=1 le=100) |
422 limit out of range |
GET outline/nodes/search |
search_outline_nodes (:110) |
q (required), course (`str |
None), limit(default 20,ge=1 le=100`) |
GET outline |
get_outline_tree (:128) |
course (required) |
404 {reason:"course_not_found"…}, 422 missing course
|
GET outline/nodes/{node_id}/subtree |
get_node_subtree (:142) |
— | 404 {reason:"node_not_found", node_id} |
GET healthz |
healthcheck (:156) |
— | — |
Note: Outline search is domain-blind (T22) — it ranks node names without privileging any one uploaded schema (V-O3).
Persist-only (V-M1) discriminator factor write from the MCP host; append-only,
deduped by (question_id, factor_text) (V-M3).
-
Auth: token. Handler:
post_discriminator—app/api/v1/pkm.py:23. -
Body model:
DiscriminatorIn.
| Field | Type | Notes |
|---|---|---|
question_id |
int |
Required. |
factor_text |
str |
Stripped then min_length=1 — whitespace-only → 422. |
node_id |
int | None |
Optional. |
Success — 200 DiscriminatorOut:
{
"id": 22,
"question_id": 95,
"factor_text": "Distinguish competitive vs noncompetitive inhibition by Vmax.",
"node_id": 12,
"notion_block_id": null,
"created_at": "2026-05-30T14:05:00Z"
}Errors: 404 {reason:"question_not_found", question_id};
404 {reason:"node_not_found", node_id}; 422 blank factor_text.
Note: This is the only write under
/pkm; the seam persists data fields only — no verdict, grade, or heuristic (V-M1). The MCP host does the reasoning.
All Anki routes are per-route token-gated. The shared list filters on
assignments and reviews are status (query alias status → status_filter)
and window_days (int|None, ge=1 le=365; out of range → 422).
POST /api/v1/anki/sync → sync_anki (anki.py:76), 200. Stays soft on
AnkiConnect being down — no 500; returns the §V4 error envelope:
{ "synced_cards": 0, "linked_qids": 0, "reviews_synced": 0, "error": "anki unreachable" }GET /api/v1/anki/review-queue → get_review_queue (anki.py:110), 200.
Query limit (default 50, ge=1 le=200). Returns list[AnkiReviewQueueCardOut]
(extends AnkiCardOut with per-card retention + retrievability, both
nullable):
{
"id": 30, "anki_card_id": 1500300, "deck_name": "AnKing::MCAT",
"note_id": 88001, "model_name": "Cloze",
"fields_json": {"Text": "..."},
"due_date": "2026-05-31", "interval_days": 12, "ease": 2500,
"lapses": 1, "queue": 2, "sync_at": "2026-05-30T08:00:00Z",
"tags": [{"tag_raw": "uworld::qid::9921", "parsed_kind": "qid",
"topic_id": null, "question_qid": "9921"}],
"retention": 0.91, "retrievability": 0.86
}GET /api/v1/anki/cards/by-qid/{qid} → get_cards_for_qid (anki.py:132),
200. Returns list[AnkiCardOut] (same shape minus the two metric fields)
for cards tagged uworld::qid::{qid}.
POST /api/v1/anki/assignments → create_assignment_route (anki_assign.py:48),
201. Body AnkiAssignmentCreateIn:
| Field | Type | Notes |
|---|---|---|
scope_kind |
Literal["cc","topic"] |
|
scope_value |
str |
1–64 chars; a topic_id (as str) or a CC code. |
scheduled_unlock_at |
datetime |
|
max_cards |
int | None |
> 0 if present. |
priority |
Literal["most_specific_first","random","mature_first","young_first"] |
Default "most_specific_first". |
Creates the assignment and snapshots resolved card_ids (V52). Returns
AnkiAssignmentOut:
{
"id": 7, "scope_kind": "topic", "scope_value": "12",
"scheduled_unlock_at": "2026-06-01T06:00:00Z", "actual_unlock_at": null,
"card_ids": [1500300, 1500301], "max_cards": 25,
"priority": "most_specific_first", "status": "scheduled",
"error_text": null, "failure_count": 0,
"created_at": "2026-05-30T14:00:00Z", "updated_at": "2026-05-30T14:00:00Z"
}Errors: 422 AssignmentError (e.g. V52 invalid scope), re-raised with
str(exc); 422 schema validation on scope_kind/scope_value/max_cards.
GET /api/v1/anki/assignments → list_assignments_route (anki_assign.py:71),
200. list[AnkiAssignmentOut] ordered by scheduled_unlock_at; filters
status, window_days.
PATCH /api/v1/anki/assignments/{assignment_id} → patch_assignment_route
(anki_assign.py:97), 200. Body AnkiAssignmentPatchIn
(status: Literal["skipped","completed"]) — the two human-driven transitions
(mark_skipped / mark_completed_manual). Errors: 404
AssignmentNotFoundError; 409 AssignmentTerminalError (V51 terminal-state
transition); 422 invalid status.
POST /api/v1/anki/reviews → create_review_route (anki_review.py:30),
201. Body AnkiReviewCreateIn: card_ids: list[int] (min_length=1,
AnKing-native BIGINT ids), review_date: date. Standalone pending review (V53
amended, no FK to assignments). Returns AnkiReviewOut:
{
"id": 4, "review_date": "2026-06-02", "card_ids": [1500300, 1500301],
"deck_name": "gradient::review::4", "status": "pending",
"error_text": null, "failure_count": 0,
"created_at": "2026-05-30T14:10:00Z", "pushed_at": null
}Errors: 422 empty card_ids or missing review_date.
GET /api/v1/anki/reviews → list_reviews_route (anki_review.py:47), 200.
list[AnkiReviewOut] ordered by review_date; filters status, window_days.
GET /api/v1/anki/load-config → get_load_config_route (anki_load.py:56),
200 and POST → upsert_load_config_route (:68), 200. The singleton
id=1 row (V59); GET auto-seeds the default (card budget 200, minutes 60) on
miss. POST body AnkiLoadConfigIn: daily_card_review_budget: int > 0,
daily_minutes_budget: Decimal > 0 (non-positive → 422). Both return
AnkiLoadConfigOut:
{ "daily_card_review_budget": 200, "daily_minutes_budget": "60", "updated_at": "2026-05-30T14:12:00Z" }GET /api/v1/anki/load-adherence → get_load_adherence_route (anki_load.py:93),
200. Query window_days (default 30, ge=1 le=365). Deterministic V54
adherence (data-only, no recommendations per V60) plus the T43 reviewed-count
series. Returns AnkiLoadAdherenceOut:
{
"window_days": 30, "projected_daily_load": 180, "projected_daily_minutes": 54.0,
"daily_card_review_budget": 200, "daily_minutes_budget": 60.0,
"headroom_card_review_pct": 10.0, "headroom_minutes_pct": 10.0,
"status_label": "within_budget",
"reviewed_series": [ { "date": "2026-05-29", "reviewed": 175 } ]
}Note:
admin.pyexposes norecategorizeroute. The only mutation routes are the manual-tag create and the tag delete below.
POST /api/v1/admin/questions/{question_id}/tags → create_manual_tag
(admin.py:119), 201. Effectively open (no verify_coach_token, no
localhost guard). Body ManualTagBody: topic_id: int|None,
content_category_id: int|None, skill: int|None — exactly one must be
non-null (model_validator, else 422 "exactly one of topic_id, content_category_id, skill must be provided"). Returns:
{
"tag_id": 1201, "question_id": 95, "topic_id": 12,
"content_category_id": null, "skill": null, "confidence": 1.0,
"source": "manual", "rationale": null, "extractor_version": "manual"
}Errors: 404 ManualTagQuestionNotFoundError; 422 ManualTagValidationError;
409 CONFLICT ManualTagConflictError.
Note: the catalog described
skillloosely; in codeManualTagBody.skillis typedint | None(a skill id), like the other two scope fields.
DELETE /api/v1/admin/tags/{tag_id} → delete_tag (admin.py:236), 204.
Effectively open (no auth dependency, no localhost guard). Behavior keys off
source: source="manual" → hard delete; source="llm" → soft-override
(is_overridden=True, overridden_at stamped); anything else → 403. Errors:
404 "tag_id={tag_id} not found"; 403 "refusing to delete tag with source={source!r}; only manual and llm tags can be removed".
GET /api/v1/admin/jobs → list_jobs (admin.py:194), 200. Token-gated.
list[{job_id, next_run_time}] from the live scheduler.
POST /api/v1/admin/jobs/{job_name}/trigger → trigger_job (admin.py:203),
202. Token-gated. Validates job_name against the _VALID_JOBS allowlist
(run_anki_sync, run_anki_assignment_unlock, run_anki_assignment_complete,
run_anki_review) and nudges the scheduler. Returns
{"status": "triggered", "job": "<name>"}.
Errors: 404 "unknown job: {job_name}"; 409 "{job_name} already running"
(in _inflight); 503 "scheduler not running" (scheduler.get_job returns
None).
Note:
_VALID_JOBSis the Anki-job subset only — the ingest/embed/grounded jobs are not triggerable here. See the full scheduler catalog in Architecture.
GET /api/v1/admin/status → system_status (admin.py:217), 200.
Token-gated. Probes AnkiConnect / OpenAI / Notion reachability (each via its own
overridable dep client, V16) and folds each scheduler job's last TaskRun
outcome into a settings-panel health payload (T39). Returns
collect_system_status(...).
Serve PDF/image media from MEDIA_ROOT. Open, mounted at root (not under
/api/v1). file_path is a path-converter and may contain slashes.
-
Handler:
serve—app/web/media.py:12. -
Success —
200:FileResponsewithCache-Control: public, max-age=86400. -
Errors:
404 "not found"onOSError/ValueErrorresolving the path, on path-traversal (resolved target not relative toMEDIA_ROOTviaresolve()+is_relative_to), or when the target is not a file.
- Function Index — handler signatures + service seams behind these routes.
- Data Models — the tables these payloads read/write.
- Intelligence Layer — the MCP tutor seam and the AI tagging chain.
-
Core Library — the
gradient-coretransport/runtime boundary. -
Features · the
Feature-*pages linked per group above. - Getting Started · Glossary
Reference
Features
Design Notes