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