Skip to content

Data Models

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

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:

  • source is an open discriminator (§A) on captures — there is no closed enum. Status/source/kind fields elsewhere are CHECK-constrained Text, not Python enums. The sole real Python/DB enum is TaskRunStatus.
  • Tag invariants (V-T1..V-T3) govern every *_tags table identically:
    • V-T1 — the canonical tag target is a single node_id (the PoC 3-target shape is retired).
    • V-T2source IN ('schema_map','llm','manual'); extractor_version stamps the prompt/schema that produced LLM rows; an LLM re-run replaces only its own source='llm' rows.
    • V-T3confidence is required iff source='llm' (NULL otherwise), lives in [0.0, 1.0], and a value < 0.5 must set manual_review (CHECK enforced).
  • Versioning stampsembedding_version (V-E1) and extractor_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 .py file wins. Field-level uncertainties were verified against the source; remaining caveats are flagged inline with > Note: callouts.


1. Core domain ORM

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.

Course

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: slug unique at column level.

OutlineNode

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); indexes ix_outline_nodes_course_id, ix_outline_nodes_parent_id.
  • Notes: module-level constant OUTLINE_PATH_DELIMITER = ' >> ' (V-O4) is the reserved node-path delimiter.

RawCapture

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.id FK only — no relationship() declared.
  • Indexes: ix_raw_captures_qid, ix_raw_captures_captured_at, ix_raw_captures_course_id, partial ix_raw_captures_qid_with_warnings (WHERE parse_warnings IS NOT NULL). No UniqueConstraint.

Passage

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 unique ix_passages_uworld_id on uworld_passage_id WHERE NOT NULL.

Question

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] and tags -> list[QuestionTag] (both back_populates, cascade all, delete-orphan); course_id FK only (no relationship()).
  • Indexes: ix_questions_passage_id, ix_questions_source, ix_questions_course_id, partial ix_questions_needs_categorization (WHERE needs_categorization = true). qid unique at column level.

Note: §I plans to rename qid -> external_id with UQ (source, external_id); not yet applied in live code.

Attempt

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, composite ix_attempts_question_attempted(question_id, attempted_at), ix_attempts_uworld_test_id, ix_attempts_source. No UniqueConstraint.

Note: §I plans to rename uworld_test_id -> session_ref.

QuestionTag

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_id FK 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.5 OR manual_review, V-T3), ..._source.
  • Constraints / indexes: UQ uq_question_tags_node_source(question_id, node_id, source); indexes ix_question_tags_question_id, ix_question_tags_node_id.

AttemptNote

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_id FK only — no relationship().
  • Validators (CHECK): ck_attempt_notes_source: source IN ('user','mcp').
  • Indexes: ix_attempt_notes_attempt_id; partial ix_attempt_notes_flagged (WHERE flag_for_review = true).

QuestionFeatures

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_id FK only (column unique=True); no relationship().
  • 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_id unique; no explicit Index() declared.

DiscriminatorFactor

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_id FK only — no relationship().
  • Constraints / indexes: UQ uq_discriminator_factors_question_text(question_id, factor_text); indexes ix_discriminator_factors_question_id, ix_discriminator_factors_node_id.

Media

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_undescribed on id WHERE description IS NULL (drives the un-described-media backfill).

TaskRunStatus (enum) + TaskRun

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_name plus composite ix_task_runs_job_started(job_name, started_at). Python-side defaults (not server_default) on status/items_processed.

2. Anki ORM

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 BigInteger and used directly with AnkiConnect — there is no local id bridge (§B11 lesson). note_id, anki_card_id, and review_id are all native Anki ids.

AnkiNote

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).

AnkiCard

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 via primaryjoin AnkiCard.note_id == foreign(AnkiNoteTag.note_id) (no back_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); index ix_anki_cards_due_date.

AnkiNoteTag

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_id FK 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) and uq_anki_note_tags_note_tag(note_id, tag_raw) (addTags sync idempotency); indexes ix_anki_note_tags_node_id, ix_anki_note_tags_question_qid, ix_anki_note_tags_source.

AnkiCardReview

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: AnkiCardReview is defined in anki.py only and is not re-exported from app/models/__init__.py.

AnkiAssignment

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 is pending → unlocked → (completed|skipped|failed).
  • Indexes: ix_anki_assignments_status_scheduled(status, scheduled_unlock_at), ix_anki_assignments_actual_unlock.

