pdf-quiz-generator/backend/app/routers/drafts.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

257 lines
9.2 KiB
Python

"""Extracted questions, before they are questions.
A batch is one run of extraction. It is read, edited and decided here, and
nothing reaches the question bank — or takes a question id — until somebody
accepts it.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.draft_question import DraftBatch, DraftQuestion
from app.models.question_category import QuestionCategory
from app.models.user import User
from app.services import draft_questions as drafts
from app.utils.auth import get_current_user, require_moderator
from app.utils.quiz_questions import validate_key_points, validate_option_explanations
router = APIRouter()
def _counts(db: Session, batch_ids: list[int]) -> dict[int, dict[str, int]]:
if not batch_ids:
return {}
rows = (db.query(DraftQuestion.batch_id, DraftQuestion.status, func.count(DraftQuestion.id))
.filter(DraftQuestion.batch_id.in_(batch_ids))
.group_by(DraftQuestion.batch_id, DraftQuestion.status).all())
out: dict[int, dict[str, int]] = {}
for batch_id, status, count in rows:
out.setdefault(batch_id, {})[status] = count
return out
def _get_batch(db: Session, batch_id: int) -> DraftBatch:
batch = db.get(DraftBatch, batch_id)
if not batch:
raise HTTPException(404, "Batch not found")
return batch
def _get_draft(db: Session, draft_id: int) -> tuple[DraftQuestion, DraftBatch]:
draft = db.get(DraftQuestion, draft_id)
if not draft:
raise HTTPException(404, "Draft not found")
return draft, _get_batch(db, draft.batch_id)
@router.get("/batches")
def list_batches(
status: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
query = db.query(DraftBatch)
if status:
query = query.filter(DraftBatch.status == status)
batches = query.order_by(DraftBatch.created_at.desc()).limit(200).all()
counts = _counts(db, [b.id for b in batches])
return [drafts.batch_json(db, b, counts.get(b.id, {})) for b in batches]
@router.get("/batches/{batch_id}")
def read_batch(
batch_id: int,
status: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
batch = _get_batch(db, batch_id)
query = db.query(DraftQuestion).filter(DraftQuestion.batch_id == batch_id)
if status:
query = query.filter(DraftQuestion.status == status)
rows = query.order_by(DraftQuestion.position, DraftQuestion.id).all()
body = drafts.batch_json(db, batch, _counts(db, [batch_id]).get(batch_id, {}))
body["drafts"] = [drafts.as_json(d) for d in rows]
return body
class BatchEdit(BaseModel):
title: str | None = None
category_id: int | None = None
status: str | None = None
@router.patch("/batches/{batch_id}")
def update_batch(
batch_id: int,
data: BatchEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
batch = _get_batch(db, batch_id)
fields = data.model_dump(exclude_unset=True)
if fields.get("status") and fields["status"] not in ("open", "closed"):
raise HTTPException(400, "A batch is open or closed")
if fields.get("category_id") is not None and not db.get(QuestionCategory, fields["category_id"]):
raise HTTPException(400, "Category not found")
for key, value in fields.items():
setattr(batch, key, value)
db.commit()
return drafts.batch_json(db, batch, _counts(db, [batch_id]).get(batch_id, {}))
@router.delete("/batches/{batch_id}", status_code=204)
def delete_batch(
batch_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Throw a whole run away. Questions already accepted from it stay — they
are in the bank now and are nothing to do with the batch any more."""
db.delete(_get_batch(db, batch_id))
db.commit()
class DraftEdit(BaseModel):
question_text: str | None = None
question_type: str | None = None
options: list[str] | None = None
correct_answer: str | None = None
explanation: str | None = None
option_explanations: dict | None = None
key_points: list[dict] | None = None
attending_tip: str | None = None
difficulty: str | None = None
category_id: int | None = None
@router.patch("/{draft_id}")
def update_draft(
draft_id: int,
data: DraftEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Fix a draft in place. This is the whole point of the staging table: it
can be wrong, and be corrected, without anything being in the bank."""
draft, _ = _get_draft(db, draft_id)
if draft.status == "accepted":
raise HTTPException(409, "That draft is in the bank; edit the question instead")
fields = data.model_dump(exclude_unset=True)
if "difficulty" in fields and fields["difficulty"] not in (None, "easy", "medium", "hard"):
raise HTTPException(422, "Difficulty is easy, medium or hard")
options = fields.get("options", draft.options)
if "option_explanations" in fields:
fields["option_explanations"] = validate_option_explanations(options, fields["option_explanations"])
if "key_points" in fields:
fields["key_points"] = validate_key_points(fields["key_points"], db)
if fields.get("category_id") is not None and not db.get(QuestionCategory, fields["category_id"]):
raise HTTPException(400, "Category not found")
for key, value in fields.items():
setattr(draft, key, value)
# Worth knowing about a model's output: how much of it a human had to change.
draft.edited = 1
db.commit()
return drafts.as_json(draft)
class Decision(BaseModel):
ids: list[int] = Field(min_length=1, max_length=200)
note: str | None = None
@router.post("/accept")
def accept_drafts(
data: Decision,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Put drafts into the bank. Each one becomes a question here and nowhere
else, which is the only moment a question id is taken.
All or nothing: a batch half-accepted because the twentieth draft was
missing an answer is worse than a refusal naming it.
"""
rows = db.query(DraftQuestion).filter(DraftQuestion.id.in_(data.ids)).all()
missing = set(data.ids) - {row.id for row in rows}
if missing:
raise HTTPException(404, f"No such draft: {', '.join(str(m) for m in sorted(missing))}")
batches = {b.id: b for b in db.query(DraftBatch).filter(
DraftBatch.id.in_({row.batch_id for row in rows})).all()}
# Everything is checked before anything is created. Creating as we go and
# refusing part-way leaves questions in the bank from a call that reported
# failure — and the caller has no way to know which.
# Already in the bank is a different answer from not ready: one is a
# conflict with what has happened, the other is work still to do.
done = [row.id for row in rows if row.status == "accepted" and row.question_id]
if done:
raise HTTPException(409, "Already in the bank: "
f"{', '.join(f'draft {i}' for i in sorted(done))}")
faults = []
for row in rows:
found = drafts.problems(row)
if found:
faults.append(f"draft {row.id}: {', '.join(found)}")
if faults:
raise HTTPException(400, "; ".join(faults[:10]))
made = []
try:
for row in sorted(rows, key=lambda r: (r.batch_id, r.position, r.id)):
question = drafts.accept(db, row, batches[row.batch_id], current_user)
made.append({"draft_id": row.id, "question_id": question.id})
db.commit()
except Exception:
db.rollback()
raise
# A batch with nothing left to decide closes itself.
for batch_id in {row.batch_id for row in rows}:
left = db.query(DraftQuestion.id).filter(
DraftQuestion.batch_id == batch_id, DraftQuestion.status == "pending").first()
if not left:
db.get(DraftBatch, batch_id).status = "closed"
db.commit()
return {"accepted": len(made), "questions": made}
@router.post("/reject")
def reject_drafts(
data: Decision,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
rows = db.query(DraftQuestion).filter(DraftQuestion.id.in_(data.ids)).all()
for row in rows:
drafts.reject(db, row, current_user, data.note)
db.commit()
return {"rejected": len(rows)}
@router.post("/{draft_id}/reopen")
def reopen_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
draft, _ = _get_draft(db, draft_id)
drafts.reopen(db, draft)
db.commit()
return drafts.as_json(draft)
@router.delete("/{draft_id}", status_code=204)
def delete_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Remove a draft outright. Nothing in the bank is touched: a draft that
was accepted has already become a question, which is its own row."""
draft, _ = _get_draft(db, draft_id)
db.delete(draft)
db.commit()