**The API.** Every route now lives under `/api/v1`, with `/api/...` rewritten
onto it — one route, two spellings, so they cannot drift and the OpenAPI
document describes each endpoint once. Errors carry an `error` object with a
stable code, one human sentence and, for a validation failure, the fields that
were wrong; `detail` is untouched so nothing that reads it breaks. The whole
surface — 320 routes, their parameters and their status codes — is checked in
as `backend/tests/api-contract.json`, and a test fails on any difference,
naming the routes that moved. `docs/api.md` is the contract in prose.
**Refresh tokens**, so an app can stay signed in without keeping a password.
Rows rather than signatures: listable, withdrawable, stored as hashes, rotated
on every use. A spent token coming back ends the whole session, because a theft
and a replay look identical from the server and the safe reading is the unsafe
one. A browser is not given one — it has nowhere to put it and a person to ask.
**An end-to-end stack**: `docker-compose.test.yml` with its own Postgres and
Redis, `e2e/seed.py` for the smallest world the tests name, and Playwright with
five projects — desktop, iPhone, Pixel, iPad and a browserless API project.
Devices because every bug reported this week was a phone bug found by a person
looking at a screenshot; a desktop-only suite would have passed through all of
them. Forty tests, five clean runs.
It found four things in its first hour:
- **A fresh deploy could not start.** `create_all()` ran before
`CREATE EXTENSION vector`, so any database that had never had pgvector
installed died on the first table with a vector column. Invisible here
because this one has had the extension for a year.
- **A figure in a published article was a 404 for everyone but an admin.**
Media in the library is nobody's to read by default, and nothing made an
exception for a drawing an article actually shows — so every illustration
added this week was an empty box for every real user.
- **Every rate limit was one bucket for the whole site.** The backend saw
nginx's address for every request, so ten bad passwords from anybody locked
out everybody, and no log line could say who. nginx now takes the real
address from the proxy and overwrites the header on the way in; uvicorn runs
with --proxy-headers.
- **The reading page's breakpoints disagreed** — 1150px in the component,
820px in the stylesheet. Between them the menu button claimed the contents
drawer and then toggled a class on a rail that was still in the layout: the
contents did not open and the site menu did not either. The button was dead
on every tablet.
And two smaller ones: the login limiter counted successful sign-ins, so eleven
people behind one hospital NAT locked each other out — it is cleared by a
correct password now; and `/uploads/{path}` served GET and HEAD from one route
with one operation id, which makes every OpenAPI client generator refuse the
document.
The first admin's password is generated and printed once at first start when
`DEFAULT_ADMIN_PASSWORD` is blank, rather than the account not existing:
`docker compose logs backend | grep -A3 "FIRST ADMIN"`.
CI (`.forgejo/workflows/tests.yml`) runs the backend suite, the contract, the
frontend suite and the build on every push to dev, main or master, and the
end-to-end stack on those branches and on pull requests into them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
756 lines
34 KiB
Python
756 lines
34 KiB
Python
"""Educator-maintained lab references and authorized question response statistics."""
|
|
from collections import Counter, defaultdict
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
|
from sqlalchemy import case, func, inspect, or_
|
|
from sqlalchemy import text as sa_text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.exam import Exam
|
|
from app.services.knowledge_groups import Grouping
|
|
from app.services.quiz_builder import exam_scope_predicate
|
|
from app.database import get_db
|
|
from app.models.article import Article
|
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
|
from app.models.flashcard import Flashcard, FlashcardDeck
|
|
from app.models.lab_reference import LabReference, LabReferenceCardLink
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
|
from app.models.quiz import Quiz
|
|
from app.models.user import User
|
|
from app.utils.auth import get_current_user, require_moderator
|
|
from app.services.quiz_builder import shareable_question_predicate
|
|
from app.utils.quiz_access import general_quiz_visibility, require_quiz_access
|
|
from app.utils.quiz_questions import question_in_quiz
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class LabInput(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
group: str = Field(min_length=1, max_length=60)
|
|
reference_range: str = Field(min_length=1, max_length=250)
|
|
units: str = Field(min_length=1, max_length=80)
|
|
age_group: str = Field(min_length=1, max_length=120)
|
|
specimen: str = Field(min_length=1, max_length=120)
|
|
source: str = Field(min_length=1, max_length=500)
|
|
source_url: HttpUrl | None = None
|
|
article_id: int | None = None
|
|
article_section_id: str | None = Field(default=None, max_length=64)
|
|
is_published: bool = False
|
|
|
|
@field_validator("source_url")
|
|
@classmethod
|
|
def bounded_source_url(cls, value):
|
|
if value is not None and len(str(value)) > 2000:
|
|
raise ValueError("Source URL must be at most 2000 characters")
|
|
return value
|
|
|
|
@field_validator("name", "group", "reference_range", "units", "age_group", "specimen", "source")
|
|
@classmethod
|
|
def not_blank(cls, value):
|
|
if not value.strip():
|
|
raise ValueError("A value is required")
|
|
return value.strip()
|
|
|
|
|
|
@router.get("/lab-values")
|
|
def lab_values(include_drafts: bool = False, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
if include_drafts and not user.is_moderator:
|
|
raise HTTPException(403, "Educator access required")
|
|
query = db.query(LabReference)
|
|
if not include_drafts:
|
|
query = query.filter(LabReference.is_published.is_(True))
|
|
# ponytail: bounded personal-project table; add pagination before exceeding 500 entries.
|
|
# Insertion order keeps each test's age rows in their logical sequence.
|
|
entries = query.order_by(LabReference.group, LabReference.name, LabReference.id).limit(500).all()
|
|
card_rows = db.query(LabReferenceCardLink.lab_reference_id, Flashcard.id, Flashcard.front, Flashcard.deck_id,
|
|
FlashcardDeck.title).join(
|
|
Flashcard, Flashcard.id == LabReferenceCardLink.flashcard_id,
|
|
).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
|
|
LabReferenceCardLink.lab_reference_id.in_([entry.id for entry in entries]) if entries else False,
|
|
).all()
|
|
rows_by_lab: dict[int, list] = {}
|
|
for lab_id, cid, front, deck_id, deck_title in card_rows:
|
|
rows_by_lab.setdefault(lab_id, []).append((cid, front, deck_id, deck_title))
|
|
return [lab_json(db, entry, rows_by_lab.get(entry.id, [])) for entry in entries]
|
|
|
|
|
|
def lab_fields(data):
|
|
fields = data.model_dump()
|
|
fields["source_url"] = str(data.source_url) if data.source_url else None
|
|
fields["article_section_id"] = data.article_section_id or None
|
|
return fields
|
|
|
|
|
|
def validate_lab_target(db, article_id, article_section_id):
|
|
if article_id is None:
|
|
if article_section_id:
|
|
raise HTTPException(400, "article_section_id requires article_id")
|
|
return
|
|
article = db.get(Article, article_id)
|
|
if not article:
|
|
raise HTTPException(400, "Article not found")
|
|
if article_section_id and article_section_id not in {s["id"] for s in (article.sections or [])}:
|
|
raise HTTPException(400, "Section not found in this article")
|
|
|
|
|
|
def lab_json(db, entry, card_rows=None):
|
|
article = db.get(Article, entry.article_id) if entry.article_id else None
|
|
section_title = None
|
|
if article and entry.article_section_id:
|
|
section_title = next((s["title"] for s in (article.sections or []) if s["id"] == entry.article_section_id), None)
|
|
cards = []
|
|
if card_rows:
|
|
cards = [{"card_id": cid, "front": front, "deck_id": deck_id, "deck_title": deck_title}
|
|
for cid, front, deck_id, deck_title in card_rows]
|
|
return {
|
|
"id": entry.id, "name": entry.name, "group": entry.group,
|
|
"reference_range": entry.reference_range, "units": entry.units,
|
|
"age_group": entry.age_group, "specimen": entry.specimen,
|
|
"source": entry.source, "source_url": entry.source_url,
|
|
"article_id": entry.article_id, "article_section_id": entry.article_section_id,
|
|
"article_title": article.title if article else None,
|
|
"article_section_title": section_title,
|
|
"is_published": entry.is_published, "updated_at": entry.updated_at,
|
|
"cards": cards,
|
|
}
|
|
|
|
|
|
@router.post("/lab-values", status_code=201)
|
|
def create_lab_value(data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
|
validate_lab_target(db, data.article_id, data.article_section_id)
|
|
entry = LabReference(**lab_fields(data), updated_by=user.id)
|
|
db.add(entry)
|
|
db.commit()
|
|
db.refresh(entry)
|
|
return lab_json(db, entry)
|
|
|
|
|
|
@router.put("/lab-values/{entry_id}")
|
|
def update_lab_value(entry_id: int, data: LabInput, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
|
entry = db.get(LabReference, entry_id)
|
|
if not entry:
|
|
raise HTTPException(404, "Reference not found")
|
|
validate_lab_target(db, data.article_id, data.article_section_id)
|
|
for key, value in lab_fields(data).items():
|
|
setattr(entry, key, value)
|
|
entry.updated_by = user.id
|
|
entry.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(entry)
|
|
return lab_json(db, entry)
|
|
|
|
|
|
@router.put("/lab-values/{entry_id}/cards/{card_id}")
|
|
def link_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
|
entry = db.get(LabReference, entry_id)
|
|
if not entry:
|
|
raise HTTPException(404, "Reference not found")
|
|
if not db.get(Flashcard, card_id):
|
|
raise HTTPException(404, "Card not found")
|
|
if db.query(LabReferenceCardLink.id).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).first():
|
|
return {"linked": False}
|
|
db.add(LabReferenceCardLink(lab_reference_id=entry_id, flashcard_id=card_id))
|
|
db.commit()
|
|
return {"linked": True}
|
|
|
|
|
|
@router.delete("/lab-values/{entry_id}/cards/{card_id}", status_code=204)
|
|
def unlink_lab_card(entry_id: int, card_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
|
db.query(LabReferenceCardLink).filter_by(lab_reference_id=entry_id, flashcard_id=card_id).delete(synchronize_session=False)
|
|
db.commit()
|
|
|
|
|
|
@router.delete("/lab-values/{entry_id}", status_code=204)
|
|
def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User = Depends(require_moderator)):
|
|
entry = db.get(LabReference, entry_id)
|
|
if not entry:
|
|
raise HTTPException(404, "Reference not found")
|
|
db.delete(entry)
|
|
db.commit()
|
|
|
|
|
|
@router.get("/performance-by-category")
|
|
def performance_by_category(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""Accuracy per category from the user's completed, non-expired general-bank answers.
|
|
A question counts in its primary category and every additional category link."""
|
|
from collections import defaultdict
|
|
from app.models.question_category import QuestionCategoryLink
|
|
rows = db.query(AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id).join(
|
|
QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).join(
|
|
Question, Question.id == AttemptAnswer.question_id).filter(
|
|
QuizAttempt.user_id == user.id, QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
# A repetition is practice, not a new measurement: the answers have
|
|
# already been seen, so getting them right again says nothing about
|
|
# whether they were known. It has its own analysis; it is not in this.
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
# A skipped question is not a wrong answer. Counting it as one made a
|
|
# 360-question sitting that was never worked through read as 0% accuracy
|
|
# across every category it touched.
|
|
AttemptAnswer.user_answer.isnot(None), AttemptAnswer.user_answer != "",
|
|
).all()
|
|
extra: dict[int, set[int]] = defaultdict(set)
|
|
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
|
|
extra[qid].add(cid)
|
|
counts: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
|
for qid, is_correct, primary in rows:
|
|
categories = extra.get(qid, set()) | ({primary} if primary else set())
|
|
for cid in categories:
|
|
counts[cid][0] += 1
|
|
if is_correct:
|
|
counts[cid][1] += 1
|
|
names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()}
|
|
parents = {cat.id: cat.parent_id for cat in db.query(QuestionCategory).all()}
|
|
categories = [{
|
|
"category_id": cid,
|
|
"name": names.get(cid, "Uncategorized"),
|
|
"parent_id": parents.get(cid),
|
|
"answered": answered,
|
|
"correct": correct,
|
|
"accuracy": round(100 * correct / answered, 1) if answered else 0,
|
|
} for cid, (answered, correct) in counts.items()]
|
|
categories.sort(key=lambda row: (-row["answered"], row["name"]))
|
|
return {
|
|
"total_answered": sum(row["answered"] for row in categories),
|
|
"categories": categories,
|
|
"basis": "Questions you actually answered in completed sessions; skipped ones are left out, and a question counts in every category it belongs to.",
|
|
}
|
|
|
|
|
|
# Readiness needs enough answers before a per-category estimate means anything.
|
|
READINESS_UNLOCK_ANSWERS = 40
|
|
# Shrinkage weight: a category with this many answers sits halfway between its
|
|
# own accuracy and the learner's overall accuracy.
|
|
READINESS_PRIOR_ANSWERS = 8
|
|
|
|
|
|
@router.get("/completion")
|
|
def completion(
|
|
days: int | None = Query(None, ge=1, le=3650),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""How much of the bank has been worked through, and at what pace.
|
|
|
|
Over a window, because "how am I doing" and "how was I doing last month"
|
|
are different questions and one figure cannot answer both. No window means
|
|
everything.
|
|
|
|
Repetitions are excluded for the same reason they are excluded everywhere
|
|
else: sitting a question you have already seen the answer to is practice,
|
|
not a measurement.
|
|
"""
|
|
since = datetime.utcnow() - timedelta(days=days) if days else None
|
|
rows = db.query(
|
|
AttemptAnswer.is_correct, AttemptAnswer.seconds_spent, AttemptAnswer.user_answer,
|
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).filter(
|
|
QuizAttempt.user_id == user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
*([QuizAttempt.completed_at >= since] if since else []),
|
|
).all()
|
|
|
|
answered = [row for row in rows if row.user_answer]
|
|
correct = sum(1 for row in answered if row.is_correct)
|
|
timed = [row.seconds_spent for row in answered if row.seconds_spent]
|
|
bank = db.query(func.count(Question.id)).filter(
|
|
Question.deleted_at.is_(None)).scalar() or 0
|
|
|
|
return {
|
|
"days": days,
|
|
"answered": len(answered),
|
|
"bank_total": bank,
|
|
# Out of what was answered, not out of what was set — an unanswered
|
|
# question is not a wrong answer.
|
|
"percent_correct": round(100 * correct / len(answered), 1) if answered else None,
|
|
"seconds_per_question": round(sum(timed) / len(timed)) if timed else None,
|
|
"seconds_total": sum(timed) if timed else 0,
|
|
}
|
|
|
|
|
|
def _split(rows) -> dict:
|
|
"""Correct / correct with a tip / incorrect / unanswered.
|
|
|
|
A tip opened before answering is a nudge, not a mistake and not nothing.
|
|
It is counted as correct — because it was — and named separately, so a
|
|
learner can see how much of a score leaned on one.
|
|
"""
|
|
right = [row for row in rows if row.user_answer and row.is_correct]
|
|
hinted = sum(1 for row in right if getattr(row, "used_hint", False))
|
|
incorrect = sum(1 for row in rows if row.user_answer and not row.is_correct)
|
|
blank = sum(1 for row in rows if not row.user_answer)
|
|
answered = len(right) + incorrect
|
|
return {
|
|
"correct": len(right) - hinted,
|
|
"correct_with_hints": hinted,
|
|
"incorrect": incorrect,
|
|
"unanswered": blank,
|
|
"answered": answered,
|
|
"total": len(rows),
|
|
"percent_correct": round(100 * len(right) / answered, 1) if answered else None,
|
|
}
|
|
|
|
|
|
@router.get("/answer-split")
|
|
def answer_split(
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""The same answers counted two ways.
|
|
|
|
*All attempts* is every answer ever given: it says how much work has been
|
|
done. *Latest attempt* keeps only the most recent answer to each question:
|
|
it says what is known now. A learner who got a question wrong in March and
|
|
right in September is at 50% by the first measure and 100% by the second,
|
|
and both are true statements about different questions.
|
|
|
|
Repetitions and expired attempts are left out, as
|
|
everywhere else that measures rather than counts practice.
|
|
"""
|
|
rows = db.query(
|
|
AttemptAnswer.question_id, AttemptAnswer.is_correct, AttemptAnswer.user_answer,
|
|
AttemptAnswer.used_hint,
|
|
QuizAttempt.id.label("attempt_id"), QuizAttempt.completed_at,
|
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).filter(
|
|
QuizAttempt.user_id == user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
).order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc()).all()
|
|
|
|
# Ordered oldest first, so the last write per question is the latest one.
|
|
latest: dict[int, object] = {}
|
|
for row in rows:
|
|
latest[row.question_id] = row
|
|
|
|
return {
|
|
"all": _split(rows),
|
|
"latest": _split(list(latest.values())),
|
|
"attempts": len({row.attempt_id for row in rows}),
|
|
"unique_questions": len(latest),
|
|
}
|
|
|
|
|
|
#: Two points make a line but not a trend; the chart stays shut until there is
|
|
#: something to see in it.
|
|
TREND_MIN_SESSIONS = 3
|
|
|
|
|
|
@router.get("/performance-over-time")
|
|
def performance_over_time(
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""The headline score by date.
|
|
|
|
Two lines are meant here, and only one of them is the score. A session's
|
|
own percentage swings with whatever twelve questions it happened to hold;
|
|
the running figure — everything answered up to that day — is the one that
|
|
says whether the learner is getting better. The chart draws the running
|
|
line and marks the sessions along it.
|
|
|
|
It stays locked until there are enough answers to mean anything, for the
|
|
same reason readiness does: a line through two points is a decoration.
|
|
"""
|
|
rows = db.query(
|
|
QuizAttempt.id, QuizAttempt.completed_at, Quiz.title,
|
|
func.count(AttemptAnswer.id).label("answered"),
|
|
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)).label("correct"),
|
|
).join(AttemptAnswer, AttemptAnswer.attempt_id == QuizAttempt.id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).filter(
|
|
QuizAttempt.user_id == user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
AttemptAnswer.user_answer != "",
|
|
).group_by(QuizAttempt.id, QuizAttempt.completed_at, Quiz.title
|
|
).order_by(QuizAttempt.completed_at.asc()).all()
|
|
|
|
points = []
|
|
seen = correct_so_far = 0
|
|
for row in rows:
|
|
answered = int(row.answered or 0)
|
|
if not answered:
|
|
continue
|
|
correct = int(row.correct or 0)
|
|
seen += answered
|
|
correct_so_far += correct
|
|
points.append({
|
|
"attempt_id": row.id,
|
|
"date": row.completed_at.date().isoformat() if row.completed_at else None,
|
|
"title": row.title,
|
|
"answered": answered,
|
|
"percent": round(100 * correct / answered, 1),
|
|
# Everything answered up to and including this session.
|
|
"running": round(100 * correct_so_far / seen, 1),
|
|
})
|
|
|
|
total = seen
|
|
return {
|
|
"points": points,
|
|
"total_answered": total,
|
|
"unlocked": total >= READINESS_UNLOCK_ANSWERS and len(points) >= TREND_MIN_SESSIONS,
|
|
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total),
|
|
"sessions_needed": max(0, TREND_MIN_SESSIONS - len(points)),
|
|
}
|
|
|
|
|
|
#: Below this many other learners on the same questions there is no cohort to
|
|
#: compare against, only a couple of strangers.
|
|
PEER_MIN_LEARNERS = 3
|
|
#: And below this many questions held in common, the comparison is about which
|
|
#: questions each of you happened to sit.
|
|
PEER_MIN_SHARED = 10
|
|
|
|
|
|
def _latest_answers(db: Session, user_id: int):
|
|
"""The most recent answer to each question this learner has answered.
|
|
|
|
Repetitions, expired attempts and blanks are left out, as
|
|
everywhere that measures rather than counts practice. The most recent
|
|
answer is the one that says what is known now; the earlier attempt at the
|
|
same question says what was known then, which is a different question and
|
|
not the one a readiness score is asking.
|
|
"""
|
|
rows = db.query(
|
|
AttemptAnswer.question_id, AttemptAnswer.is_correct, QuizAttempt.completed_at,
|
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).filter(
|
|
QuizAttempt.user_id == user_id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
AttemptAnswer.user_answer != "",
|
|
).order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc()).all()
|
|
latest: dict[int, bool] = {}
|
|
for row in rows:
|
|
latest[row.question_id] = bool(row.is_correct)
|
|
return latest
|
|
|
|
|
|
@router.get("/readiness")
|
|
def readiness(
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Two figures, each refusing to appear before it means anything.
|
|
|
|
**Your score** is the share of questions you got right at your most recent
|
|
attempt at each. Not an equated score: we do not have the psychometrics to
|
|
equate one, and a number dressed up as one would be a claim we cannot
|
|
support.
|
|
|
|
**Against everyone else** compares that with how other learners did on the
|
|
very questions you answered, rather than with their scores on whatever they
|
|
happened to sit. Someone who worked through the hardest fifty in the bank
|
|
should not read as weaker than someone who did fifty easy ones, and a
|
|
percentile over different question sets says exactly that.
|
|
"""
|
|
mine = _latest_answers(db, user.id)
|
|
answered = len(mine)
|
|
correct = sum(1 for right in mine.values() if right)
|
|
unlocked = answered >= READINESS_UNLOCK_ANSWERS
|
|
|
|
peers = {}
|
|
if mine:
|
|
rows = db.query(
|
|
AttemptAnswer.question_id,
|
|
func.count(func.distinct(QuizAttempt.user_id)).label("learners"),
|
|
func.count(AttemptAnswer.id).label("answers"),
|
|
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)).label("correct"),
|
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).filter(
|
|
QuizAttempt.user_id != user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
AttemptAnswer.user_answer != "",
|
|
AttemptAnswer.question_id.in_(list(mine)),
|
|
).group_by(AttemptAnswer.question_id).all()
|
|
peers = {row.question_id: row for row in rows}
|
|
|
|
shared = [qid for qid in mine if qid in peers]
|
|
# Distinct learners cannot be summed across questions without counting the
|
|
# same person once per question they answered, so the cohort reported is
|
|
# the most any one shared question saw — a floor, not a guess.
|
|
cohort_size = max((peers[qid].learners for qid in shared), default=0)
|
|
peer_unlocked = len(shared) >= PEER_MIN_SHARED and cohort_size >= PEER_MIN_LEARNERS
|
|
|
|
expected = None
|
|
yours_on_shared = None
|
|
if shared:
|
|
expected = round(100 * sum(
|
|
(peers[qid].correct or 0) / peers[qid].answers for qid in shared) / len(shared), 1)
|
|
yours_on_shared = round(100 * sum(1 for qid in shared if mine[qid]) / len(shared), 1)
|
|
|
|
return {
|
|
"answered": answered,
|
|
"score": round(100 * correct / answered, 1) if answered else None,
|
|
"unlocked": unlocked,
|
|
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - answered),
|
|
"peer": {
|
|
"unlocked": peer_unlocked,
|
|
"shared_questions": len(shared),
|
|
"shared_needed": max(0, PEER_MIN_SHARED - len(shared)),
|
|
"cohort": cohort_size,
|
|
"cohort_needed": max(0, PEER_MIN_LEARNERS - cohort_size),
|
|
"expected": expected,
|
|
"yours": yours_on_shared,
|
|
"delta": None if expected is None else round(yours_on_shared - expected, 1),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/recommendations")
|
|
def study_recommendations(
|
|
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
|
limit: int = Query(20, ge=1, le=60),
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""Focus areas ranked by the study time most likely to raise the learner's score.
|
|
|
|
Readiness is the learner's accuracy in a category shrunk toward their own
|
|
overall accuracy in proportion to how few answers that category has, so a
|
|
single unlucky question does not read as a knowledge gap. It is a plain
|
|
empirical-Bayes estimate over recorded answers — not a psychometric exam
|
|
score, and not a prediction of any real examination.
|
|
"""
|
|
# Everything below is scoped to the study objective the learner has chosen.
|
|
# Someone revising for a paediatrics board who sat a plan meant for a step
|
|
# exam should not have that plan steer their recommendations; the objective
|
|
# is the frame, not whatever they happened to answer.
|
|
exam_filter = exam_scope_predicate(db, user)
|
|
active_exam = db.get(Exam, user.active_exam_id) if getattr(user, "active_exam_id", None) else None
|
|
|
|
categories = db.query(QuestionCategory).all()
|
|
# Membership — which article, discipline or system a question counts
|
|
# towards — is the same question a session analysis asks, so both ask it
|
|
# of the same place.
|
|
grouping = Grouping(db, group, categories)
|
|
names = grouping.names
|
|
parents = grouping.parents
|
|
|
|
# ── What the learner has answered ──────────────────────────────
|
|
answered_rows = db.query(
|
|
AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id,
|
|
AttemptAnswer.used_hint,
|
|
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
|
|
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
|
|
).join(Question, Question.id == AttemptAnswer.question_id
|
|
).filter(
|
|
QuizAttempt.user_id == user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
|
|
|
|
# A repetition is practice, not a new measurement: the answers have
|
|
# already been seen, so getting them right again says nothing about
|
|
# whether they were known. It has its own analysis; it is not in this.
|
|
or_(Quiz.is_repetition == 0, Quiz.is_repetition.is_(None)),
|
|
*([exam_filter] if exam_filter is not None else []),
|
|
).all()
|
|
|
|
groups_for = grouping.keys_for
|
|
|
|
answered: dict[int, int] = defaultdict(int)
|
|
correct: dict[int, int] = defaultdict(int)
|
|
# Right after opening a tip. Counted as correct, because it was, and kept
|
|
# apart so a topic can show how much of its score leaned on one.
|
|
hinted: dict[int, int] = defaultdict(int)
|
|
seen_questions: dict[int, set[int]] = defaultdict(set)
|
|
total_answers = len(answered_rows)
|
|
total_correct = sum(1 for _, is_correct, _, _ in answered_rows if is_correct)
|
|
for question_id, is_correct, primary, used_hint in answered_rows:
|
|
for key in groups_for(question_id, primary):
|
|
answered[key] += 1
|
|
seen_questions[key].add(question_id)
|
|
if is_correct:
|
|
correct[key] += 1
|
|
if used_hint:
|
|
hinted[key] += 1
|
|
|
|
# ── How much bank material each group holds ────────────────────
|
|
available: dict[int, int] = defaultdict(int)
|
|
bank_total = 0
|
|
grouped_total = 0
|
|
for question_id, primary in db.query(Question.id, Question.question_category_id).filter(
|
|
shareable_question_predicate(),
|
|
*([exam_filter] if exam_filter is not None else [])).all():
|
|
bank_total += 1
|
|
keys = groups_for(question_id, primary)
|
|
if keys:
|
|
grouped_total += 1
|
|
for key in keys:
|
|
available[key] += 1
|
|
|
|
overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
|
|
unlocked = total_answers >= READINESS_UNLOCK_ANSWERS
|
|
|
|
describe = grouping.describe
|
|
|
|
# ── Relevance from the board's own outline ───────────────────────────
|
|
# A topic's relevance is the share of the real paper it accounts for, which
|
|
# the examining board publishes and `exam_blueprints.weight` holds. Pool
|
|
# share — how much of our bank happens to sit under it — is a fact about
|
|
# the bank, not about the exam, and says cardiology and rheumatology are
|
|
# equally worth an evening when the board says one is worth two and a half
|
|
# of the other.
|
|
#
|
|
# A domain's weight is divided among the topics beneath it in proportion to
|
|
# the material each has, so the topics under a domain add up to its
|
|
# published share. Anything the blueprint does not cover falls back to pool
|
|
# share rather than reporting nothing.
|
|
blueprint_weight: dict[int, float] = {}
|
|
if active_exam:
|
|
from app.services import exam_blueprint
|
|
|
|
for line in exam_blueprint.domains(db, active_exam.id):
|
|
if line.weight is None:
|
|
continue
|
|
for category_id in exam_blueprint.categories_for(db, line.id):
|
|
blueprint_weight[category_id] = float(line.weight)
|
|
|
|
# How much material sits under each category, so a domain's weight can be
|
|
# divided among its topics in proportion to what each actually has.
|
|
category_pools: dict[int, int] = {}
|
|
if blueprint_weight:
|
|
for key in set(available) | set(seen_questions):
|
|
category_id = describe(key).get("category_id")
|
|
if category_id is not None:
|
|
category_pools[category_id] = category_pools.get(category_id, 0) + available.get(key, 0)
|
|
|
|
def published_relevance(category_id, pool):
|
|
"""The topic's share of the paper, or None if the board does not say."""
|
|
weight = blueprint_weight.get(category_id) if category_id else None
|
|
if weight is None:
|
|
return None
|
|
siblings = sum(p for cid, p in category_pools.items()
|
|
if blueprint_weight.get(cid) == weight)
|
|
return round(weight * pool / siblings, 2) if siblings else round(weight, 2)
|
|
|
|
rows = []
|
|
for key in set(available) | set(seen_questions):
|
|
seen = len(seen_questions.get(key, ()))
|
|
pool = available.get(key, 0)
|
|
if pool == 0 and seen == 0:
|
|
continue
|
|
n = answered.get(key, 0)
|
|
c = correct.get(key, 0)
|
|
accuracy = round(100 * c / n, 1) if n else None
|
|
readiness = None
|
|
if unlocked and n:
|
|
shrunk = (c + READINESS_PRIOR_ANSWERS * overall_accuracy) / (n + READINESS_PRIOR_ANSWERS)
|
|
readiness = round(100 * shrunk, 1)
|
|
# Relevance is measured against the material this grouping can see, not
|
|
# the whole bank: half the bank carries no system tag, and dividing by
|
|
# the whole bank would make every system look half as relevant as it is.
|
|
denominator = grouped_total if group == "systems" else bank_total
|
|
relevance = round(100 * pool / denominator, 1) if denominator else 0.0
|
|
described = describe(key)
|
|
published = published_relevance(described.get("category_id"), pool)
|
|
if published is not None:
|
|
relevance = published
|
|
coverage = round(100 * seen / pool, 1) if pool else 0.0
|
|
rows.append({
|
|
"key": key,
|
|
"answered": n,
|
|
"correct": c,
|
|
"correct_with_hints": hinted.get(key, 0),
|
|
"seen_questions": seen,
|
|
"available": pool,
|
|
"coverage": coverage,
|
|
"accuracy": accuracy,
|
|
"readiness": readiness,
|
|
"relevance": relevance,
|
|
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
|
|
# Said, so the number can be read for what it is: a board's
|
|
# published share, or our bank's own proportions.
|
|
"relevance_source": "blueprint" if published is not None else "bank",
|
|
**described,
|
|
})
|
|
|
|
# Priority: weak-and-relevant first, then untouched material by relevance.
|
|
baseline = 100 * overall_accuracy if total_answers else 70.0
|
|
for row in rows:
|
|
score = row["readiness"] if row["readiness"] is not None else row["accuracy"]
|
|
gap = (baseline - score) / 100 if score is not None else 0.5 # unseen material sits mid-priority
|
|
unseen = 1 - (row["coverage"] / 100)
|
|
row["priority"] = round(max(gap, 0.0) * (row["relevance"] / 100) + 0.25 * unseen * (row["relevance"] / 100), 5)
|
|
rows.sort(key=lambda row: (-row["priority"], -row["relevance"], row["name"]))
|
|
focus_keys = {row["key"] for row in rows[:3] if row["answered"]}
|
|
for row in rows:
|
|
row["is_focus_area"] = row["key"] in focus_keys
|
|
|
|
return {
|
|
"group": group,
|
|
"exam_id": active_exam.id if active_exam else None,
|
|
"exam_name": active_exam.name if active_exam else None,
|
|
"unlocked": unlocked,
|
|
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total_answers),
|
|
"total_answered": total_answers,
|
|
"unique_questions_seen": len({row[0] for row in answered_rows}),
|
|
"bank_total": bank_total,
|
|
# For systems: how much of the bank this grouping can actually see.
|
|
"grouped_total": grouped_total,
|
|
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
|
|
# The session most recently worked on, so the page can point at one
|
|
# rather than offering a third tab with nothing behind it.
|
|
"last_attempt_id": (
|
|
db.query(QuizAttempt.id)
|
|
.filter(QuizAttempt.user_id == user.id)
|
|
.order_by(QuizAttempt.started_at.desc())
|
|
.limit(1).scalar()
|
|
),
|
|
"focus_areas": rows[:limit],
|
|
# No `basis` any more. It was a paragraph of methodology under a table
|
|
# that already says what it is — how the shrinkage works is a decision
|
|
# for whoever tunes it, not something to explain to a learner who wants
|
|
# to know what to revise.
|
|
}
|
|
|
|
@router.get("/attempts/{attempt_id}/questions/{question_id}/responses")
|
|
def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
|
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()
|
|
if not attempt:
|
|
raise HTTPException(404, "Attempt not found")
|
|
quiz = db.get(Quiz, attempt.quiz_id)
|
|
require_quiz_access(db, quiz, user, review=True)
|
|
if attempt.completed_at is None and attempt.mode != "study":
|
|
raise HTTPException(403, "Responses are hidden until the exam is submitted")
|
|
if not question_in_quiz(db, quiz.id, question_id) or (attempt.selected_question_ids is not None and question_id not in attempt.selected_question_ids):
|
|
raise HTTPException(404, "Question not selected for this attempt")
|
|
question = db.get(Question, question_id)
|
|
rows = db.query(AttemptAnswer.user_answer, func.count(AttemptAnswer.id)).join(
|
|
QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).filter(
|
|
AttemptAnswer.question_id == question_id, QuizAttempt.completed_at.isnot(None),
|
|
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), general_quiz_visibility(user),
|
|
).group_by(AttemptAnswer.user_answer).all()
|
|
counts = Counter()
|
|
for answer, count in rows:
|
|
if answer and answer.strip():
|
|
counts[answer.strip().casefold()] += count
|
|
# Duplicate option strings must not double-count the same answer; case variants collapse too.
|
|
options = list(dict.fromkeys((option.casefold() for option in (question.options or []))))
|
|
stats = [{"option": option, "count": counts[option.casefold()]} for option in options]
|
|
sample_size = sum(option["count"] for option in stats)
|
|
for option in stats:
|
|
option["percentage"] = round(100 * option["count"] / sample_size, 1) if sample_size else 0
|
|
return {"sample_size": sample_size, "options": stats, "basis": "Recorded answers from accessible completed general-bank attempts; skips and obsolete options excluded."}
|