pdf-quiz-generator/backend/app/services/prepared_session.py
Daniel 3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00

275 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The prepared session: one action, and a plain statement of why.
The manual builder asks a learner to choose topics, a count, a difficulty and a
state before it will give them anything. Most of those are decisions nobody
opening the app at seven in the morning has the information to make, and the
answers are already in their own record of answers. This turns that record into
a session.
Two things are produced together and must never disagree: the questions, and
the account of why those questions. The account is the product — an adaptive
session a learner cannot see the reasoning behind is one they abandon for a
manual session they can — so a plan is built, stated, and only then committed,
and the same `CandidateRanking` produces both. There is no language model
anywhere in here. This is arithmetic over the learner's own answers, and it has
to be reproducible and legible; a model that could not show its working would
make the explanation impossible, which is the whole of what is being built.
Selection itself lives in `quiz_builder.CandidateRanking` — this module decides
how long the session should be, groups what was chosen, and says why.
"""
import random
import statistics
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.exam import Exam
from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz
from app.services import exam_blueprint
from app.services.knowledge_groups import category_ancestry
from app.services.quiz_builder import (CandidateRanking, bank_question_predicate,
general_question_predicate, require_objective)
#: Where a session starts before the learner has finished enough of them for
#: their own habit to be a measurement rather than an anecdote.
DEFAULT_SESSION_LENGTH = 20
#: How many finished sessions it takes before length is set from the learner's
#: own median. Three, because one session is not a habit and two cannot have a
#: median that is not simply the mean of the pair.
SESSIONS_BEFORE_LENGTH_IS_PERSONAL = 3
#: Bounds on an offered length. Not on what the learner may then ask for — the
#: builder's own 1200 still governs that — only on what is offered unasked.
#: Below five a session says nothing about a topic; above sixty it stops being
#: something anyone finishes in a sitting, and length is set here from what
#: this learner actually finishes.
MIN_SESSION_LENGTH = 5
MAX_SESSION_LENGTH = 60
#: Accuracy below which a topic is described to the learner as a weak area.
WEAK_ACCURACY = 0.6
#: Decayed answers a topic needs behind it before that word is used. Below
#: this the prior in `combined_accuracy` is most of the number, and a topic
#: last answered — correctly — in the spring would come out under the
#: threshold and be reported as a weakness on no evidence at all. Three is the
#: point at which the learner's own answers outweigh the prior.
WEAK_EVIDENCE_ANSWERS = 3.0
def habitual_length(db: Session, user) -> tuple[int | None, int]:
"""The learner's own median finished session, and how many they have finished.
The median rather than the mean: one abandoned three-question session and
one marathon both pull a mean somewhere neither of them is, and a learner
who reliably does thirty should be offered thirty.
Counted as answers recorded, not as questions the session held, because
what is wanted is the length they *finish*. Expired attempts are
out for the same reason they are out of every other figure here.
"""
rows = db.query(func.count(AttemptAnswer.id)).select_from(QuizAttempt).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)),
).group_by(QuizAttempt.id).all()
lengths = sorted(row[0] for row in rows if row[0])
if len(lengths) < SESSIONS_BEFORE_LENGTH_IS_PERSONAL:
return None, len(lengths)
median = int(round(statistics.median(lengths)))
return max(MIN_SESSION_LENGTH, min(MAX_SESSION_LENGTH, median)), len(lengths)
def _ago(days: float) -> str:
"""How long ago, at the resolution a learner thinks in."""
if days < 7:
return "this week"
weeks = int(days // 7)
if weeks < 9:
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
months = max(2, int(round(days / 30.44)))
return f"{months} months ago"
def _percent(weight: float | None) -> str | None:
return None if weight is None else f"{round(weight, 1):g}"
def _blueprint_paper(db: Session, user, count: int, seed: str) -> list[int]:
"""A paper shaped like the published exam, for a learner with no record yet.
Seeded, and seeded on the day rather than the moment, because the plan and
the session it commits are two requests: an unseeded draw would show one
paper and start another, and the explanation would then be a fiction.
"""
exam_id = getattr(user, "active_exam_id", None)
if not exam_id or not exam_blueprint.domains(db, exam_id):
return []
ids, _ = exam_blueprint.sample(
db, exam_id, count, predicate=bank_question_predicate(user) & general_question_predicate(),
rng=random.Random(seed))
return ids
def _topic_rows(db: Session, ranking: CandidateRanking, question_ids: list[int],
cold_start: bool) -> list[dict]:
"""Group the chosen questions by discipline, and say why each is there.
Counted from the questions actually chosen rather than predicted from the
ranking, so the plan cannot drift from the session: every count below is a
tally of `question_ids`.
Grouped by the top-level category — the discipline — because that is the
vocabulary the Analysis page already reports a learner's weaknesses in, and
a plan phrased in leaf topics would be twenty rows of one question each.
"""
categories = db.query(QuestionCategory).all()
names = {cat.id: cat.name for cat in categories}
ancestry = category_ancestry(categories)
discipline_of = {cid: chain[-1] for cid, chain in ancestry.items() if chain}
groups: dict[int | None, dict] = {}
for question_id in question_ids:
category = ranking.category_of.get(question_id)
key = discipline_of.get(category) if category is not None else None
row = groups.setdefault(key, {
"category_id": key, "name": names.get(key, "Unfiled") if key else "Unfiled",
"count": 0, "new_count": 0, "review_count": 0,
"_leaves": set(), "_ages": [],
})
row["count"] += 1
if question_id in ranking.recall:
row["review_count"] += 1
row["_ages"].append(ranking.latest[question_id][2])
else:
row["new_count"] += 1
if category is not None:
row["_leaves"].add(category)
rows = []
for row in groups.values():
leaves = row.pop("_leaves")
ages = row.pop("_ages")
# A discipline's own numbers, not the average of its leaves': what the
# learner is told about "Cardiology" has to be about cardiology.
scored = leaves | ({row["category_id"]} if row["category_id"] else set())
accuracy = ranking.combined_accuracy(scored)
evidence = ranking.evidence_weight(scored)
weight = ranking.weights.get(row["category_id"]) if ranking.weights else None
row["accuracy"] = round(100 * accuracy) if accuracy is not None else None
row["weight"] = round(weight, 1) if weight is not None else None
row["reason"] = _reason(row, accuracy, evidence, weight, ages, cold_start)
rows.append(row)
# Biggest share of the session first: the plan should read in the order the
# learner's attention is being spent.
rows.sort(key=lambda row: (-row["count"], row["name"]))
return rows
def _reason(row: dict, accuracy: float | None, evidence: float, weight: float | None,
ages: list[float], cold_start: bool) -> str:
"""One line saying why this topic is in the session.
Strongest reason first, so the sentence a learner reads is the reason and
not merely a true statement about the topic. A weak topic is usually also
due for review — being told the accuracy is the more useful of the two.
"""
share = _percent(weight)
if cold_start:
return f"Worth {share}% of the exam" if share else "Broad coverage — no history to go on yet"
if accuracy is not None and accuracy < WEAK_ACCURACY and evidence >= WEAK_EVIDENCE_ANSWERS:
return f"Weak area — {round(100 * accuracy)}% correct so far"
if ages and row["review_count"] >= row["new_count"]:
return f"Due for review — last answered {_ago(min(ages))}"
if accuracy is None:
return f"Not attempted yet, worth {share}% of the exam" if share else "Not attempted yet"
return f"Worth {share}% of the exam" if share else "Keeping your coverage even"
def prepare_session(db: Session, user, count: int | None = None, now: datetime | None = None) -> dict:
"""Build a session for this learner and the account of why it is that one.
Nothing is written. The same call is made again to commit, which is what
makes the plan honest: the questions named here are the questions started.
"""
require_objective(db, user)
now = now or datetime.utcnow()
ranking = CandidateRanking(db, user, now=now)
available = len(ranking.rows)
if not available:
raise HTTPException(400, "There are no questions in your bank yet")
habitual, finished = habitual_length(db, user)
asked = count is not None
if asked:
length = max(1, min(200, count))
else:
length = habitual or DEFAULT_SESSION_LENGTH
length = min(length, available)
# No finished session is the honest test for cold start, not "no answers":
# a learner who abandoned one question has told us nothing to personalize
# from, and being handed a "weak area" on the strength of it is the fake
# personalization this is meant to avoid.
cold_start = finished == 0
seed = f"{user.id}:{length}:{now.date().isoformat()}"
question_ids = _blueprint_paper(db, user, length, seed) if cold_start else []
blueprint_led = bool(question_ids)
if not question_ids:
question_ids = ranking.select(length)
if not question_ids:
raise HTTPException(400, "There are no questions in your bank yet")
topics = _topic_rows(db, ranking, question_ids, cold_start)
review_count = sum(row["review_count"] for row in topics)
new_count = sum(row["new_count"] for row in topics)
exam_name = None
if getattr(user, "active_exam_id", None):
exam = db.get(Exam, user.active_exam_id)
exam_name = exam.name if exam else None
return {
"count": len(question_ids),
"question_ids": question_ids,
"basis": "cold_start" if cold_start else "personalized",
"new_count": new_count,
"review_count": review_count,
"available": available,
"exam_name": exam_name,
"summary": _summary(cold_start, blueprint_led, new_count, review_count),
"length_reason": _length_reason(asked, habitual, finished, len(question_ids), available),
"topics": topics,
}
def _summary(cold_start: bool, blueprint_led: bool, new_count: int, review_count: int) -> str:
if cold_start:
spread = "the exam blueprint" if blueprint_led else "your whole question bank"
return ("You haven't finished a session yet, so this is an even spread across "
f"{spread} rather than a personalized one.")
if review_count:
return (f"{new_count} new and {review_count} due for review, ranked by what would "
"move your score the most.")
return f"{new_count} new questions, ranked by what would move your score the most."
def _length_reason(asked: bool, habitual: int | None, finished: int,
length: int, available: int) -> str:
if asked:
reason = f"{length} questions, your choice."
elif habitual:
reason = f"You usually finish {habitual} questions, so that's the length."
else:
remaining = SESSIONS_BEFORE_LENGTH_IS_PERSONAL - finished
reason = (f"{DEFAULT_SESSION_LENGTH} to start with — after {remaining} more finished "
f"session{'s' if remaining != 1 else ''} this matches your own median.")
if length >= available:
reason += " That is everything left in your bank."
return reason