pdf-quiz-generator/backend/app/services/knowledge_groups.py
Daniel 13d5f50c58 feat: an organ system is a fact about a topic, not about a keyword
A question reached a system through a symptom keyword it happened to
mention — question → keyword → parent system — and only 726 of 4,281
keywords had ever been given a parent. The Systems tab saw 1,492 of 2,924
questions while Disciplines saw all of them.

The system now sits on the category: question_categories.system_id. Every
question has a category, so every question reaches a system. 2,919 of
2,924, and all sixteen buckets have real content.

It stays a third way of asking rather than the discipline tree relabelled
because a topic's system is assigned separately from where it sits in the
tree. scripts/assign_category_systems takes the discipline as a default
and lets the topic's own name overrule it, which is exactly the case that
makes the axis worth having: conjunctivitis is filed under Infectious
Disease and is an eye, osteomyelitis is filed there and is a bone. 110 of
660 topics were decided that way.

Two regex traps caught in the dry run and fixed before applying:
"adRENAL" matched the kidney rule, and "Abnormal Uterine Bleeding" matched
the bleeding rule. Both now have a specific rule above the general one.

I first tried to fix this by parenting the orphan keywords to systems,
deriving each keyword's system from the questions carrying it. The dry run
showed why that was the wrong shape: it reached only 534 of 3,555 orphans,
and inherited every coarse edge of the discipline map — conjunctivitis came
out as Multisystem because conjunctivitis questions are filed under
Infectious Disease. That script is left in place, unapplied, as the record
of a measurement worth keeping.

No ForeignKey on system_id in the model: question_tags is a raw-SQL table
with no ORM class, and declaring one leaves every metadata build unable to
resolve it. The constraint is real in Postgres.

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

170 lines
7.8 KiB
Python