AnkiReview

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 writes coach::review:{id} per V50.

AnkiWrite

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_id FK only — no relationship().
  • Validators (CHECK): ..._status: IN ('succeeded','failed'); ..._source: IN ('mcp','scheduler','manual','test').
  • Indexes: ix_anki_writes_occurred_at (migration uses DESC; ORM omits direction for create_all).

AnkiLoadConfig

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_config updates in place.

3. KB substrate ORM

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.

PdfSource

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_id FK 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; indexes ix_pdf_sources_course_id, ix_pdf_sources_status.

AtomicFact

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_id FK 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); indexes ix_atomic_facts_course_id, ix_atomic_facts_pdf_source_id, ix_atomic_facts_node_id.

AtomicFactTag

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_id FK 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); indexes ix_atomic_fact_tags_atomic_fact_id, ix_atomic_fact_tags_node_id. (V-N2 one-page-per-node: AtomicFact.node_id is the denormalized primary; these rows carry the full per-node provenance.)

ContentEmbedding

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; indexes ix_content_embeddings_entity(entity_kind, entity_id), ix_content_embeddings_version. __mapper_args__ = {} (empty).

Note: the embedding column is a JSONB placeholder (JSONB().with_variant(JSONB, "postgresql")) pending T25, which ALTERs it to vector(N) (N from EMBEDDING_MODEL, default 1536 for text-embedding-3-small) after adding the pgvector dep + Postgres vector extension.

ConceptEdge

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_id FK only — no relationship().
  • 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; indexes ix_concept_edges_src, ix_concept_edges_dst, ix_concept_edges_kind.

NotionPage

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_id FK only — no relationship().
  • Constraints / indexes: two UQs — uq_notion_pages_node_id(node_id), uq_notion_pages_notion_page_id(notion_page_id); re-sync upserts on node_id; index ix_notion_pages_node_id.

LlmBatchRun

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); index ix_llm_batch_runs_extractor_submitted(extractor, submitted_at). Python-side default=0 on the counts (not server_default).

Note: the column name anthropic_batch_id and 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.


4. Request / Response schemas (Pydantic)

These are wire shapes only — not persisted. They are the bodies the API Reference documents. Conventions:

  • from_attributes=True (aliased _FROM_ATTRS in 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.

Capture (app/schemas/captures.py)

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.choices and correct_choice are required, but the ORM Question.correct_choice is nullable (V-CAP1, deferred answer). The strict POST /captures wire shape does not itself exercise the deferred-answer path — that is a property of source adapters that write the ORM directly.

KB ingest (app/schemas/pdf.py)

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.

Anki (app/schemas/anki.py)

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, AnkiReviewQueueCardOut carry from_attributes=True. AnkiReviewQueueCardOut.retention /retrievability are Optional[float] (None when no qualifying reviews / no scheduled interval).
  • AnkiStateOut, AnkiRetention*Out, AnkiPerformanceOut are plain BaseModel with no model_config.

Note: AnkiCardOut.tags defaults to a mutable list literal [] rather than Field(default_factory=list).

Anki assignments (app/schemas/anki_assign.py)

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 (_PriorityKind Literal = most_specific_first|random|mature_first|young_first, default most_specific_first).
  • AnkiAssignmentPatchIn: status is Literal["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/ status returned as plain str. Optional fields (actual_unlock_at, max_cards, priority, error_text) have no default (present, may be null).

Anki load (app/schemas/anki_load.py)

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_changes field on AnkiLoadAdherenceOut — advisory lives in the MCP host chat, not this data shape. daily_minutes_budget is float here but Decimal on the config models.

Anki reviews (app/schemas/anki_review.py)

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 AnkiConnect cid:<csv> query), review_date (date).
  • AnkiReviewOut (from_attributes=True): id, review_date, card_ids, deck_name, status, failure_count, created_at; error_text /pushed_at Optional, no default. Deck name derives from the row PK; reviews are standalone (no FK to assignments).

Attempt notes (app/schemas/attempt_notes.py)

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 POST note request body is defined in the attempts.py router, not in app/schemas/.

PKM / discriminators (app/schemas/pkm.py)

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.

Analyzer (app/schemas/analyzer.py) — FENCED {#analyzer}

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 backing QuestionFeatures writer 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).


See also

Clone this wiki locally