pdf-quiz-generator/backend/scripts/fix_blueprint_collisions.py
Daniel b2a353cf58 fix: one topic per idea, and one ABP domain per topic
The complaint was that biostatistics and research ethics were the same thing
filed twice. They were — and under them the topic tree had no home for
research, biostatistics, ethics, patient safety or quality improvement at all.
Domains 23 (Ethics, 2%) and 24 (Patient Safety, QI and Research Methods, 2%)
both pointed at the single row "Professional Topics", and 19 (Nephrology, 3%)
and 20 (Genitourinary, 3%) both claimed "Nephrology & Urology". Six per cent of
the paper rested on two rows that could not be told apart, so an adaptive
session could not weight either half of any of those pairs.

Every domain now owns its own topics, with none shared: 19 takes the kidney
topics and 20 the genital and urological ones, and Professional Topics gains
Ethics, Patient Safety & Quality Improvement, and Research & Biostatistics. A
blueprint-shaped paper draws from all 24 domains with no shortfall; two of
them — Psychosocial Issues and Child Abuse and Neglect, 3% each — had a pool of
literally zero before.

And 93 topics that were one idea under two names are now one: 25 "Pediatric X"
leaves under a root already called X, on a site that is paediatric throughout;
66 plain synonyms (Nocturnal Enuresis and Enuresis, Down Syndrome and Trisomy
21, Eczema and Atopic Dermatitis, Chlamydia filed once per discipline that
mentioned it); and two abuse topics onto the ABP's own headings. Every merge is
written out by id *and* name and refuses to run if either has moved — no
normaliser, no fuzzy key, because a key that ignores digits puts Trisomy 18
into Trisomy 21. Questions move before any row is deleted, and a cross-filing
that would have been lost becomes a link instead.

566 topics, 2,924 questions, none without a primary topic, none without an
organ system. Backups either side in backups/topics_*_reorg_*.sql.

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

146 lines
6.7 KiB
Python

