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