"""Three ways to ask the same question of the same answers.
Which reading to go back to (**articles**), which discipline is weak
(**disciplines**), which organ system is weak (**systems**). Only the
membership differs; whatever is being counted — a lifetime of answers on the
Analysis page, or the dozen questions in one session — the rules for what
belongs where must be the same, or the two pages disagree about a learner in
ways neither can explain.
So the rules live here once, and both callers ask this.
"""
from collections import defaultdict
from sqlalchemy import inspect
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.models.article import Article
from app.models.question_category import QuestionCategory, QuestionCategoryLink
GROUPS = ("articles", "disciplines", "systems")
def category_ancestry(categories) -> dict[int, list[int]]:
"""Every category with the chain of parents above it, itself included.
A question filed under "Neonatal jaundice" counts towards "Neonatology"
too, or a discipline would only ever score on questions filed at its own
level — which is none of them.
"""
parents = {cat.id: cat.parent_id for cat in categories}
ancestry: dict[int, list[int]] = {}
for cid in parents:
chain, walk, guard = [], cid, 0
# Guarded rather than trusted: a cycle in the tree would hang the page,
# and the depth of a real taxonomy is nowhere near this.
while walk is not None and guard < 20:
chain.append(walk)
walk = parents.get(walk)
guard += 1
ancestry[cid] = chain
return ancestry
class Grouping:
"""The membership rules for one grouping, built once and asked many times."""
def __init__(self, db: Session, group: str, categories=None):
if group not in GROUPS:
raise ValueError(f"unknown grouping: {group}")
self.group = group
self.categories = categories if categories is not None else db.query(QuestionCategory).all()
self.names = {cat.id: cat.name for cat in self.categories}
self.parents = {cat.id: cat.parent_id for cat in self.categories}
self.top_level = {cat.id for cat in self.categories if cat.parent_id is None}
self.ancestry = category_ancestry(self.categories)
self.extra_links: dict[int, set[int]] = defaultdict(set)
for question_id, category_id in db.query(
QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
self.extra_links[question_id].add(category_id)
# An article is reached through the category it is filed under.
self.articles: dict[int, Article] = {}
for article in db.query(Article).filter(Article.status == "published",
Article.category_id.isnot(None)).all():
self.articles.setdefault(article.category_id, article)
self.article_by_id = {a.id: a for a in self.articles.values()}
self.category_of_article = {a.id: cid for cid, a in self.articles.items()}
# A question reaches an organ system through the topic it is filed
# under, which carries one. It used to go through a symptom keyword the
# question happened to mention, and only about half of them mentioned
# one that had been filed — so the Systems tab saw half the bank while
# Disciplines saw all of it.
#
# A category's system is a separate fact from where it sits in the
# tree, which is what keeps this a third way of asking rather than the
# discipline tree relabelled: conjunctivitis is filed under Infectious
# Disease and is an eye.
#
# Raw SQL for the names because question_tags has no ORM model; the
# tags router is the same, and one spelling of this table is better
# than two.
self.system_names: dict[int, str] = {}
self.system_of_category: dict[int, int] = {}
if group == "systems" and inspect(db.get_bind()).has_table("question_tags"):
for tag_id, name in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system' AND parent_id IS NULL")).all():
self.system_names[tag_id] = name
for cat in self.categories:
system_id = getattr(cat, "system_id", None)
if system_id in self.system_names:
self.system_of_category[cat.id] = system_id
def categories_for(self, question_id: int, primary: int | None) -> set[int]:
"""Every category a question counts towards, ancestors included."""
direct = self.extra_links.get(question_id, set()) | ({primary} if primary else set())
rolled: set[int] = set()
for cid in direct:
rolled.update(self.ancestry.get(cid, [cid]))
return rolled
def keys_for(self, question_id: int, primary: int | None) -> set[int]:
"""Which rows of this grouping the question belongs to."""
rolled = self.categories_for(question_id, primary)
if self.group == "systems":
# Every category the question counts towards, including the ones it
# inherits up the tree — a topic with no system of its own still
# reaches one through the topic above it.
return {self.system_of_category[cid] for cid in rolled
if cid in self.system_of_category}
if self.group == "articles":
return {self.articles[cid].id for cid in rolled if cid in self.articles}
return rolled & self.top_level
def describe(self, key: int) -> dict:
"""What one row is called, and what it links on to."""
if self.group == "articles":
article = self.article_by_id.get(key)
category_id = self.category_of_article.get(key)
return {
"name": article.title if article else "Untitled",
"parent_name": self.names.get(self.parents.get(category_id)) if category_id else None,
"category_id": category_id,
"article_id": key,
"article_title": article.title if article else None,
"system_id": None,
}
if self.group == "systems":
return {
"name": self.system_names.get(key, "Unfiled"),
"parent_name": None,
"category_id": None,
"article_id": None,
"article_title": None,
"system_id": key,
}
article = self.articles.get(key)
return {
"name": self.names.get(key, "Uncategorized"),
"parent_name": self.names.get(self.parents.get(key)),
"category_id": key,
"article_id": article.id if article else None,
"article_title": article.title if article else None,
"system_id": None,
}
def score_rows(grouping: Grouping, marks) -> list[dict]:
"""Rank one set of answers by how they went, worst first.
`marks` is (question_id, primary_category_id, was_correct). Used for a
single session, where there is no bank coverage to weigh and no readiness
to shrink — just what was answered, and how much of it was right.
"""
answered: dict[int, list[bool]] = defaultdict(list)
for question_id, primary, is_correct in marks:
for key in grouping.keys_for(question_id, primary):
answered[key].append(bool(is_correct))
rows = [
{**grouping.describe(key),
"correct": sum(hits), "total": len(hits),
"percent": round(100 * sum(hits) / len(hits))}
for key, hits in answered.items() if hits
]
# Weakest first, and among equals the one with more answers behind it —
# a 50% on eight questions is a firmer finding than a 50% on two.
rows.sort(key=lambda row: (row["percent"], -row["total"]))
return rows