Skip to content

Feature Question Capture

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

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.

User flow

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
Loading

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.

Endpoints

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 contract

CapturePayload is strictmodel_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_choice is a required str, so today every adapter supplies the answer at capture time. The questions.correct_choice column is nullable (V-CAP1, T55) to leave room for a future deferred-answer source that captures a question before its answer is revealed — NULL would mean "answer pending". No such source exists yet; the nullability is forward-looking only.

IngestResponse

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

Functions involved

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. Awaits ingest_capture; catches UnknownSourceError / UnknownCourseError and re-raises as HTTPException(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 its source key (uworld, web-qbank, manual) and delegates verbatim to normalize_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). Stamps payload.source onto 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).

What normalize_capture does, step by step

  1. Resolve course (V-CAP2) before writing anything — _resolve_course_id. None slug -> None (unscoped); an unknown slug raises UnknownCourseError and aborts the whole ingest (422), so no unscoped row is ever persisted on a typo.
  2. RawCapture — the immutable audit row: full raw_html, the entire payload as raw_json, source, course_id, uworld_test_id, and the initial parse_warnings.
  3. Media_persist_media decodes each base64 asset, writes it via the media store, and upserts a Media row keyed on content_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.
  4. Passage — if present, _upsert_passage finds an existing passage by uworld_passage_id, else by SHA-256 content_hash; updates HTML in place if it changed, else inserts.
  5. Resolve choices_resolve_choice rewrites each choice's media_content_hashes into resolved media_ids (a hash with no matching media is a hard error).
  6. Question upsert_upsert_question keyed on qid (see De-dup & upsert).
  7. Attempt — always a new Attempt row (captures are append-only history): selected_choice, is_correct, time_seconds, flagged, uworld_test_id, attempted_at = captured_at.
  8. Sanity warnings_sanity_warnings flags any field whose html has visible text but whose plain is empty; merged into RawCapture.parse_warnings.

How a capture reaches the categorizer

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_id is NULL (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).

Data models touched

  • 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. Carries source, course_id (FK -> courses, ON DELETE SET NULL), passage_id, stem/choices/answer/explanation, uworld_aamc_tags, and the needs_categorization flag.
  • Attempt — one new row per capture; ON DELETE CASCADE from Question.
  • 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 sets needs_categorization.

Media rows are Media, served at /media/{file_path} (see Knowledge Base / API Reference).

Edge cases & business rules

Course scoping (V-CAP2)

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.

Re-scope on re-capture (V-CAP2)

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.

De-dup & upsert rules

  • 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, or uworld_aamc_tags. A content change bumps last_updated_at; a tag change also re-sets needs_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_id first, then by content content_hash.
  • Media dedups by content_hash via ON CONFLICT DO NOTHING.

Source dispatch (§A)

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-qset adapter was cut (T37, 2026-05-28). PDF ingress is notes-only — lecture-notes PDFs flow through kb/pdf_ingest into atomic_facts, not through the capture path. Practice questions only ever arrive via the capture adapters above (each carrying an attempt). See Knowledge Base.

Validation & errors

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.

See also

Clone this wiki locally