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
257 lines
9.2 KiB
Python
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()
|