"""Give every ABP domain a topic of its own.
Two pairs of domains pointed at the same category, which makes the published
weight meaningless for both: Ethics (2%) and Patient Safety, Quality
Improvement and Research Methods (2%) both claimed "Professional Topics", and
Nephrology (3%) and Genitourinary System (3%) both claimed "Nephrology &
Urology". Six per cent of the paper resting on one row cannot be split between
two subjects by arithmetic — the outline is telling us the tree is missing a
branch.
So the branches are made. Professional Topics gains Ethics, Patient Safety &
Quality Improvement, and Research & Biostatistics; Medication Error moves under
Patient Safety, where it always belonged. Paediatric Dentistry leaves
Professional Topics entirely — it is a clinical subject that was filed under
"professional" because nothing else fitted, and its real home is preventive
care, where fluoride and caries are anticipatory guidance (ABP 1.E).
Two corrections since the first draft, both found by reading the code that
consumes the mapping. `exam_blueprint.categories_for()` expands a mapped
category to its whole subtree, so pointing Nephrology at the "Nephrology &
Urology" root would have swallowed "Genital & Scrotal" and collided with
Genitourinary all over again: the root is left unmapped and the two domains
take its children instead. And the genitourinary half needs "Pediatric
Urology" and "Congenital Urinary Tract" beside the genital topics, or it is
too thin to draw a block from.
This is now the smaller half of `reorganize_topics`, which does the same work
along with the merges the tree needed. Running it after that one should report
that there is nothing left to do; it is kept because it states this particular
repair on its own.
Idempotent, and a dry run by default:
docker compose exec backend python -m scripts.fix_blueprint_collisions
docker compose exec backend python -m scripts.fix_blueprint_collisions --apply
"""
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
#: Domain code -> the topics that domain is about. Replaces whatever is mapped.
#: Names, not ids, and every one of them is checked before it is used.
MAPPING = {
"23": ["Ethics"],
"24": ["Patient Safety & Quality Improvement", "Research & Biostatistics"],
"19": ["Pediatric Nephrology", "Nephrolithiasis", "Urinary Tract Infection", "Enuresis",
"Renal Dysplasia", "Glomerular Disease", "Acute & Chronic Kidney Disease"],
"20": ["Pediatric Urology", "Orchitis", "Epididymitis", "Congenital Urinary Tract",
"Genital & Scrotal"],
}
#: Topics to create under a parent if they are not there, with their system.
NEW = [
("Ethics", "Professional Topics", "Social Sciences & Ethics"),
("Patient Safety & Quality Improvement", "Professional Topics", "Social Sciences & Ethics"),
("Research & Biostatistics", "Professional Topics", "Biostatistics & Epidemiology"),
]
#: Topics filed somewhere that does not describe them.
REHOME = [("Pediatric Dentistry", "Primary Care & Prevention"),
("Dental Fluorosis", "Primary Care & Prevention"),
("Medication Error", "Patient Safety & Quality Improvement")]
def one(db, sql, **args):
return db.execute(sa_text(sql), args).first()
def main(apply: bool) -> int:
db = SessionLocal()
try:
owner = one(db, "SELECT id FROM users ORDER BY id LIMIT 1")
if not owner:
print("! no users, so nothing can own a category")
return 1
print("topics to create")
for name, parent, system in NEW:
exists = one(db, "SELECT id FROM question_categories WHERE name = :n", n=name)
print(f" {name:<38} under {parent:<26} {'(already there)' if exists else ''}")
if exists or not apply:
continue
p = one(db, "SELECT id FROM question_categories WHERE name = :n AND parent_id IS NULL",
n=parent)
s = one(db, "SELECT id FROM question_tags WHERE name = :n AND type = 'system'", n=system)
db.execute(sa_text(
"INSERT INTO question_categories (name, parent_id, user_id, system_id, created_at) "
"VALUES (:n, :p, :u, :s, NOW())"),
{"n": name, "p": p[0] if p else None, "u": owner[0], "s": s[0] if s else None})
print("\ntopics to rehome")
for name, parent in REHOME:
node = one(db, "SELECT id, parent_id FROM question_categories WHERE name = :n", n=name)
target = one(db, "SELECT id FROM question_categories WHERE name = :n", n=parent)
if not node or not target:
print(f" ! {name:<36} no '{name if not node else parent}' to work with")
continue
done = " (already there)" if node[1] == target[0] else ""
print(f" {name:<38}{parent}{done}")
if apply and not done:
db.execute(sa_text("UPDATE question_categories SET parent_id = :p WHERE id = :n"),
{"p": target[0], "n": node[0]})
print("\ndomains to remap")
for code, names in MAPPING.items():
line = one(db, "SELECT id, title FROM exam_blueprints "
"WHERE exam_id = 1 AND code = :c AND parent_id IS NULL", c=code)
if not line:
print(f" ! no domain {code}")
continue
found = {}
for name in names:
cat = one(db, "SELECT id FROM question_categories WHERE name = :n", n=name)
if cat:
found[name] = cat[0]
else:
print(f" ! domain {code} wants '{name}', which is not a topic")
have = {r[0] for r in db.execute(sa_text(
"SELECT category_id FROM blueprint_category_links WHERE blueprint_id = :b"),
{"b": line[0]}).all()}
done = " (already mapped)" if have == set(found.values()) else ""
print(f" {code:>2} {line[1][:40]:<42}{', '.join(found)}{done}")
if not apply or done:
continue
db.execute(sa_text("DELETE FROM blueprint_category_links WHERE blueprint_id = :b"),
{"b": line[0]})
for cid in found.values():
db.execute(sa_text(
"INSERT INTO blueprint_category_links (blueprint_id, category_id) "
"VALUES (:b, :c) ON CONFLICT DO NOTHING"), {"b": line[0], "c": cid})
if not apply:
print("\ndry run. Pass --apply to write.")
db.rollback()
return 0
db.commit()
print("\ndone.")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main("--apply" in sys.argv))