"""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."}