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
170 lines
7.8 KiB
Python
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
|