**Prepared sessions.** Most of this existed: unanswered first, weakest topic next, wrong-before-right after that, all scaled by what share of the real paper each topic carries. What it could not do was change with time, say anything about itself, or be reached without filling in a form. Evidence now decays on a thirty-day half-life. Exponential rather than a fixed window because memory has a slope, not a cliff — under a window, 29 days counts fully and 31 counts for nothing — and because it is memoryless, so an answer's weight does not shift when unrelated questions are answered, which is what lets the preview stay a valid forecast. Spring is worth an eighth of last week. Two things decay: a question's recall probability, drifting towards even rather than past it, so an old right answer becomes eligible rather than wrong; and a topic's accuracy, against a prior of two "no idea" answers, which fixes "right once, known forever". Strict unanswered-first meant that on a bank of 2,900 nothing was ever recycled — spaced repetition existed and was unreachable. Review now takes up to two fifths of a session. And the damping that spread the picks across topics was applied only to seen material, so a learner with no history was handed the heaviest domain entire instead of a spread; that was live. The plan is the product. It is computed, shown, and then the session is built from that plan's own ids and the plan returned with it, so the two cannot differ; every figure in it is a tally over the chosen questions rather than a forecast. No model touches the ranking — a learner asking "why these twenty" has to get the same answer twice. **Vision.** The proxy's own `/model/info` says which models can see, so nothing is hard-coded: 77 report yes, 11 no, and 328 say nothing at all, which means absent rather than incapable — so those are asked once with an 8px PNG and the refusal cached. The deployment's main model turns out not to see, and questions carry figures the learner is looking at, so the tutor was answering about an image it had never been shown. It routes to a configured tool model now, folds the description back in as text saying plainly where it came from, and caches on the bytes because the same figure is re-sent every turn. Also fixed on the way: `article` was missing from the admin's task list, so article drafting always ran on the fallback model whatever an administrator chose; and `.jpx` stem images were sent as JPEG because `mimetypes` guesses that from the name, so the provider rejected them two hops later. An administrator must pick a tool model in Settings → AI models. Until then the tutor says a figure exists that nothing could read, rather than describing one it cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
276 lines
13 KiB
Python
276 lines
13 KiB
Python
"""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 1–200 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 and course 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)),
|
||
Quiz.course_id.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
|