-
Notifications
You must be signed in to change notification settings - Fork 0
Data Models
Catalog of every persisted ORM model and every request/response Pydantic schema in
gradient-server. Persisted ORM models (SQLAlchemy Base subclasses under
app/models/) own the Postgres tables; request/response schemas (Pydantic
BaseModel subclasses under app/schemas/) are the wire shapes the
API Reference documents — they never touch the database directly.
For how these models are produced and consumed, see Features; for the
LLM tagging chain that writes the *_tags tables, see
Intelligence Layer; for the gradient-core boundary, see
Core Library.
Conventions across the schema:
-
sourceis an open discriminator (§A) on captures — there is no closed enum. Status/source/kindfields elsewhere are CHECK-constrainedText, not Python enums. The sole real Python/DB enum isTaskRunStatus. -
Tag invariants (V-T1..V-T3) govern every
*_tagstable identically:-
V-T1 — the canonical tag target is a single
node_id(the PoC 3-target shape is retired). -
V-T2 —
source IN ('schema_map','llm','manual');extractor_versionstamps the prompt/schema that produced LLM rows; an LLM re-run replaces only its ownsource='llm'rows. -
V-T3 —
confidenceis required iffsource='llm'(NULL otherwise), lives in[0.0, 1.0], and a value< 0.5must setmanual_review(CHECK enforced).
-
V-T1 — the canonical tag target is a single
-
Versioning stamps —
embedding_version(V-E1) andextractor_version(V-T2 / V-KB3) make a policy change a clean re-run rather than a destructive edit.
Note: Source of truth is the live code under
app/. Where this page and the source disagree, the.pyfile wins. Field-level uncertainties were verified against the source; remaining caveats are flagged inline with > Note: callouts.
The capture → question → attempt spine plus the outline tree, tags, notes, features, discriminators, media, and scheduler bookkeeping. Backs Question capture, Outline / domain model, and the tutor seam.
app/models/outline.py:25 · table courses · feature: outline
A study domain. The core is domain-blind (§A): MCAT/AAMC is just one uploaded
schema (V-O3), not a privileged branch. Adding a course then importing an outline
materializes the outline_nodes tree.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
slug |
Text | yes | Column-level unique=True
|
name |
Text | yes | Display name |
description |
Text | no | Optional |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
nodes -> list[OutlineNode](back_populates="course", cascade all, delete-orphan). -
Constraints:
slugunique at column level.
app/models/outline.py:48 · table outline_nodes · feature: outline
The sole outline hierarchy (V-O1): a recursive, arbitrary-depth tree. Rollup is
subtree membership (set union), not a sum. AAMC maps section/fc/cc/topic onto the
generic kind label — per-course, not hardcoded.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
course_id |
Integer | yes | FK courses.id ON DELETE CASCADE |
parent_id |
Integer | no | Self-FK outline_nodes.id ON DELETE CASCADE; NULL = root |
kind |
Text | yes | Per-course level label (section/unit/lecture/concept/…) |
name |
Text | yes | Importer (T9) rejects names containing the path delimiter ' >> '
|
depth |
Integer | yes | Tree depth |
position |
Integer | yes | Sibling ordering |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
course(back_populates="nodes");parent(remote_side=[OutlineNode.id],back_populates="children");children -> list[OutlineNode](back_populates="parent", cascade all, delete-orphan). -
Constraints / indexes: UQ
uq_outline_nodes_course_parent_name(course_id, parent_id, name); indexesix_outline_nodes_course_id,ix_outline_nodes_parent_id. -
Notes: module-level constant
OUTLINE_PATH_DELIMITER = ' >> '(V-O4) is the reserved node-path delimiter.
app/models/captures.py:25 · table raw_captures · feature: captures
The unparsed inbound capture record (raw HTML + structured payload). Course-scope is captured at ingest (V-CAP2).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
source |
Text | yes | Open source discriminator (§A); server_default 'uworld' — no enum |
course_id |
Integer | no | FK courses.id ON DELETE SET NULL (V-CAP2); NULL = unscoped |
qid |
Text | yes | External question id |
captured_at |
DateTime(tz) | yes | Client-supplied capture time |
raw_html |
Text | yes | Raw captured HTML |
raw_json |
JSONB | yes | Raw structured payload |
parse_warnings |
JSONB (list[dict]) | no | Parse warnings |
extension_version |
Text | yes | Capturing extension version |
uworld_test_id |
Text | no | Source session/test id |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
course_id -> courses.idFK only — norelationship()declared. -
Indexes:
ix_raw_captures_qid,ix_raw_captures_captured_at,ix_raw_captures_course_id, partialix_raw_captures_qid_with_warnings(WHEREparse_warnings IS NOT NULL). NoUniqueConstraint.
app/models/captures.py:62 · table passages · feature: captures
A shared passage block, deduped by content_hash.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
uworld_passage_id |
Text | no | Source passage id; partial UNIQUE when NOT NULL |
content_hash |
Text | yes | Dedup key; UNIQUE |
html |
Text | yes | Passage HTML |
plain_text |
Text | yes | Passage plain text |
first_seen_at |
DateTime(tz) | yes | server_default now() |
last_updated_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
questions -> list[Question](back_populates="passage"). -
Constraints / indexes: UQ
uq_passages_content_hash; partial uniqueix_passages_uworld_idonuworld_passage_idWHERE NOT NULL.
app/models/captures.py:89 · table questions · feature: captures
The deduped question record; needs_categorization drives the grounded-tag job.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
source |
Text | yes | Open discriminator (§A); server_default 'uworld'
|
qid |
Text | yes | External key; column-level unique=True
|
course_id |
Integer | no | FK courses.id ON DELETE SET NULL (V-CAP2); NULL → single-course fallback |
passage_id |
Integer | no | FK passages.id ON DELETE SET NULL |
stem_html |
Text | yes | Stem HTML |
stem_plain |
Text | yes | Stem plain text |
choices |
JSONB (list[dict]) | yes | Answer choices |
correct_choice |
Text | no | Nullable per V-CAP1 — deferred-answer sources may record before the answer is known; NULL = pending |
explanation_html |
Text | no | Explanation HTML |
explanation_plain |
Text | no | Explanation plain text |
uworld_aamc_tags |
JSONB (list[str]) | no | Source-supplied AAMC tag strings |
needs_categorization |
Boolean | yes |
server_default true; drives run_grounded_tag
|
first_seen_at |
DateTime(tz) | yes | server_default now() |
last_updated_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
passage(back_populates="questions");attempts -> list[Attempt]andtags -> list[QuestionTag](bothback_populates, cascade all, delete-orphan);course_idFK only (norelationship()). -
Indexes:
ix_questions_passage_id,ix_questions_source,ix_questions_course_id, partialix_questions_needs_categorization(WHEREneeds_categorization = true).qidunique at column level.
Note: §I plans to rename
qid -> external_idwith UQ(source, external_id); not yet applied in live code.
app/models/captures.py:150 · table attempts · feature: captures
A single answering of a question.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
question_id |
Integer | yes | FK questions.id ON DELETE CASCADE |
source |
Text | yes | Open discriminator (§A); server_default 'uworld'
|
attempted_at |
DateTime(tz) | yes | When attempted |
selected_choice |
Text | yes | Choice selected |
is_correct |
Boolean | yes | Whether correct |
time_seconds |
Integer | no | Time spent — no timing weighting in any scoring (V-spec) |
flagged |
Boolean | yes | server_default false |
uworld_test_id |
Text | no | Source session/test id |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
question(back_populates="attempts"). -
Indexes:
ix_attempts_question_id,ix_attempts_attempted_at, compositeix_attempts_question_attempted(question_id, attempted_at),ix_attempts_uworld_test_id,ix_attempts_source. NoUniqueConstraint.
Note: §I plans to rename
uworld_test_id -> session_ref.
app/models/captures.py:182 · table question_tags · feature: categorization
Canonical tag (V-T1) attaching a question to one outline node. Tag invariants V-T1..V-T3 apply (see top of page).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
question_id |
Integer | yes | FK questions.id ON DELETE CASCADE |
node_id |
Integer | yes | FK outline_nodes.id ON DELETE CASCADE — sole canonical target (V-T1) |
source |
Text | yes |
schema_map|llm|manual (V-T2, CHECK) |
confidence |
Numeric(3,2) | no | Required iff source='llm', range 0.0–1.0 (V-T3) |
rationale |
Text | no | LLM rationale |
extractor_version |
Text | no | Extractor prompt/schema stamp (V-T2) |
manual_review |
Boolean | yes |
server_default false; confidence < 0.5 must set this (V-T3) |
is_overridden |
Boolean | yes | server_default false |
overridden_at |
DateTime(tz) | no | When a human overrode |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
question(back_populates="tags");node_idFK only. -
Validators (CHECK):
ck_question_tags_confidence_when_llm(V-T3),..._confidence_range(NULL or 0.0–1.0),..._low_conf_flagged(NULL OR>=0.5ORmanual_review, V-T3),..._source. -
Constraints / indexes: UQ
uq_question_tags_node_source(question_id, node_id, source); indexesix_question_tags_question_id,ix_question_tags_node_id.
app/models/attempt_note.py:17 · table attempt_notes · feature: attempts
A free-text note attached to an attempt; can flag the attempt for review.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
attempt_id |
Integer | yes | FK attempts.id ON DELETE CASCADE |
note_text |
Text | yes | Note body |
flag_for_review |
Boolean | yes | server_default false |
source |
Text | yes |
user|mcp (CHECK); Python-side default='user'
|
created_at |
DateTime(tz) | yes |
server_default 'now()' (string literal, not func.now()) |
-
Relationships:
attempt_idFK only — norelationship(). -
Validators (CHECK):
ck_attempt_notes_source: source IN ('user','mcp'). -
Indexes:
ix_attempt_notes_attempt_id; partialix_attempt_notes_flagged(WHEREflag_for_review = true).
app/models/features.py:20 · table question_features · feature: features
One deterministic feature row per question (the writer is fenced, V-RB1). Backs the fenced analyzer surface (see Schemas: analyzer).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
question_id |
Integer | yes | FK questions.id CASCADE; column-level unique=True (one row/question) |
question_format |
Text | yes |
discrete|passage_based (CHECK) |
reasoning_type |
Text | yes |
recall|comprehension|application|analysis|inference (CHECK) |
requires_calculation |
Boolean | yes | Calculation required |
calculation_steps |
Integer | yes |
server_default 0; CHECK >= 0
|
involves_graph_or_figure |
Boolean | yes | Graph/figure involvement |
involves_data_table |
Boolean | yes | Data-table involvement |
has_negative_phrasing |
Boolean | yes | Negative phrasing flag |
passage_length_bucket |
Text | no |
short|medium|long OR NULL (CHECK) |
passage_type |
Text | no |
experimental|descriptive|hypothesis_driven OR NULL (CHECK) |
distractor_difficulty |
Text | yes |
low|medium|high (CHECK) |
trap_distractor_present |
Boolean | yes | Trap-distractor flag |
common_misconception |
Text | no | Common misconception text |
jargon_density |
Text | yes |
low|medium|high (CHECK) |
key_concept_summary |
Text | yes | Key-concept summary |
extracted_at |
DateTime(tz) | yes | server_default now() |
extractor_version |
Text | yes | Stamp (non-null, required) |
-
Relationships:
question_idFK only (columnunique=True); norelationship(). -
Validators (CHECK):
..._question_format,..._reasoning_type,..._calculation_steps_nonneg,..._passage_length_bucket,..._passage_type,..._distractor_difficulty,..._jargon_density. -
Notes: no table-level UniqueConstraint beyond the column-level
question_idunique; no explicitIndex()declared.
app/models/discriminator_factor.py:20 · table discriminator_factors · feature: pkm
A persisted discriminator factor from the MCP/tutor seam (V-M1, V-M3, §I). Append-only by design (V-M3): re-writes dedupe but link history is preserved.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
question_id |
Integer | yes | FK questions.id ON DELETE CASCADE |
factor_text |
Text | yes | Discriminator text; part of dedup UQ |
node_id |
Integer | no | FK outline_nodes.id ON DELETE SET NULL; NULL when host hasn't chosen a node |
notion_block_id |
Text | no | Notion mirror anchor when V-N1/V-N2 sync ran |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
question_id,node_idFK only — norelationship(). -
Constraints / indexes: UQ
uq_discriminator_factors_question_text(question_id, factor_text); indexesix_discriminator_factors_question_id,ix_discriminator_factors_node_id.
app/models/media.py:12 · table media · feature: media
A stored media asset, deduped by content_hash, served by GET /media/{file_path}.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
content_hash |
Text | yes | Dedup key; column-level unique=True
|
local_path |
Text | yes | Local stored path |
original_url |
Text | no | Source URL |
mime_type |
Text | yes | MIME type |
width_px |
Integer | no | Width |
height_px |
Integer | no | Height |
byte_size |
Integer | yes | File size (bytes) |
description |
Text | no | AI-generated description |
description_model |
Text | no | Model that described it |
described_at |
DateTime(tz) | no | When described |
first_seen_at |
DateTime(tz) | yes | server_default now() |
-
Relationships: none — no FK, no
relationship(). -
Indexes: partial
ix_media_undescribedonidWHEREdescription IS NULL(drives the un-described-media backfill).
app/models/task_run.py:10 (enum) / :16 (table task_runs) · feature: scheduler
TaskRunStatus is a str + enum.Enum mapped to the Postgres ENUM
task_run_status — the only true Python/DB enum in app/models
(running / succeeded / failed). TaskRun is one row per scheduler job run
(in-flight guard + V41 partial-success).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
job_name |
String(64) | yes | Scheduler job name; column-level index=True
|
started_at |
DateTime(tz) | yes | Job start |
finished_at |
DateTime(tz) | no | Job finish |
status |
Enum(task_run_status) |
yes | Python default TaskRunStatus.running
|
items_processed |
Integer | yes | default 0 |
cost_usd |
NUMERIC(10,4) | no | Run cost |
error_text |
Text | no | Failure detail |
- Relationships: none.
-
Indexes: column-level index on
job_nameplus compositeix_task_runs_job_started(job_name, started_at). Python-side defaults (notserver_default) onstatus/items_processed.
The AnkiConnect mirror and the write-orchestration tables. Backs Anki integration. Per §V75 note-as-unit, the note (not the card) is the tag/topic-resolution target; cards read tags through their note.
Note: Anki native ids are stored as
BigIntegerand used directly with AnkiConnect — there is no local id bridge (§B11 lesson).note_id,anki_card_id, andreview_idare all native Anki ids.
app/models/anki.py:29 · table anki_notes · feature: anki
| Name | Type | Required | Description |
|---|---|---|---|
note_id |
BigInteger | yes | PK, autoincrement=False — native Anki note id (13-digit) |
deck_name |
Text | no | Indicative deck |
model_name |
Text | no | Note model name |
fields_json |
JSONB | no | User-authored note content |
-
Relationships:
cards -> list[AnkiCard](back_populates="note");tags -> list[AnkiNoteTag](back_populates="note", cascade all, delete-orphan).
app/models/anki.py:57 · table anki_cards · feature: anki
Scheduler-state mirror with idempotent upsert identity. §V75: card tags are read through the note via a viewonly relationship.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK (local surrogate) |
anki_card_id |
BigInteger | yes | Native Anki card id; part of UQ identity |
deck_name |
Text | yes | Deck name; part of UQ identity |
note_id |
BigInteger | no | FK anki_notes.note_id ON DELETE SET NULL (named fk_anki_cards_note_id) |
model_name |
Text | no | Note model name |
fields_json |
JSONB | no | Card fields |
due_date |
Date | no | §V2 scheduler state at sync |
interval_days |
Integer | no | Scheduler interval |
ease |
Integer | no | Scheduler ease |
lapses |
Integer | no | Lapse count |
queue |
Integer | no | Anki queue state |
sync_at |
DateTime(tz) | yes |
server_default now(); T3 flags stale at > ANKI_SYNC_INTERVAL_MINUTES * 2
|
first_seen_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
note(back_populates="cards");tags -> list[AnkiNoteTag]VIEWONLY viaprimaryjoin AnkiCard.note_id == foreign(AnkiNoteTag.note_id)(noback_populates/cascade, §V75);reviews -> list[AnkiCardReview](back_populates="card", cascade all, delete-orphan). -
Constraints / indexes: UQ
uq_anki_cards_deck_card(deck_name, anki_card_id)enables idempotent upsert (§V1); indexix_anki_cards_due_date.
app/models/anki.py:117 · table anki_note_tags · feature: categorization
Canonical tag (V-T1) on an AnkiNote (§V75). Tag invariants V-T1..V-T3 apply.
The MCAT-specific CHECK on parsed_kind was removed — the AnKing tag-shape parser is a
plugin (§A).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
note_id |
BigInteger | yes | FK anki_notes.note_id ON DELETE CASCADE |
tag_raw |
Text | yes | Raw Anki tag string; part of sync-idempotency UQ |
node_id |
Integer | no | FK outline_nodes.id ON DELETE SET NULL — canonical target (V-T1); NULL = unparsed/bare qid |
question_qid |
Text | no | Deliberately NOT a FK — AnKing carries qids for not-yet-scraped questions |
parsed_kind |
Text | yes | Plugin-defined free text (no MCAT CHECK) |
source |
Text | yes |
server_default 'schema_map'; schema_map|llm|manual (V-T2, CHECK) |
confidence |
Numeric(3,2) | no | Required iff source='llm' (V-T3) |
rationale |
Text | no | LLM rationale |
extractor_version |
Text | no | Extractor stamp (V-T2) |
manual_review |
Boolean | yes |
server_default false; V-T3 flag for < 0.5
|
is_overridden |
Boolean | yes | server_default false |
overridden_at |
DateTime(tz) | no | When overridden |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
note(back_populates="tags");node_idFK only. -
Validators (CHECK):
ck_anki_note_tags_confidence_when_llm(V-T3),..._confidence_range,..._low_conf_flagged(V-T3),..._source. -
Constraints / indexes: two UQs —
uq_anki_note_tags_node_source(note_id, node_id, source)(canonical) anduq_anki_note_tags_note_tag(note_id, tag_raw)(addTags sync idempotency); indexesix_anki_note_tags_node_id,ix_anki_note_tags_question_qid,ix_anki_note_tags_source.
app/models/anki.py:194 · table anki_card_reviews · feature: anki
Append-only revlog mirror (§V26 / §V27) backing windowed true-retention math (T37,
retention.py).
| Name | Type | Required | Description |
|---|---|---|---|
review_id |
BigInteger | yes | PK, autoincrement=False — Anki revlog id (unix-ms); enables idempotent incremental sync (startID = MAX+1, T36) |
card_id |
Integer | yes | FK anki_cards.id ON DELETE CASCADE |
reviewed_at |
DateTime(tz) | yes | Review timestamp |
ease |
Integer | yes | Grade 1–4 (CHECK); pass = ease IN {2,3,4}
|
type |
Text | yes |
learn|review|relearn|cram (CHECK); exclude learn from retention |
interval_before |
Integer | no | Interval before review |
interval_after |
Integer | no | Interval after review |
time_ms |
Integer | no | Time spent (ms) |
-
Relationships:
card(back_populates="reviews"). -
Validators (CHECK):
ck_anki_card_reviews_ease: ease BETWEEN 1 AND 4;ck_anki_card_reviews_type: type IN ('learn','review','relearn','cram'). -
Indexes:
ix_anki_card_reviews_card_reviewed(card_id, reviewed_at).
Note:
AnkiCardReviewis defined inanki.pyonly and is not re-exported fromapp/models/__init__.py.
app/models/anki.py:229 · table anki_assignments · feature: anki
A scheduled unlock of a scoped card/note set (V52 snapshot). Standalone — no FK.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK (filtered-deck naming derives from it) |
scope_kind |
Text | yes |
cc|topic (CHECK) |
scope_value |
Text | yes | Scope identifier |
scheduled_unlock_at |
DateTime(tz) | yes | Planned unlock |
actual_unlock_at |
DateTime(tz) | no | Actual unlock |
card_ids |
ARRAY(BigInteger) | yes | V52 snapshot of resolved cards (unsuspend target); drift tolerated |
note_ids |
ARRAY(BigInteger) | yes | §V75 notes backing the assignment (addTags target); server_default '{}'
|
max_cards |
Integer | no | Cap; CHECK NULL OR > 0
|
priority |
Text | no | Resolver mode for audit; server_default 'most_specific_first'
|
status |
Text | yes |
server_default 'pending'; V51 lifecycle (CHECK) |
error_text |
Text | no | Failure detail |
failure_count |
Integer | yes |
server_default 0; V55 retry-with-cap |
created_at |
DateTime(tz) | yes | server_default now() |
updated_at |
DateTime(tz) | yes | server_default now() |
-
Validators (CHECK):
..._scope_kind: IN ('cc','topic');..._status: IN ('pending','unlocked','completed','skipped','failed');..._max_cards_pos: max_cards NULL OR > 0. The V51 lifecycle ispending → unlocked → (completed|skipped|failed). -
Indexes:
ix_anki_assignments_status_scheduled(status, scheduled_unlock_at),ix_anki_assignments_actual_unlock.
app/models/anki.py:292 · table anki_reviews · feature: anki
A one-off filtered-deck review (V53, amended 2026-05-23 T76). Standalone — no FK to assignments.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK; filtered-deck name = <ANKI_DECK_PREFIX>::review::{id}
|
review_date |
Date | yes | Target review date |
card_ids |
ARRAY(BigInteger) | yes | Target card set |
note_ids |
ARRAY(BigInteger) | yes | §V75 notes (addTags target); server_default '{}'
|
deck_name |
Text | yes | Filtered deck name |
status |
Text | yes |
server_default 'pending'; pending|pushed|failed (CHECK) |
error_text |
Text | no | Failure detail |
failure_count |
Integer | yes | server_default 0 |
created_at |
DateTime(tz) | yes | server_default now() |
pushed_at |
DateTime(tz) | no | When pushed |
-
Validators (CHECK):
ck_anki_reviews_status: IN ('pending','pushed','failed'). -
Indexes:
ix_anki_reviews_status_date(status, review_date). -
Notes: no UNIQUE on
(review_date, *)per the V53 amendment — tags-as-log accepts duplicate reviews/day (UX debounce protects). The tag chain writescoach::review:{id}per V50.
app/models/anki.py:331 · table anki_writes · feature: anki
Append-only AnkiConnect write audit (V50, V55). Drives the /admin allowlist
verification and V55 retry-with-cap.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
action |
Text | yes | AnkiConnect action name |
payload_hash |
Text | yes | Hash of the write payload |
response_json |
JSONB | no | AnkiConnect response |
status |
Text | yes |
succeeded|failed (CHECK) |
error_text |
Text | no | Failure detail |
source |
Text | yes |
mcp|scheduler|manual|test (CHECK) |
assignment_id |
Integer | no | FK anki_assignments.id ON DELETE SET NULL |
review_id |
Integer | no | FK anki_reviews.id ON DELETE SET NULL |
occurred_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
assignment_id,review_idFK only — norelationship(). -
Validators (CHECK):
..._status: IN ('succeeded','failed');..._source: IN ('mcp','scheduler','manual','test'). -
Indexes:
ix_anki_writes_occurred_at(migration uses DESC; ORM omits direction forcreate_all).
app/models/anki.py:379 · table anki_load_config · feature: anki
Singleton config for the Anki load-realism evaluator (V59). Single row enforced by
id = 1 CHECK.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK, autoincrement=False; CHECK id = 1 (singleton) |
daily_card_review_budget |
Integer | yes | CHECK > 0; T61 seeds 200 |
daily_minutes_budget |
Numeric (Decimal) | yes | CHECK > 0; T61 seeds 60 |
updated_at |
DateTime(tz) | yes | server_default now() |
-
Validators (CHECK):
ck_anki_load_config_singleton: id = 1;..._budget_pos: daily_card_review_budget > 0;..._minutes_pos: daily_minutes_budget > 0. -
Notes: the service-layer
set_anki_load_configupdates in place.
The grounded knowledge base: PDF sources, atomic facts and their tags, the polymorphic embedding store, concept edges, the Notion mirror, and LLM batch bookkeeping. Backs the Knowledge base (PKM core); the categorization chain is in Intelligence Layer.
app/models/pdf_source.py:21 · table pdf_sources · feature: kb
An ingested classroom PDF keyed by SHA-256 (V-KB1, §I.schema). One row per PDF per
course; sha256 dedupes re-uploads.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
course_id |
Integer | yes | FK courses.id ON DELETE CASCADE |
filename |
Text | yes | Uploaded filename |
sha256 |
Text | yes | SHA-256 dedup key; UNIQUE |
status |
Text | yes |
server_default 'pending'; pending|parsing|ingested|failed (CHECK) |
ingested_at |
DateTime(tz) | no | When ingest completed |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
course_idFK only;atomic_facts -> list[AtomicFact](back_populates="pdf_source", cascade all, delete-orphan). -
Validators (CHECK):
ck_pdf_sources_status: IN ('pending','parsing','ingested','failed'). -
Constraints / indexes: UQ
uq_pdf_sources_sha256; indexesix_pdf_sources_course_id,ix_pdf_sources_status.
app/models/atomic_fact.py:20 · table atomic_facts · feature: kb
A grounded fact extracted from a PDF (V-KB1; V-L3 ground truth). node_id is a
denormalized primary node (the highest-confidence non-review pick); the full
per-node provenance lives in AtomicFactTag.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
course_id |
Integer | yes | FK courses.id ON DELETE CASCADE |
pdf_source_id |
Integer | yes | FK pdf_sources.id ON DELETE CASCADE |
page |
Integer | no | Source page number |
text |
Text | yes | Atomic claim text |
node_id |
Integer | no | FK outline_nodes.id ON DELETE SET NULL — denormalized primary node; nullable until T29/T30 grounded tagging |
content_hash |
Text | yes | Dedup within course |
extractor_version |
Text | no | V-KB3 stamp; nullable for pre-T54 rows |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
course_id,node_idFK only;pdf_source(back_populates="atomic_facts");tags -> list[AtomicFactTag](back_populates="atomic_fact", cascade all, delete-orphan). -
Constraints / indexes: UQ
uq_atomic_facts_course_hash(course_id, content_hash); indexesix_atomic_facts_course_id,ix_atomic_facts_pdf_source_id,ix_atomic_facts_node_id.
app/models/atomic_fact_tag.py:23 · table atomic_fact_tags · feature: categorization
The atomic-fact analogue of QuestionTag / AnkiNoteTag — identical shape, target
atomic_fact_id (V-T1). The grounded LLM4Tag chain (T29/T30) writes source='llm'
rows; an LLM re-run replaces only its own llm rows while manual + schema_map
survive (V-T2). Tag invariants V-T1..V-T3 apply.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
atomic_fact_id |
Integer | yes | FK atomic_facts.id ON DELETE CASCADE |
node_id |
Integer | yes | FK outline_nodes.id ON DELETE CASCADE — sole canonical target (V-T1) |
source |
Text | yes |
schema_map|llm|manual (V-T2, CHECK) |
confidence |
Numeric(3,2) | no | V69-calibrated logprob grade; required iff source='llm' (V-T3) |
rationale |
Text | no | LLM rationale |
extractor_version |
Text | no | Extractor stamp (V-T2) |
manual_review |
Boolean | yes |
server_default false; < 0.5 surfaced for review (V-T3) |
is_overridden |
Boolean | yes | server_default false |
overridden_at |
DateTime(tz) | no | When overridden |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
atomic_fact(back_populates="tags");node_idFK only. -
Validators (CHECK):
ck_atomic_fact_tags_confidence_when_llm(V-T3),..._confidence_range,..._low_conf_flagged(V-T3),..._source. -
Constraints / indexes: UQ
uq_atomic_fact_tags_node_source(atomic_fact_id, node_id, source); indexesix_atomic_fact_tags_atomic_fact_id,ix_atomic_fact_tags_node_id. (V-N2 one-page-per-node:AtomicFact.node_idis the denormalized primary; these rows carry the full per-node provenance.)
app/models/content_embedding.py:21 · table content_embeddings · feature: kb
A polymorphic vector store keyed by (entity_kind, entity_id) (V-KB1, V-E1).
embedding_version stamps every row; a provider/dim change ⇒ bump + full re-embed.
Mixed-dimension vectors in one column are forbidden (V-E1).
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
entity_kind |
Text | yes |
question|atomic_fact|outline_node (CHECK) — polymorphic key |
entity_id |
Integer | no/poly | Id of the embedded entity (no FK — polymorphic) |
embedding |
JSONB (list[float] | None) |
no | pgvector placeholder pending T25 |
embedding_version |
Text | yes | V-E1 stamp on every row |
created_at |
DateTime(tz) | yes | server_default now() |
-
Validators (CHECK):
ck_content_embeddings_entity_kind: entity_kind IN ('question','atomic_fact','outline_node'). -
Constraints / indexes: UQ
uq_content_embeddings_entity_version(entity_kind, entity_id, embedding_version)— versions coexist during a migration sweep; indexesix_content_embeddings_entity(entity_kind, entity_id),ix_content_embeddings_version.__mapper_args__ = {}(empty).
Note: the
embeddingcolumn is a JSONB placeholder (JSONB().with_variant(JSONB, "postgresql")) pending T25, which ALTERs it tovector(N)(N fromEMBEDDING_MODEL, default 1536 fortext-embedding-3-small) after adding thepgvectordep + Postgresvectorextension.
app/models/concept_edge.py:21 · table concept_edges · feature: kb
A cross-domain node ↔ node edge (V-KB1, V-E2, §A). similarity = a derived cosine edge;
manual = a human-verified edge.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
src_node_id |
Integer | yes | FK outline_nodes.id ON DELETE CASCADE |
dst_node_id |
Integer | yes | FK outline_nodes.id ON DELETE CASCADE |
kind |
String | yes |
similarity|manual (CHECK) |
score |
Numeric(6,5) | no | Cosine score; CHECK NULL OR -1.0..1.0
|
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
src_node_id,dst_node_idFK only — norelationship(). -
Validators (CHECK):
ck_concept_edges_kind: IN ('similarity','manual');..._no_self_edge: src_node_id <> dst_node_id;..._score_range: NULL OR -1.0..1.0. -
Constraints / indexes: UQ
uq_concept_edges_src_dst_kind(src_node_id, dst_node_id, kind)— the same pair across both kinds is allowed, but each kind dedupes; indexesix_concept_edges_src,ix_concept_edges_dst,ix_concept_edges_kind.
app/models/notion_page.py:21 · table notion_pages · feature: notion
A pointer to a Notion page mirroring one outline node (V-N1, V-N2). Write-out only: Postgres → Notion, never read back.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
node_id |
Integer | yes | FK outline_nodes.id ON DELETE CASCADE; UNIQUE (one page per node, V-N2) |
notion_page_id |
Text | yes | Notion page id; UNIQUE |
url |
Text | yes | Notion page URL |
tags |
JSONB (list) | no | Last-synced tag snapshot for backlink rendering — not a source of truth |
last_synced_at |
DateTime(tz) | no | Last sync time |
created_at |
DateTime(tz) | yes | server_default now() |
-
Relationships:
node_idFK only — norelationship(). -
Constraints / indexes: two UQs —
uq_notion_pages_node_id(node_id),uq_notion_pages_notion_page_id(notion_page_id); re-sync upserts onnode_id; indexix_notion_pages_node_id.
app/models/llm_batch.py:21 · table llm_batch_runs · feature: llm
Tracks LLM Message Batches submissions (§T51). Counts are mirrored locally so /admin
avoids round-trips.
| Name | Type | Required | Description |
|---|---|---|---|
id |
Integer | yes | PK |
anthropic_batch_id |
Text | yes | Handle to retrieve status/results; UNIQUE |
extractor |
Text | yes | Which extractor (anki topic resolver / categorizer / feature extractor) |
extractor_version |
Text | yes | Extractor version |
model |
Text | yes | Model name |
submitted_at |
DateTime(tz) | yes | Submission time |
processing_status |
Text | yes | Batch status (free text, no CHECK) |
total_requests |
Integer | yes | default 0 |
succeeded_count |
Integer | yes | default 0 |
errored_count |
Integer | yes | default 0 |
canceled_count |
Integer | yes | default 0 |
expired_count |
Integer | yes | default 0 |
processing_count |
Integer | yes | default 0 |
ended_at |
DateTime(tz) | no | When the batch ended |
total_cost_usd |
NUMERIC(10,4) | no | Total cost |
result_persisted_at |
DateTime(tz) | no | When results were persisted locally |
notes |
Text | no | Free-text notes |
-
Constraints / indexes: UQ
uq_llm_batch_runs_anthropic_id(anthropic_batch_id); indexix_llm_batch_runs_extractor_submitted(extractor, submitted_at). Python-sidedefault=0on the counts (notserver_default).
Note: the column name
anthropic_batch_idand the module docstring reference Anthropic's Message Batches API, but the project pivoted to OpenAI — treat the Anthropic naming as possible staleness in the model docstring/column name.
These are wire shapes only — not persisted. They are the bodies the API Reference documents. Conventions:
-
from_attributes=True(aliased_FROM_ATTRSin some modules) lets a response model be built directly from an ORM row. - Capture inbound models use
extra="forbid"(aliased_STRICT) → unknown keys 422. - "Optional, no default" on a response field means the field must be present but may
be
null.
The Chrome-extension wire shape for POST /captures and its response. The
RequestValidationError handler in app/main.py logs capture-path 422s.
| Schema | Kind | Purpose |
|---|---|---|
CapturePayload (:71) |
request | Top-level POST /captures body; composes ParsedCapture + MediaCapture + ParseWarning; extra="forbid"
|
ParsedCapture (:54) |
request | Parsed body; composes PassageCapture + ChoiceItem
|
PassageCapture (:46) |
request | Optional passage block |
ChoiceItem (:37) |
request | One answer choice |
MediaCapture (:26) |
request | Inbound media item (base64 bytes) |
ParseWarning (:18) |
request | Non-fatal parser warning |
IngestResponse (:92) |
response |
response_model of POST /captures
|
CapturePayload key fields:
| Name | Type | Required | Description |
|---|---|---|---|
source |
str | no | Default "uworld"; source discriminator (§A) |
course_slug |
Optional[str] | no | Default None; resolved to course_id vs courses.slug at ingest (V-CAP2) — unknown slug → 422; NULL → single-course fallback |
qid |
str | yes | Vendor question id |
uworld_test_id |
Optional[str] | no | Default None |
captured_at |
datetime | yes | Client capture timestamp |
html |
str | yes | Raw captured HTML |
parsed |
ParsedCapture | yes | Structured parse |
media |
list[MediaCapture] | no | default_factory=list |
parse_warnings |
list[ParseWarning] | no | default_factory=list |
extension_version |
str | yes | Capturing extension version |
IngestResponse: capture_id, question_id, attempt_id (all int, required),
passage_id (Optional[int], default None), media_ids (list[int],
default_factory=list). extra="forbid".
Note:
ParsedCapture.choicesandcorrect_choiceare required, but the ORMQuestion.correct_choiceis nullable (V-CAP1, deferred answer). The strictPOST /captureswire shape does not itself exercise the deferred-answer path — that is a property of source adapters that write the ORM directly.
| Schema | Kind | Purpose |
|---|---|---|
PdfIngestResponse (:8) |
response |
response_model of POST /api/v1/pdf/ingest (T51); mirrors kb.pdf_ingest.IngestReport
|
Fields: pdf_source_id, pages, new_facts, dup_facts (int); reused_pdf (bool);
extractor_version (str); input_tokens, output_tokens, cached_tokens (int). All
required. extra="forbid". The route takes an uploaded file, not a JSON body — no
request schema lives here.
Read shapes for the Anki read routes (review queue, card-by-qid). Data-only per §V37 — no verdicts/heuristics; the MCP host interprets.
| Schema | Kind | Purpose |
|---|---|---|
AnkiCardOut (:20) |
response | Base card read shape (§T42); composes AnkiCardTagOut
|
AnkiCardTagOut (:11) |
response | Per-tag projection on a card (§T5) |
AnkiReviewQueueCardOut (:38) |
response | Extends AnkiCardOut with per-card retention + retrievability (T43) |
AnkiStateOut (:50) |
response | Raw state buckets for one scope (§T39/§V28/§V37) |
AnkiRetentionWindowOut (:64) |
response | Pass/fail counts for one window (§V27) |
AnkiRetentionOut (:74) |
response | Retention container across windows; composes AnkiRetentionWindowOut
|
AnkiPerformanceOut (:79) |
response |
{state, retention} for one CC/topic (§T39/§V37); composes AnkiStateOut + AnkiRetentionOut
|
-
AnkiCardOut,AnkiCardTagOut,AnkiReviewQueueCardOutcarryfrom_attributes=True.AnkiReviewQueueCardOut.retention/retrievabilityareOptional[float](None when no qualifying reviews / no scheduled interval). -
AnkiStateOut,AnkiRetention*Out,AnkiPerformanceOutare plainBaseModelwith nomodel_config.
Note:
AnkiCardOut.tagsdefaults to a mutable list literal[]rather thanField(default_factory=list).
| Schema | Kind | Purpose |
|---|---|---|
AnkiAssignmentCreateIn (:14) |
request | V52 body for POST /anki/assignments
|
AnkiAssignmentPatchIn (:25) |
request | V51 body for PATCH /anki/assignments/{id}
|
AnkiAssignmentOut (:33) |
response | Shape for POST/GET/PATCH /anki/assignments
|
-
AnkiAssignmentCreateIn:scope_kind(Literal["cc","topic"]),scope_value(str,min_length=1/max_length=64),scheduled_unlock_at(datetime),max_cards(Optional[int],gt=0),priority(_PriorityKindLiteral =most_specific_first|random|mature_first|young_first, defaultmost_specific_first). -
AnkiAssignmentPatchIn:statusisLiteral["skipped","completed"]— only the two human-driven transitions are allowed here; unlock + auto-complete live on the scheduler jobs (T63/T64). -
AnkiAssignmentOut(from_attributes=True): mirrors the ORM row;scope_kind/statusreturned as plain str. Optional fields (actual_unlock_at,max_cards,priority,error_text) have no default (present, may be null).
| Schema | Kind | Purpose |
|---|---|---|
AnkiLoadConfigIn (:12) |
request | V59 singleton-upsert body for POST /anki/load-config
|
AnkiLoadConfigOut (:21) |
response |
GET/POST /anki/load-config
|
AnkiLoadAdherenceOut (:38) |
response | V54 adherence shape for GET /anki/load-adherence; composes ReviewedDayOut
|
ReviewedDayOut (:29) |
response | One point of the T43 reviewed-count series |
-
AnkiLoadConfigIn:daily_card_review_budget(int,gt=0),daily_minutes_budget(Decimal,gt=Decimal("0")) — mirrors the DB CHECKs at the API layer. -
AnkiLoadAdherenceOut:window_days,projected_daily_load(int);projected_daily_minutes,daily_minutes_budget,headroom_*_pct(float);daily_card_review_budget(int);status_label(str);reviewed_series(list[ReviewedDayOut]).
Note: per V60 there is intentionally no
recommended_changesfield onAnkiLoadAdherenceOut— advisory lives in the MCP host chat, not this data shape.daily_minutes_budgetisfloathere butDecimalon the config models.
| Schema | Kind | Purpose |
|---|---|---|
AnkiReviewCreateIn (:11) |
request | V53 (amended) body for POST /anki/reviews
|
AnkiReviewOut (:21) |
response |
POST/GET /anki/reviews (T67/T76/T77) |
-
AnkiReviewCreateIn:card_ids(list[int],min_length=1; AnKing-native BIGINT ids passed verbatim to the AnkiConnectcid:<csv>query),review_date(date). -
AnkiReviewOut(from_attributes=True):id,review_date,card_ids,deck_name,status,failure_count,created_at;error_text/pushed_atOptional, no default. Deck name derives from the row PK; reviews are standalone (no FK to assignments).
| Schema | Kind | Purpose |
|---|---|---|
NoteOut (:8) |
response |
GET/POST /attempts/{attempt_id}/notes
|
Fields: id, attempt_id (int), note_text (str), flag_for_review (bool),
source (str), created_at (datetime). All required. from_attributes=True.
Note: this file is response-only — the
POSTnote request body is defined in theattempts.pyrouter, not inapp/schemas/.
Persist-only write seam (V-M1) — data fields only, no verdict/grade/heuristic. The MCP host reasons; this seam persists. See Intelligence Layer.
| Schema | Kind | Purpose |
|---|---|---|
DiscriminatorIn (:9) |
request |
POST /pkm/discriminators body |
DiscriminatorOut (:21) |
response |
response_model of POST /pkm/discriminators
|
-
DiscriminatorIn:question_id(int),factor_text(StringConstraints(strip_whitespace=True, min_length=1)— whitespace-only rejected 422),node_id(int | None, default None). -
DiscriminatorOut(from_attributes=True):id,question_id,factor_text(required);node_id,notion_block_id(nullable, no default);created_at.
Note: this is a FENCED surface. The module docstring references Tickets 4.3/4.4/4.5, but no analyzer/recommendations router is mounted in
app/main.py— these schemas exist but are not currently exposed by a live route. The backingQuestionFeatureswriter is likewise fenced (V-RB1).
| Schema | Kind | Purpose |
|---|---|---|
InsightSynthesisResponse (:62) |
response | Top-level LLM-synthesis response; wraps InsightReportOut + token/cost telemetry |
InsightReportOut (:50) |
response | Deterministic insight report; composes the three sub-models below |
AnalysisFilterOut (:12) |
response | Echo of the filter applied (nested in InsightReportOut.filter_applied) |
FeatureFindingOut (:24) |
response | One feature-correlation finding row |
CoverageStatsOut (:42) |
response | Feature-coverage block |
All carry from_attributes=True (_FROM_ATTRS). InsightSynthesisResponse adds
markdown, cache_hit, input_tokens/output_tokens, estimated_cost_usd/
cost_saved_usd, extractor_version, model. FeatureFindingOut carries Wilson
lower-bound and confident_delta fields plus representative_missed_qids
(list[str], required, no default).
- Home · Getting Started · Architecture
- API Reference — the routes that accept/return the §4 schemas
- Function Index · Glossary
- Features — what each model powers, by feature area
-
Intelligence Layer — the tagging chain that writes
*_tags; the tutor/MCP persist seam -
Core Library — the
gradient-coreboundary - Feature pages: Feature-Capture · Feature-Outline · Feature-Knowledge-Base · Feature-Anki · Feature-Tutor · Feature-Platform
Reference
Features
Design Notes