-
Notifications
You must be signed in to change notification settings - Fork 0
Feature Question Capture
Summary: Capture is the front door to the study engine. You POST a single practice-question capture — the rendered question, the answer choices, your selected answer, and whether it was correct — and the backend normalizes it into durable Question + Attempt rows (plus any Passage and media), keyed by an external qid. Re-posting the same qid updates the existing question in place rather than duplicating it, so the same source can re-scrape a question safely. Every capture names the source it came from (UWorld, a generic web Qbank, or manual entry) and — since T56 — the course you are studying it under, which is what lets the grounded-tag categorizer place the question against that course's outline.
The canonical producer is the Gradient Capture Chrome extension; the contract is open, so any client that can speak the CapturePayload shape and hold the coach token can ingest captures.
sequenceDiagram
participant Ext as Gradient Capture (extension)
participant API as POST /api/v1/captures
participant Ing as ingest_capture (dispatcher)
participant Ad as Source adapter
participant Norm as normalize_capture
participant DB as Postgres
participant Job as run_grounded_tag (scheduler)
Ext->>API: CapturePayload (X-Coach-Token, source, course_slug, qid, parsed...)
API->>Ing: ingest_capture(payload, session)
Ing->>Ad: get_adapter(payload.source).ingest(...)
Ad->>Norm: normalize_capture(payload, session)
Norm->>DB: resolve course_slug -> course_id (V-CAP2)
Norm->>DB: RawCapture, Media, Passage, Question (upsert), Attempt
Norm-->>API: IngestResponse (ids)
API-->>Ext: 200 IngestResponse
Note over Job,DB: later, out of band
Job->>DB: tag_pending() recalls needs_categorization questions<br/>scoped to question.course_id -> writes QuestionTag rows
The capture path is synchronous and fast: it writes rows and returns ids. Categorization is deferred — the question lands with needs_categorization = true and the scheduler's run_grounded_tag job picks it up on its next interval. The HTTP response never waits on the LLM.
| Method | Path | Auth | Body model | Response model | Purpose |
|---|---|---|---|---|---|
POST |
/api/v1/captures |
token | CapturePayload |
IngestResponse |
Ingest one practice-question capture into Question/Attempt (+Passage/media). |
Auth is the X-Coach-Token header, validated by verify_coach_token (see API Reference). On success the endpoint returns 200 with the created/affected row ids.
CapturePayload is strict — model_config = ConfigDict(extra="forbid"), so an unknown top-level key is a 422, not silently dropped. Top-level fields:
| Field | Type | Notes |
|---|---|---|
source |
str = "uworld"
|
Source discriminator (§A). Routes to the matching adapter; defaults to uworld for back-compat with the current extension. |
course_slug |
Optional[str] = None
|
Capture-time course intent (V-CAP2). Resolved against courses.slug at ingest. See Course scoping. |
qid |
str |
External question key (the de-dup key). |
uworld_test_id |
Optional[str] |
Session/test id; stamped onto RawCapture and Attempt. |
captured_at |
datetime |
Used as both the capture time and the attempt's attempted_at. |
html |
str |
Raw page HTML, stored verbatim on RawCapture.raw_html. |
parsed |
ParsedCapture |
The normalized question/answer payload (below). |
media |
list[MediaCapture] = []
|
Base64 image assets referenced by stem/choices. |
parse_warnings |
list[ParseWarning] = []
|
Client-side scrape warnings, merged into the stored warning list. |
extension_version |
str |
Producer version string ("manual" for manual entry). |
ParsedCapture carries the question itself: optional passage, stem_html / stem_plain, choices (list[ChoiceItem]), correct_choice (a required str on the wire), optional explanation_html/explanation_plain, uworld_aamc_tags, and the attempt fields selected_choice, is_correct, time_seconds?, flagged (default false). Each ChoiceItem has key, html, plain, and media_content_hashes linking it to entries in the top-level media array.
Note:
ParsedCapture.correct_choiceis a requiredstr, so today every adapter supplies the answer at capture time. Thequestions.correct_choicecolumn is nullable (V-CAP1, T55) to leave room for a future deferred-answer source that captures a question before its answer is revealed —NULLwould mean "answer pending". No such source exists yet; the nullability is forward-looking only.
capture_id: int # the RawCapture row
question_id: int # created or updated Question
attempt_id: int # the new Attempt
passage_id: Optional[int] # set iff the capture carried a passage
media_ids: list[int] # Media rows touched (created or matched) this ingest
The chain is a thin controller -> dispatcher -> adapter -> shared normalizer. Adapters are deliberately trivial; all the real work is in normalize_capture.
-
post_capture(app/api/v1/captures.py:17) — controller. Awaitsingest_capture; catchesUnknownSourceError/UnknownCourseErrorand re-raises asHTTPException(422, str(exc)). -
ingest_capture(app/services/ingest.py:17) — dispatcher (T3). One line:get_adapter(payload.source).ingest(payload, session). Runs inside the endpoint's transaction; the endpoint owns commit/rollback. -
get_adapter/register_adapter(app/services/adapters/init.py) — the §A registry. Built-in adapters register themselves on import for side effect. -
UWorldAdapter/WebQbankAdapter/ManualAdapter(.ingest) — each registers under itssourcekey (uworld,web-qbank,manual) and delegates verbatim tonormalize_capture. They exist to prove the seam takes a new source without touching the entrypoint; none override any step today. -
normalize_capture(app/services/adapters/extension_capture.py:294) — the source-agnostic pipeline (below). Stampspayload.sourceonto every row it writes.
Internal helpers in extension_capture.py: _resolve_course_id (slug -> course_id), _persist_media (decode + dedup by content_hash), _upsert_passage (dedup by uworld_passage_id then content_hash), _upsert_question (the upsert + re-scope logic), _resolve_choice (binds choice media hashes to ids), and _sanity_warnings (HTML-without-plain-text checks).
-
Resolve course (V-CAP2) before writing anything —
_resolve_course_id.Noneslug ->None(unscoped); an unknown slug raisesUnknownCourseErrorand aborts the whole ingest (422), so no unscoped row is ever persisted on a typo. -
RawCapture — the immutable audit row: full
raw_html, the entire payload asraw_json,source,course_id,uworld_test_id, and the initialparse_warnings. -
Media —
_persist_mediadecodes each base64 asset, writes it via the media store, and upserts aMediarow keyed oncontent_hash(ON CONFLICT DO NOTHING, then re-select). Empty bytes or a decode failure append a warning and skip that asset rather than failing the capture. -
Passage — if present,
_upsert_passagefinds an existing passage byuworld_passage_id, else by SHA-256content_hash; updates HTML in place if it changed, else inserts. -
Resolve choices —
_resolve_choicerewrites each choice'smedia_content_hashesinto resolvedmedia_ids(a hash with no matching media is a hard error). -
Question upsert —
_upsert_questionkeyed onqid(see De-dup & upsert). -
Attempt — always a new
Attemptrow (captures are append-only history):selected_choice,is_correct,time_seconds,flagged,uworld_test_id,attempted_at = captured_at. -
Sanity warnings —
_sanity_warningsflags any field whosehtmlhas visible text but whoseplainis empty; merged intoRawCapture.parse_warnings.
A new or content-changed question is written with needs_categorization = true. It is not tagged inline. The scheduler's run_grounded_tag job (no-op without OPENAI_API_KEY) calls tag_pending (app/services/kb/jobs.py:271), which selects every needs_categorization question and scopes its outline recall:
- If the question has a
course_id(stamped at ingest), recall is scoped to that course's outline — multi-course installs tag normally. - If
course_idisNULL(legacy/unscoped capture), it falls back to the sole course iff exactly one course exists; otherwise the question is skipped with a "course ambiguous" log and stays pending.
On a successful tag the job clears needs_categorization = false. See Intelligence Layer for the recall -> grounded-pick -> calibrate -> persist chain (V-L3, V69).
-
RawCapture — immutable audit row; one per POST.
source,course_id(FK ->courses,ON DELETE SET NULL),qid,raw_html,raw_json,parse_warnings,uworld_test_id. -
Question — upserted, keyed by unique
qid. Carriessource,course_id(FK ->courses,ON DELETE SET NULL),passage_id, stem/choices/answer/explanation,uworld_aamc_tags, and theneeds_categorizationflag. -
Attempt — one new row per capture;
ON DELETE CASCADEfromQuestion. -
Passage — shared across questions, deduped by
uworld_passage_id/content_hash. -
QuestionTag — written later by the grounded-tag job, not by the capture path. Canonical target is
node_id(V-T1); capture only setsneeds_categorization.
Media rows are Media, served at /media/{file_path} (see Knowledge Base / API Reference).
Every capture is course-scoped at ingest. The adapter resolves course_slug against courses.slug and stamps course_id on both RawCapture and Question. course_slug stays Optional only for back-compat with the pre-course extension and the single-course case; a multi-course install effectively requires it, because an unscoped question can't be deterministically tagged when more than one course exists. An unknown slug is a 422 (UnknownCourseError) — never a silent drop or a wrong-course tag.
On course delete the FK is SET NULL, not cascade: captures and attempts are your study history and outlive regenerable course content.
If a capture supplies a course_id that differs from the existing question's, _upsert_question adopts the new course and re-flags needs_categorization = true so the categorizer re-recalls against the new outline. A capture with no course_slug (course_id is None) never clears an existing binding — re-posting an unscoped capture won't un-scope an already-scoped question.
-
Question is keyed on
qid(UNIQUE). First sighting inserts; a repeat sighting updates only if content changed — stem HTML, correct choice, explanation, passage link, choices, oruworld_aamc_tags. A content change bumpslast_updated_at; a tag change also re-setsneeds_categorization = true. An unchanged re-post is a no-op upsert. - Attempt is never deduped — every capture appends a fresh attempt, which is how the same question accrues an attempt history.
-
Passage dedups by
uworld_passage_idfirst, then by contentcontent_hash. -
Media dedups by
content_hashviaON CONFLICT DO NOTHING.
source is an open discriminator — no closed enum at the DB or schema layer. Dispatch is registry-keyed: get_adapter(source) raises UnknownSourceError (-> 422) for an unregistered source, listing the known set. Built-in registered sources today: uworld, web-qbank, manual. New sources register in app/services/adapters/ without touching the ingest entrypoint.
Note: The
pdf-qsetadapter was cut (T37, 2026-05-28). PDF ingress is notes-only — lecture-notes PDFs flow throughkb/pdf_ingestintoatomic_facts, not through the capture path. Practice questions only ever arrive via the capture adapters above (each carrying an attempt). See Knowledge Base.
| Condition | Result |
|---|---|
Unregistered source
|
422, UnknownSourceError re-raised as HTTPException with str(exc) detail. |
course_slug matches no course |
422, UnknownCourseError, ingest aborted before any write. |
Malformed / extra-key CapturePayload
|
422 RequestValidationError. The _capture_validation_handler in app/main.py logs the (truncated) validation errors for any /api/v1/captures path. |
Missing / invalid X-Coach-Token
|
401 "invalid coach token". |
Choice references an unknown media content_hash
|
ValueError from _resolve_choice (hard failure — the whole capture rolls back). |
| Empty/undecodable media bytes | Warning appended; that asset skipped, capture still succeeds. |
Because ingest_capture runs inside the endpoint's session, any uncaught exception rolls back the entire capture — partial rows are never committed.
-
Intelligence Layer — the grounded-tag categorizer that consumes
needs_categorizationquestions. -
Core Library — the domain-blind
gradient-coreboundary the §A seam lives behind. - Data Models · API Reference · Function Index · Glossary
- Sibling features: Knowledge Base · Outline & Mastery · Anki Sync · Tutor MCP · Platform Admin
- Home · Architecture · Getting Started
Reference
Features
Design Notes