pdf-quiz-generator/backend/alembic/versions/c9d0e1f2a3b4_draft_questions.py
Daniel c9abc4f310 feat: extracted questions are staged, and only acceptance takes an id
Extraction wrote straight into `questions`, so a machine's first attempt
took a permanent id the moment it was produced. Ids come from a sequence
and are never reissued: every rejected draft burned one, and every draft
that needed fixing was sitting in the bank while it was being fixed.

A run now lands in a batch of drafts with their own table and their own
sequence. They are read, corrected and decided there, and `accept` is
the only place a Question is created — a copy rather than a translation,
because every field a draft holds is a field a question has, so nothing
is lost at the moment of acceptance.

Accepting is all or nothing, and everything is checked before anything
is created: a call that reports failure must not leave questions behind
from the drafts it got through first. My own test caught that — the
first question existed before the second draft was refused.

Readiness is reported for every draft rather than only on the attempt to
accept it, so a reviewer sees what needs work before opening anything.
A decided draft keeps its row and records what it became, so a batch
reads as a history of what was decided rather than emptying as it is
worked through. An acceptance cannot be undone from here: the question
exists, and deciding twice would make a second one.

No embeddings for drafts. A vector is for finding a question in the
bank, and a draft is not in the bank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 22:57:08 +02:00

73 lines
3.7 KiB
Python

"""A staging area for extracted questions.
Extraction wrote straight into `questions`, so a machine's first attempt took a
permanent id the moment it was produced — and ids come from a sequence that
never reissues one, so every rejected draft burned an id and every draft that
needed fixing was in the bank while it was being fixed.
Drafts have their own table and their own sequence now. Nothing reaches the
bank, or takes a question id, until somebody accepts it.
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
"""
import sqlalchemy as sa
from alembic import op
revision = "c9d0e1f2a3b4"
down_revision = "b8c9d0e1f2a3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"draft_batches",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("document_id", sa.Integer(), sa.ForeignKey("pdf_documents.id", ondelete="SET NULL"), nullable=True),
sa.Column("section_id", sa.Integer(), sa.ForeignKey("sections.id", ondelete="SET NULL"), nullable=True),
sa.Column("job_id", sa.String(64), nullable=True),
sa.Column("model_id", sa.String(160), nullable=True),
sa.Column("extraction_mode", sa.String(40), nullable=True),
sa.Column("category_id", sa.Integer(), sa.ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("status", sa.String(20), server_default="open"),
)
op.create_index("ix_draft_batches_job_id", "draft_batches", ["job_id"])
op.create_index("ix_draft_batches_status", "draft_batches", ["status"])
op.create_table(
"draft_questions",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("batch_id", sa.Integer(), sa.ForeignKey("draft_batches.id", ondelete="CASCADE"), nullable=False),
sa.Column("position", sa.Integer(), server_default="0"),
sa.Column("question_text", sa.Text(), nullable=False),
sa.Column("question_type", sa.String(), nullable=False, server_default="mcq"),
sa.Column("options", sa.JSON(), nullable=True),
sa.Column("correct_answer", sa.String(), nullable=True),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("option_explanations", sa.JSON(), nullable=True),
sa.Column("key_points", sa.JSON(), nullable=True),
sa.Column("attending_tip", sa.Text(), nullable=True),
sa.Column("difficulty", sa.String(10), nullable=True),
sa.Column("page_reference", sa.Integer(), nullable=True),
sa.Column("image_path", sa.String(), nullable=True),
sa.Column("explanation_image_path", sa.String(), nullable=True),
sa.Column("category_id", sa.Integer(), sa.ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True),
sa.Column("status", sa.String(20), server_default="pending"),
sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id", ondelete="SET NULL"), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("decided_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("decided_at", sa.DateTime(), nullable=True),
sa.Column("edited", sa.Integer(), server_default="0"),
sa.Column("created_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_draft_questions_batch_id", "draft_questions", ["batch_id"])
op.create_index("ix_draft_questions_status", "draft_questions", ["status"])
def downgrade() -> None:
op.drop_table("draft_questions")
op.drop_table("draft_batches")