diff --git a/backend/scripts/convert_tags_to_categories.py b/backend/scripts/convert_tags_to_categories.py index 5f9fb12..37593bf 100644 --- a/backend/scripts/convert_tags_to_categories.py +++ b/backend/scripts/convert_tags_to_categories.py @@ -147,23 +147,60 @@ def main(): system_by_name = {name: get_category(name).id for name in SYSTEMS} print(f"Canonical systems: {len(SYSTEMS)}") + # Level 2: 'Pediatric X' subject tags become children of the matching system. + def pediatric_sub(tag): + name = tag.strip() + if not name.casefold().startswith("pediatric "): + return None + base = name[9:].strip() + system = canonical_system(base) or canonical_system(name) + if not system or system == "General Pediatrics": + return None + return system, name + + sub_ids: dict[str, int] = {} + for qid, tag in subject_rows: + pair = pediatric_sub(tag) + if pair: + system, name = pair + sub_ids.setdefault(name, get_category(name, system_by_name[system]).id) + + # Level 3: diseases under the systems where the pair is actually used. + pair_usage = defaultdict(int) + for qid, names in diseases.items(): + systems_for_q = {canonical_system(t) for t in subjects.get(qid, []) + if canonical_system(t) and canonical_system(t) != "General Pediatrics"} or {"General Pediatrics"} + for name in names: + for system in systems_for_q: + pair_usage[(system, name)] += 1 + disease_ids: dict[tuple, int] = {} + for (system, name), used in pair_usage.items(): + if used >= 3: + disease_ids[(system, name)] = get_category(name, system_by_name[system]).id + print(f"Pediatric subs: {len(sub_ids)}; disease children: {len(disease_ids)}") + changed, links_added, skipped = 0, 0, 0 for qid in question_ids: specific = sorted({canonical_system(tag) for tag in subjects.get(qid, []) if canonical_system(tag) and canonical_system(tag) != "General Pediatrics"}) if specific: - system_names = specific - elif subjects.get(qid) or diseases.get(qid): - system_names = ["General Pediatrics"] + systems = specific else: - skipped += 1 - continue - # Systems first: diseases become children in a later pass once the taxonomy settles. - primary = system_by_name[system_names[0]] - extras = {system_by_name[name] for name in system_names} + # Untagged and Pediatrics-only questions fall back to General Pediatrics; + # PREP categories are being retired so nothing is left dangling. + systems = ["General Pediatrics"] + if not (subjects.get(qid) or diseases.get(qid)): + skipped += 1 + systems = ["General Pediatrics"] + subs = sorted({pediatric_sub(tag)[1] for tag in subjects.get(qid, []) if pediatric_sub(tag)}) + system_ids = [system_by_name[name] for name in systems] + sub_category_ids = [sub_ids[name] for name in subs] + disease_children = [disease_ids[(system, disease)] for disease in sorted(set(diseases.get(qid, []))) + for system in systems if (system, disease) in disease_ids] + # Primary: deepest specific subject (pediatric sub, else system); diseases stay as extras. + primary = sub_category_ids[0] if sub_category_ids else system_ids[0] + extras = set(system_ids + sub_category_ids + disease_children) extras.discard(primary) - if old_primary.get(qid) is not None: - extras.add(old_primary[qid]) if old_primary.get(qid) != primary: db.query(Question).filter(Question.id == qid).update({"question_category_id": primary}) changed += 1 @@ -171,9 +208,29 @@ def main(): for cid in sorted(extras - existing): db.add(QuestionCategoryLink(question_id=qid, category_id=cid)) links_added += 1 + # PREP provenance becomes a keyword tag; the question categories are retired. + prep_categories = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all() + prep_tagged = 0 + for category in prep_categories: + linked = {row[0] for row in db.query(QuestionCategoryLink.question_id).filter_by(category_id=category.id).all()} + linked |= {row[0] for row in db.query(Question.id).filter(Question.question_category_id == category.id).all()} + linked.discard(None) + if not linked: + db.delete(category) + continue + db.execute(text("INSERT INTO question_tags (name, type) VALUES (:name, 'keyword') " + "ON CONFLICT (LOWER(name), type) DO NOTHING"), {"name": category.name}) + tag_id = db.execute(text("SELECT id FROM question_tags WHERE LOWER(name) = LOWER(:name) AND type = 'keyword'"), + {"name": category.name}).scalar_one() + for qid in linked: + db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) " + "ON CONFLICT DO NOTHING"), {"q": qid, "t": tag_id}) + prep_tagged += len(linked) + db.delete(category) db.commit() - print(f"Reassigned primary for {changed} questions; added {links_added} extra links; skipped {skipped}; " - f"{db.query(QuestionCategory).count()} categories total.") + print(f"Reassigned primary for {changed} questions; added {links_added} extra links; " + f"tagged {prep_tagged} question-links across {len(prep_categories)} retired PREP categories; " + f"skipped {skipped}; {db.query(QuestionCategory).count()} categories total.") finally: db.close() diff --git a/backend/scripts/seed_lab_references.py b/backend/scripts/seed_lab_references.py index 93f4a34..d61e3b6 100644 --- a/backend/scripts/seed_lab_references.py +++ b/backend/scripts/seed_lab_references.py @@ -58,15 +58,15 @@ ROWS = [ ("Hematology", "MCV", "88–123", "fL", "0–1 month", "Whole blood", UI, UI_URL), ("Hematology", "MCV", "74–108", "fL", "3–6 months", "Whole blood", UI, UI_URL), ("Hematology", "MCV", "70–85", "fL", "6 months–1 year", "Whole blood", UI, UI_URL), - ("Hematology", "Reticulocytes", "0.5–1.5", "%", "Child (higher in newborn)", "Whole blood", PEDI, PEDI_URL), + ("Hematology", "Reticulocytes", "0.5–1.5", "%", "All ages (higher in newborn)", "Whole blood", PEDI, PEDI_URL), # ── Chemistries ───────────────────────────────────────────────────── ("Chemistries", "Sodium", "135–145", "mmol/L", ">10 days", "Plasma", PEDI, PEDI_URL), ("Chemistries", "Potassium", "3.5–6.0", "mEq/L", "<10 days", "Plasma", UI, UI_URL), ("Chemistries", "Potassium", "3.5–5.0", "mEq/L", ">10 days", "Plasma", UI, UI_URL), - ("Chemistries", "Chloride", "98–106", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL), - ("Chemistries", "Bicarbonate (CO2)", "18–27", "mEq/L", "Child", "Plasma", UI, UI_URL), - ("Chemistries", "Bicarbonate", "22–26", "mmol/L", "Child", "Plasma", PEDI, PEDI_URL), - ("Chemistries", "Urea nitrogen (BUN)", "7–18 (urea 2.5–6.5 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "Chloride", "98–106", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "Bicarbonate (CO2)", "18–27", "mEq/L", "All ages", "Plasma", UI, UI_URL), + ("Chemistries", "Bicarbonate", "22–26", "mmol/L", "All ages", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "Urea nitrogen (BUN)", "7–18 (urea 2.5–6.5 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL), ("Chemistries", "Creatinine", "0.2–0.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL), ("Chemistries", "Creatinine", "0.2–0.4", "mg/dL", "2–12 months", "Plasma", UI, UI_URL), ("Chemistries", "Creatinine", "0.2–0.5", "mg/dL", "1–2 years", "Plasma", UI, UI_URL), @@ -79,7 +79,7 @@ ROWS = [ ("Chemistries", "Calcium (total)", "8.7–10.5", "mg/dL", "31 days–1 year", "Plasma", UI, UI_URL), ("Chemistries", "Calcium (total)", "8.8–10.6", "mg/dL", "1–6 years", "Plasma", UI, UI_URL), ("Chemistries", "Calcium (ionized)", "4.2–5.9", "mg/dL", "Neonate", "Plasma", UI, UI_URL), - ("Chemistries", "Magnesium", "1.6–2.4 (0.7–1.0 mmol/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "Magnesium", "1.6–2.4 (0.7–1.0 mmol/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL), ("Chemistries", "Phosphate", "4.2–9.0", "mg/dL", "Newborn–11 months", "Plasma", UI, UI_URL), ("Chemistries", "Phosphate", "3.2–6.3", "mg/dL", "12 months–15 years", "Plasma", UI, UI_URL), ("Chemistries", "Albumin", "2.8–4.4", "g/dL", "0–4 days", "Plasma", UI, UI_URL), @@ -91,8 +91,8 @@ ROWS = [ ("Chemistries", "Bilirubin (total)", "≤7.0", "mg/dL", "Term, 48 h", "Serum", UI, UI_URL), ("Chemistries", "Bilirubin (total)", "≤12.0", "mg/dL", "3–5 days", "Serum", UI, UI_URL), ("Chemistries", "Bilirubin (total)", "<1.0", "mg/dL", "1 month–adult", "Serum", UI, UI_URL), - ("Chemistries", "ALT", "<40", "U/L", "Child", "Plasma", PEDI, PEDI_URL), - ("Chemistries", "AST", "<45", "U/L", "Child (higher in infants)", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "ALT", "<40", "U/L", "All ages", "Plasma", PEDI, PEDI_URL), + ("Chemistries", "AST", "<45", "U/L", "All ages (higher in infants)", "Plasma", PEDI, PEDI_URL), ("Chemistries", "GGT", "<55", "U/L", "Child (high in neonates)", "Plasma", PEDI, PEDI_URL), ("Chemistries", "Alkaline phosphatase", "75–316 (M) / 48–406 (F)", "U/L", "1–30 days", "Plasma", UI, UI_URL), ("Chemistries", "Alkaline phosphatase", "82–383 (M) / 124–341 (F)", "U/L", "31 days–1 year", "Plasma", UI, UI_URL), @@ -106,28 +106,28 @@ ROWS = [ ("Acid-base / gases", "pCO2 (arterial)", "30–40", "mmHg", "0–18 years", "Arterial blood", UI, UI_URL), ("Acid-base / gases", "pO2 (arterial)", "60–80", "mmHg", "0–1 month", "Arterial blood", UI, UI_URL), ("Acid-base / gases", "pO2 (arterial)", "80–100", "mmHg", ">1 month", "Arterial blood", UI, UI_URL), - ("Acid-base / gases", "Base excess", "−2 to +2", "mmol/L", "Child", "Blood gas", PEDI, PEDI_URL), - ("Acid-base / gases", "Lactate", "<2.0", "mmol/L", "Child", "Blood", PEDI, PEDI_URL), + ("Acid-base / gases", "Base excess", "−2 to +2", "mmol/L", "All ages", "Blood gas", PEDI, PEDI_URL), + ("Acid-base / gases", "Lactate", "<2.0", "mmol/L", "All ages", "Blood", PEDI, PEDI_URL), # ── Coagulation ────────────────────────────────────────────────────── - ("Coagulation", "PT", "11–14", "seconds", "Child", "Plasma", PEDI, PEDI_URL), - ("Coagulation", "aPTT", "25–35", "seconds", "Child", "Plasma", PEDI, PEDI_URL), - ("Coagulation", "INR", "0.9–1.2", "", "Child", "Plasma", PEDI, PEDI_URL), - ("Coagulation", "Fibrinogen", "150–400 (1.5–4.0 g/L)", "mg/dL", "Child", "Plasma", PEDI, PEDI_URL), - ("Coagulation", "D-dimer", "<0.5", "mg/L", "Child (assay-dependent)", "Plasma", PEDI, PEDI_URL), + ("Coagulation", "PT", "11–14", "seconds", "All ages", "Plasma", PEDI, PEDI_URL), + ("Coagulation", "aPTT", "25–35", "seconds", "All ages", "Plasma", PEDI, PEDI_URL), + ("Coagulation", "INR", "0.9–1.2", "", "All ages", "Plasma", PEDI, PEDI_URL), + ("Coagulation", "Fibrinogen", "150–400 (1.5–4.0 g/L)", "mg/dL", "All ages", "Plasma", PEDI, PEDI_URL), + ("Coagulation", "D-dimer", "<0.5", "mg/L", "All ages (assay-dependent)", "Plasma", PEDI, PEDI_URL), # ── Inflammatory ───────────────────────────────────────────────────── - ("Inflammatory", "CRP", "<10 (<1.0 mg/dL)", "mg/L", "Child", "Serum", PEDI, PEDI_URL), - ("Inflammatory", "Procalcitonin", "<0.5", "ng/mL", "Child", "Serum", PEDI, PEDI_URL), - ("Inflammatory", "ESR", "<15", "mm/h", "Child (varies)", "Blood", PEDI, PEDI_URL), + ("Inflammatory", "CRP", "<10 (<1.0 mg/dL)", "mg/L", "All ages", "Serum", PEDI, PEDI_URL), + ("Inflammatory", "Procalcitonin", "<0.5", "ng/mL", "All ages", "Serum", PEDI, PEDI_URL), + ("Inflammatory", "ESR", "<15", "mm/h", "All ages (varies)", "Blood", PEDI, PEDI_URL), # ── CSF ────────────────────────────────────────────────────────────── ("CSF", "CSF white cell count", "<5 (neonate up to ~20)", "/mm³", "Child vs neonate", "CSF", CSF_SRC, CSF_URL), ("CSF", "CSF protein", "15–40 (neonate up to ~100)", "mg/dL", "Child vs neonate", "CSF", CSF_SRC, CSF_URL), - ("CSF", "CSF glucose", "≥2/3 of plasma glucose", "", "Child", "CSF", CSF_SRC, CSF_URL), + ("CSF", "CSF glucose", "≥2/3 of plasma glucose", "", "All ages", "CSF", CSF_SRC, CSF_URL), # ── Endocrine ──────────────────────────────────────────────────────── - ("Endocrine", "TSH", "0.5–5.0", "mU/L", "Child (higher in neonate)", "Plasma", PEDI, PEDI_URL), - ("Endocrine", "Free T4", "12–22", "pmol/L", "Child", "Plasma", PEDI, PEDI_URL), - ("Endocrine", "Cortisol (08:00)", "170–500", "nmol/L", "Child", "Plasma", PEDI, PEDI_URL), + ("Endocrine", "TSH", "0.5–5.0", "mU/L", "All ages (higher in neonates)", "Plasma", PEDI, PEDI_URL), + ("Endocrine", "Free T4", "12–22", "pmol/L", "All ages", "Plasma", PEDI, PEDI_URL), + ("Endocrine", "Cortisol (08:00)", "170–500", "nmol/L", "All ages", "Plasma", PEDI, PEDI_URL), # ── Urine ──────────────────────────────────────────────────────────── - ("Urine", "Specific gravity", "1.001–1.035", "", "Child", "Urine", PEDI, PEDI_URL), + ("Urine", "Specific gravity", "1.001–1.035", "", "All ages", "Urine", PEDI, PEDI_URL), ] @@ -136,6 +136,13 @@ def main(): try: updated_by = db.query(User.id).filter(User.role.in_(["admin", "moderator"])).order_by(User.id).first() updated_by = updated_by[0] if updated_by else None + # Normalize vague population labels on existing rows to match the new seed data. + db.query(LabReference).filter(LabReference.age_group == "Child").update({"age_group": "All ages"}, synchronize_session=False) + db.query(LabReference).filter(LabReference.age_group == "Child (higher in infants)").update({"age_group": "All ages (higher in infants)"}, synchronize_session=False) + db.query(LabReference).filter(LabReference.age_group == "Child (higher in newborn)").update({"age_group": "All ages (higher in newborn)"}, synchronize_session=False) + db.query(LabReference).filter(LabReference.age_group == "Child (higher in neonate)").update({"age_group": "All ages (higher in neonates)"}, synchronize_session=False) + db.commit() + existing = {(row.group, row.name, row.age_group) for row in db.query(LabReference).all()} added = 0 for group, name, ref, units, age, specimen, source, url in ROWS: diff --git a/backend/scripts/tag_prep_quizzes.py b/backend/scripts/tag_prep_quizzes.py new file mode 100644 index 0000000..887c92a --- /dev/null +++ b/backend/scripts/tag_prep_quizzes.py @@ -0,0 +1,50 @@ +"""Tag questions by their PREP source quiz and retire any leftover PREP categories. + +Run after the tag→category conversion: PREP provenance moves from categories to +keyword tags named after the PREP quiz (e.g. 'PREP 2020'). +""" +import re +import sys + +from sqlalchemy import text + +from app.database import SessionLocal +from app.models.question_category import QuestionCategory +from app.models.quiz import Quiz + + +def main(): + db = SessionLocal() + try: + prep_quizzes = db.query(Quiz).filter(Quiz.title.ilike("%prep%")).all() + tagged = 0 + for quiz in prep_quizzes: + year = re.search(r"\b(19|20)\d{2}\b", quiz.title or "") + tag_name = f"PREP {year.group(0)}" if year else (quiz.title or f"PREP {quiz.id}").strip() + db.execute(text("INSERT INTO question_tags (name, type) VALUES (:name, 'keyword') " + "ON CONFLICT (LOWER(name), type) DO NOTHING"), {"name": tag_name}) + tag_id = db.execute(text("SELECT id FROM question_tags WHERE LOWER(name) = LOWER(:name) AND type = 'keyword'"), + {"name": tag_name}).scalar_one() + rows = db.execute(text(""" + SELECT id FROM questions WHERE quiz_id = :quiz_id + UNION + SELECT question_id FROM quiz_question_links WHERE quiz_id = :quiz_id + """), {"quiz_id": quiz.id}).all() + for row in rows: + db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) " + "ON CONFLICT DO NOTHING"), {"q": row[0], "t": tag_id}) + tagged += 1 + print(f"{tag_name}: {len(rows)} questions tagged") + # Retire any PREP categories that still exist. + leftovers = db.query(QuestionCategory).filter(QuestionCategory.name.ilike("prep %")).all() + for category in leftovers: + db.query(QuestionCategory).filter(QuestionCategory.id == category.id).delete() + db.commit() + print(f"Tagged {tagged} question-links across {len(prep_quizzes)} PREP quizzes; " + f"retired {len(leftovers)} leftover PREP categories.") + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/components/CommentSection.jsx b/frontend/src/components/CommentSection.jsx index d4f4127..7e94f69 100644 --- a/frontend/src/components/CommentSection.jsx +++ b/frontend/src/components/CommentSection.jsx @@ -47,48 +47,55 @@ export default function CommentSection({ articleId, questionId }) { try { const res = await api.patch(`/comments/${id}`, { status }) setComments(prev => prev.map(c => c.id === id ? res.data : c)) - } catch (err) { setError('Could not moderate comment') } + } catch { setError('Could not moderate comment') } } if (!loaded) return null return (
-

Comments {total > 0 && ({total})}

-
-