feat: real organ systems, symptoms grouped by system, and admin taxonomy CRUD
Systems were never systems
The 27 top-level rows were disciplines and care settings — Cardiology,
Emergency Medicine, Neonatology, and a stray condition (Sepsis) — not organ
systems. Cardiology is a discipline; Cardiovascular System is a system. So the
facet was mislabelled, and there was no organ-system axis at all.
Both fixes, as asked:
* that tree is now the "Topics" facet, which is what it always was;
* "Systems" is a new flat axis of 16 organ systems, matching how AMBOSS keeps
Systems flat while nesting Disciplines and Symptoms.
Tags can nest (migration e3f4a5b6c7d8)
`question_tags` gains parent_id and sort_order. A tag may sit under one of the
same kind (Surgery > Hand surgery) or under a system, which is how symptoms are
grouped by where they present. 726 symptoms are now filed under the system they
appear in; the remaining 3,536 stay top-level rather than being forced into an
approximate bucket. A false positive the dry run caught: "vision" was matching
"Health Supervision" — the same trap as erythema/erythematosus earlier, fixed
with a word boundary.
Admin can grow the taxonomy without a migration
POST /tags creates a top-level entry or a child; PATCH renames, reorders and
reparents, refusing a cycle; DELETE reparents children to the deleted tag's
parent rather than orphaning them, and can move its questions elsewhere;
POST /tags/{id}/questions attaches questions. Everything appears in every picker
immediately, because they all read the same endpoint.
Article sections were indexed but empty — `_rebuild_section_index` only runs on
save, so articles written before it existed had no rows. Backfilled: 10 articles,
28 sections, now embedded and searchable. Section-scoped question links already
worked (7 of 34 links name a section).
Tests: 10 new backend covering the tree shape, adding top-level and child
entries, kind rules, duplicate refusal, cycle refusal, rename/reparent, question
attachment, delete-reparents-children, delete-with-move, and the moderator gate.
141 backend, 136 frontend green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
This commit is contained in:
parent
04310c8980
commit
d509d3db52
10 changed files with 575 additions and 26 deletions
61
backend/alembic/versions/e3f4a5b6c7d8_tag_hierarchy.py
Normal file
61
backend/alembic/versions/e3f4a5b6c7d8_tag_hierarchy.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Let tags nest, and add organ systems as their own type.
|
||||
|
||||
AMBOSS keeps Systems flat (organ systems) and nests Disciplines and Symptoms
|
||||
("Head and neck > Eye > Blindness"). Our tag table was flat for all three, so
|
||||
symptoms could not be grouped by body region and there was no organ-system axis
|
||||
at all — what the UI called "Systems" was really the discipline tree.
|
||||
|
||||
Revision ID: e3f4a5b6c7d8
|
||||
Revises: d2e3f4a5b6c7
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "e3f4a5b6c7d8"
|
||||
down_revision = "d2e3f4a5b6c7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# The organ systems, plus the non-anatomical buckets an exam bank needs.
|
||||
SYSTEMS = [
|
||||
"Cardiovascular System",
|
||||
"Respiratory System",
|
||||
"Gastrointestinal System",
|
||||
"Nervous System & Special Senses",
|
||||
"Endocrine System",
|
||||
"Musculoskeletal System",
|
||||
"Skin & Subcutaneous Tissue",
|
||||
"Blood & Lymphoreticular System",
|
||||
"Immune System",
|
||||
"Renal & Urinary System",
|
||||
"Reproductive System",
|
||||
"Behavioral Health",
|
||||
"Human Development",
|
||||
"Multisystem Processes & Disorders",
|
||||
"Biostatistics & Epidemiology",
|
||||
"Social Sciences & Ethics",
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("""
|
||||
ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS parent_id INTEGER
|
||||
REFERENCES question_tags(id) ON DELETE SET NULL
|
||||
""")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_question_tags_parent ON question_tags(parent_id)")
|
||||
op.execute("ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 100")
|
||||
|
||||
for index, name in enumerate(SYSTEMS):
|
||||
op.execute(f"""
|
||||
INSERT INTO question_tags (name, type, sort_order)
|
||||
SELECT '{name.replace("'", "''")}', 'system', {index * 10}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM question_tags WHERE type = 'system' AND lower(name) = lower('{name.replace("'", "''")}')
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DELETE FROM question_tags WHERE type = 'system'")
|
||||
op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS sort_order")
|
||||
op.execute("DROP INDEX IF EXISTS ix_question_tags_parent")
|
||||
op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS parent_id")
|
||||
|
|
@ -4,7 +4,10 @@ Tables are created via raw SQL in setup_pgvector() to avoid race conditions with
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -24,22 +27,32 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur
|
|||
# cannot match anything they are studying.
|
||||
exam_id = getattr(current_user, "active_exam_id", None)
|
||||
rows = db.execute(text("""
|
||||
SELECT t.id, t.name, t.type, COUNT(DISTINCT tl.question_id) AS count
|
||||
SELECT t.id, t.name, t.type, t.parent_id, t.sort_order,
|
||||
COUNT(DISTINCT tl.question_id) AS count
|
||||
FROM question_tags t
|
||||
LEFT JOIN question_tag_links tl ON tl.tag_id = t.id
|
||||
LEFT JOIN question_exam_links el
|
||||
ON el.question_id = tl.question_id AND (:exam_id IS NULL OR el.exam_id = :exam_id)
|
||||
WHERE (t.exam_id IS NULL OR :exam_id IS NULL OR t.exam_id = :exam_id)
|
||||
AND (:exam_id IS NULL OR tl.question_id IS NULL OR el.question_id IS NOT NULL)
|
||||
GROUP BY t.id, t.name, t.type
|
||||
ORDER BY count DESC, t.name
|
||||
GROUP BY t.id, t.name, t.type, t.parent_id, t.sort_order
|
||||
ORDER BY t.sort_order, count DESC, t.name
|
||||
"""), {"exam_id": exam_id}).fetchall()
|
||||
|
||||
result = {"subjects": [], "diseases": [], "keywords": []}
|
||||
result = {"subjects": [], "diseases": [], "keywords": [], "systems": []}
|
||||
mapping = {"subject": "subjects", "disease": "diseases",
|
||||
"keyword": "keywords", "system": "systems"}
|
||||
# A parent with no direct questions still has to appear, or its children
|
||||
# become unreachable in a tree picker.
|
||||
has_children = {r.parent_id for r in rows if r.parent_id}
|
||||
for r in rows:
|
||||
key = {"subject": "subjects", "disease": "diseases", "keyword": "keywords"}.get(r.type)
|
||||
if key and (r.count > 0 or not exam_id):
|
||||
result[key].append({"id": r.id, "name": r.name, "count": r.count})
|
||||
key = mapping.get(r.type)
|
||||
if not key:
|
||||
continue
|
||||
if exam_id and r.count == 0 and r.id not in has_children:
|
||||
continue
|
||||
result[key].append({"id": r.id, "name": r.name, "count": r.count,
|
||||
"parent_id": r.parent_id})
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -73,3 +86,139 @@ def get_classification_status(
|
|||
error = r.get(f"classify:error:{job_id}")
|
||||
|
||||
return {"job_id": job_id, "status": status, "steps": steps, "error": error}
|
||||
|
||||
class TagWrite(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
type: Literal["subject", "disease", "keyword", "system"]
|
||||
parent_id: int | None = None
|
||||
sort_order: int = 100
|
||||
|
||||
|
||||
def _descendants(db: Session, tag_id: int) -> set[int]:
|
||||
"""Every tag beneath this one, so a cycle can be refused."""
|
||||
children: dict[int, list[int]] = {}
|
||||
for tid, parent in db.execute(text(
|
||||
"SELECT id, parent_id FROM question_tags WHERE parent_id IS NOT NULL")).fetchall():
|
||||
children.setdefault(parent, []).append(tid)
|
||||
seen, stack = set(), [tag_id]
|
||||
while stack:
|
||||
for child in children.get(stack.pop(), ()):
|
||||
if child not in seen:
|
||||
seen.add(child)
|
||||
stack.append(child)
|
||||
return seen
|
||||
|
||||
|
||||
def _validate_parent(db: Session, tag_id: int | None, parent_id: int | None, tag_type: str) -> None:
|
||||
if parent_id is None:
|
||||
return
|
||||
parent = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
|
||||
{"i": parent_id}).first()
|
||||
if not parent:
|
||||
raise HTTPException(404, "Parent tag not found")
|
||||
# Same kind nests (Surgery > Hand surgery), and anything may sit under a
|
||||
# system, which is how symptoms are grouped by where they present.
|
||||
if parent.type != tag_type and parent.type != "system":
|
||||
raise HTTPException(400, "A tag can only sit under one of the same kind, or under a system")
|
||||
if tag_id is not None and (parent_id == tag_id or parent_id in _descendants(db, tag_id)):
|
||||
raise HTTPException(400, "A tag cannot be its own parent or a descendant's child")
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
def create_tag(data: TagWrite, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator)):
|
||||
"""Add a top-level entry or a child. New tags appear in every picker at once."""
|
||||
_validate_parent(db, None, data.parent_id, data.type)
|
||||
clash = db.execute(text("""
|
||||
SELECT id FROM question_tags
|
||||
WHERE type = :t AND lower(name) = lower(:n)
|
||||
AND COALESCE(parent_id, 0) = COALESCE(:p, 0)
|
||||
"""), {"t": data.type, "n": data.name.strip(), "p": data.parent_id}).first()
|
||||
if clash:
|
||||
raise HTTPException(409, "That name already exists in the same place")
|
||||
tag_id = db.execute(text("""
|
||||
INSERT INTO question_tags (name, type, parent_id, sort_order)
|
||||
VALUES (:n, :t, :p, :s) RETURNING id
|
||||
"""), {"n": data.name.strip(), "t": data.type,
|
||||
"p": data.parent_id, "s": data.sort_order}).scalar()
|
||||
db.commit()
|
||||
return {"id": tag_id, "name": data.name.strip(), "type": data.type,
|
||||
"parent_id": data.parent_id, "count": 0}
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
parent_id: int | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
@router.patch("/{tag_id}")
|
||||
def update_tag(tag_id: int, data: TagUpdate, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator)):
|
||||
"""Rename, reorder, or move a tag under a different parent."""
|
||||
tag = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
|
||||
{"i": tag_id}).first()
|
||||
if not tag:
|
||||
raise HTTPException(404, "Tag not found")
|
||||
values = data.model_dump(exclude_unset=True)
|
||||
if "parent_id" in values:
|
||||
_validate_parent(db, tag_id, values["parent_id"], tag.type)
|
||||
db.execute(text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
|
||||
{"p": values["parent_id"], "i": tag_id})
|
||||
if values.get("name"):
|
||||
db.execute(text("UPDATE question_tags SET name = :n WHERE id = :i"),
|
||||
{"n": values["name"].strip(), "i": tag_id})
|
||||
if values.get("sort_order") is not None:
|
||||
db.execute(text("UPDATE question_tags SET sort_order = :s WHERE id = :i"),
|
||||
{"s": values["sort_order"], "i": tag_id})
|
||||
db.commit()
|
||||
return {"id": tag_id}
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", status_code=204)
|
||||
def delete_tag(tag_id: int, move_to: int | None = None, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator)):
|
||||
"""Delete a tag, optionally moving its questions and children onto another."""
|
||||
tag = db.execute(text("SELECT id, parent_id FROM question_tags WHERE id = :i"),
|
||||
{"i": tag_id}).first()
|
||||
if not tag:
|
||||
raise HTTPException(404, "Tag not found")
|
||||
if move_to is not None:
|
||||
if move_to == tag_id or not db.execute(text(
|
||||
"SELECT id FROM question_tags WHERE id = :i"), {"i": move_to}).first():
|
||||
raise HTTPException(400, "Invalid destination tag")
|
||||
# Skip links that would duplicate an existing pair.
|
||||
db.execute(text("""
|
||||
UPDATE question_tag_links SET tag_id = :d
|
||||
WHERE tag_id = :s AND question_id NOT IN (
|
||||
SELECT question_id FROM question_tag_links WHERE tag_id = :d)
|
||||
"""), {"d": move_to, "s": tag_id})
|
||||
# Children rise to this tag's parent rather than being orphaned.
|
||||
db.execute(text("UPDATE question_tags SET parent_id = :p WHERE parent_id = :i"),
|
||||
{"p": tag.parent_id, "i": tag_id})
|
||||
db.execute(text("DELETE FROM question_tag_links WHERE tag_id = :i"), {"i": tag_id})
|
||||
db.execute(text("DELETE FROM question_tags WHERE id = :i"), {"i": tag_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
class TagQuestions(BaseModel):
|
||||
question_ids: list[int] = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
@router.post("/{tag_id}/questions")
|
||||
def add_questions_to_tag(tag_id: int, data: TagQuestions, db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_moderator)):
|
||||
"""Attach questions to a tag; they show in every picker using it immediately."""
|
||||
if not db.execute(text("SELECT id FROM question_tags WHERE id = :i"), {"i": tag_id}).first():
|
||||
raise HTTPException(404, "Tag not found")
|
||||
added = 0
|
||||
for question_id in dict.fromkeys(data.question_ids):
|
||||
result = db.execute(text("""
|
||||
INSERT INTO question_tag_links (question_id, tag_id)
|
||||
SELECT :q, :t WHERE EXISTS (SELECT 1 FROM questions WHERE id = :q)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM question_tag_links WHERE question_id = :q AND tag_id = :t)
|
||||
"""), {"q": question_id, "t": tag_id})
|
||||
added += result.rowcount or 0
|
||||
db.commit()
|
||||
return {"tag_id": tag_id, "added": added}
|
||||
|
|
|
|||
37
backend/scripts/backfill_section_index.py
Normal file
37
backend/scripts/backfill_section_index.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Project existing article sections into the section index.
|
||||
|
||||
`_rebuild_section_index` runs when an article is saved, so articles written
|
||||
before that existed have no rows — the index was empty and section-level
|
||||
retrieval had nothing to search.
|
||||
|
||||
docker compose exec backend python -m scripts.backfill_section_index
|
||||
docker compose exec backend python -m scripts.backfill_section_index --apply
|
||||
"""
|
||||
import sys
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.article import Article
|
||||
from app.routers.articles import _rebuild_section_index
|
||||
|
||||
|
||||
def main():
|
||||
apply_changes = "--apply" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
articles = db.query(Article).all()
|
||||
sections = sum(len(a.sections or []) for a in articles)
|
||||
print(f" articles : {len(articles)}")
|
||||
print(f" sections : {sections}")
|
||||
if apply_changes:
|
||||
for article in articles:
|
||||
_rebuild_section_index(db, article)
|
||||
db.commit()
|
||||
print(" indexed and embedded.")
|
||||
else:
|
||||
print("\n Re-run with --apply to build the index.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
125
backend/scripts/nest_symptoms_by_system.py
Normal file
125
backend/scripts/nest_symptoms_by_system.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Group symptom tags under the organ system they present in.
|
||||
|
||||
Symptoms were a flat list of ~4,300 keywords, so the picker was an alphabetical
|
||||
wall. AMBOSS groups them by body region — "Head and neck > Eye > Blindness" — and
|
||||
that is what makes the list navigable.
|
||||
|
||||
Only the symptoms that match a system's pattern are moved. Everything else stays
|
||||
top-level rather than being forced into an approximate bucket, and nothing is
|
||||
renamed, merged or deleted: this only sets a parent.
|
||||
|
||||
docker compose exec backend python -m scripts.nest_symptoms_by_system
|
||||
docker compose exec backend python -m scripts.nest_symptoms_by_system --apply
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.database import SessionLocal
|
||||
|
||||
# Only worth a branch if several symptoms land in it.
|
||||
MIN_CHILDREN = 3
|
||||
|
||||
# Organ system -> what a symptom in it looks like. Ordered: the first match wins,
|
||||
# so put the specific patterns above the general ones.
|
||||
SYSTEM_PATTERNS = [
|
||||
("Nervous System & Special Senses",
|
||||
r"seizure|headache|migraine|ataxia|paralysis|palsy|neuropath|hypotoni|hypertoni|"
|
||||
r"tremor|dizz|vertigo|syncope|coma|conscious|meningism|blind|\bvision\b|visual|"
|
||||
r"strabismus|nystagmus|deaf|hearing|tinnitus|aphasia|dysarthr|weakness|numbness"),
|
||||
("Cardiovascular System",
|
||||
r"murmur|palpitation|chest pain|cyanosis|hypertens|hypotens|tachycardi|bradycardi|"
|
||||
r"edema|oedema|shock|syncope|pulse|perfusion"),
|
||||
("Respiratory System",
|
||||
r"cough|wheez|stridor|dyspnea|dyspnoea|tachypnea|apnea|apnoea|respiratory distress|"
|
||||
r"hemoptysis|haemoptysis|hypox|retraction|grunting"),
|
||||
("Gastrointestinal System",
|
||||
r"vomit|diarrhea|diarrhoea|constipation|abdominal pain|abdominal distention|"
|
||||
r"abdominal distension|jaundice|hematemesis|melena|dysphagia|reflux|colic|"
|
||||
r"hepatomegal|splenomegal|ascites|encopresis"),
|
||||
("Renal & Urinary System",
|
||||
r"hematuria|haematuria|proteinuria|dysuria|oliguria|polyuria|enuresis|"
|
||||
r"urinary frequency|urinary retention|flank pain"),
|
||||
("Musculoskeletal System",
|
||||
r"limp|joint pain|arthralgia|arthritis|back pain|scoliosis|fracture|"
|
||||
r"limb pain|gait|swelling of the joint|muscle pain|myalgia"),
|
||||
("Skin & Subcutaneous Tissue",
|
||||
r"rash|pruritus|itch|urticaria|petechia|purpura|blister|vesicle|pustule|"
|
||||
r"alopecia|hair loss|pallor|erythema|lesion"),
|
||||
("Blood & Lymphoreticular System",
|
||||
r"anemia|anaemia|bleeding|bruis|lymphadenopath|pallor|petechia|thrombocytopen"),
|
||||
("Endocrine System",
|
||||
r"polydipsia|polyuria|weight loss|weight gain|obesity|short stature|tall stature|"
|
||||
r"precocious|delayed puberty|goiter|goitre|hypoglycem|hyperglycem"),
|
||||
("Behavioral Health",
|
||||
r"anxiety|depress|irritab|hyperactiv|inattent|aggress|self.harm|suicid|"
|
||||
r"insomnia|sleep disturbance|tantrum|behaviou?r"),
|
||||
("Immune System",
|
||||
r"allerg|anaphylax|recurrent infection|immunodefic|angioedema"),
|
||||
("Human Development",
|
||||
r"developmental delay|failure to thrive|growth failure|milestone|regression|"
|
||||
r"feeding difficult|poor feeding"),
|
||||
("Multisystem Processes & Disorders",
|
||||
r"\bfever\b|fatigue|lethargy|malaise|sepsis|weight loss|dehydration|"
|
||||
r"failure to thrive|night sweats"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
apply_changes = "--apply" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
systems = {row[1].lower(): row[0] for row in db.execute(sa_text(
|
||||
"SELECT id, name FROM question_tags WHERE type = 'system'")).fetchall()}
|
||||
if not systems:
|
||||
print(" No 'system' tags yet; run the migration first.")
|
||||
return 1
|
||||
|
||||
symptoms = db.execute(sa_text("""
|
||||
SELECT t.id, t.name, COUNT(l.question_id) AS uses
|
||||
FROM question_tags t
|
||||
LEFT JOIN question_tag_links l ON l.tag_id = t.id
|
||||
WHERE t.type = 'keyword' AND t.parent_id IS NULL
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY uses DESC, t.name
|
||||
""")).fetchall()
|
||||
print(f" ungrouped symptoms: {len(symptoms)}")
|
||||
|
||||
compiled = [(name, re.compile(pattern, re.I)) for name, pattern in SYSTEM_PATTERNS]
|
||||
planned: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
|
||||
for tag_id, name, uses in symptoms:
|
||||
for system_name, matcher in compiled:
|
||||
if system_name.lower() in systems and matcher.search(name):
|
||||
planned[system_name].append((tag_id, name, uses))
|
||||
break
|
||||
|
||||
# A branch holding one or two entries is noise, not navigation.
|
||||
planned = {k: v for k, v in planned.items() if len(v) >= MIN_CHILDREN}
|
||||
|
||||
if apply_changes:
|
||||
for system_name, members in planned.items():
|
||||
parent = systems[system_name.lower()]
|
||||
for tag_id, _name, _uses in members:
|
||||
db.execute(sa_text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
|
||||
{"p": parent, "i": tag_id})
|
||||
db.commit()
|
||||
|
||||
moved = sum(len(v) for v in planned.values())
|
||||
print("APPLIED" if apply_changes else "DRY RUN")
|
||||
print(f" grouped under a system : {moved}")
|
||||
print(f" left top-level : {len(symptoms) - moved}\n")
|
||||
for system_name in sorted(planned, key=lambda k: -len(planned[k])):
|
||||
members = planned[system_name]
|
||||
shown = ", ".join(name for _i, name, _u in members[:4])
|
||||
more = f" … +{len(members) - 4}" if len(members) > 4 else ""
|
||||
print(f" {system_name} ({len(members)}): {shown}{more}")
|
||||
if not apply_changes:
|
||||
print("\n Re-run with --apply to group them.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -25,7 +25,8 @@ from app.utils.auth import get_current_user
|
|||
TAG_DDL = """
|
||||
CREATE TABLE question_tags (
|
||||
id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL, exam_id INTEGER, created_at TIMESTAMP
|
||||
type VARCHAR(50) NOT NULL, exam_id INTEGER, parent_id INTEGER,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100, created_at TIMESTAMP
|
||||
);
|
||||
CREATE TABLE question_tag_links (
|
||||
id INTEGER PRIMARY KEY, question_id INTEGER NOT NULL, tag_id INTEGER NOT NULL
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from app.routers import media
|
|||
from app.utils.auth import get_current_user
|
||||
|
||||
TAG_DDL = """
|
||||
CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, created_at TIMESTAMP);
|
||||
CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, parent_id INTEGER, sort_order INTEGER DEFAULT 100, created_at TIMESTAMP);
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
147
backend/tests/test_tag_hierarchy.py
Normal file
147
backend/tests/test_tag_hierarchy.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Tags nest, and a moderator can grow the taxonomy without a migration.
|
||||
|
||||
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
||||
"""
|
||||
import os
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
|
||||
from app.models.media import MediaAsset # noqa — health report walks every embeddable table.
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.routers import tags
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
DDL = """
|
||||
CREATE TABLE question_tags (
|
||||
id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL, type VARCHAR(50) NOT NULL,
|
||||
exam_id INTEGER, parent_id INTEGER, sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
CREATE TABLE question_tag_links (id INTEGER PRIMARY KEY, question_id INTEGER, tag_id INTEGER);
|
||||
"""
|
||||
|
||||
|
||||
class TagHierarchyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
for statement in DDL.strip().split(";"):
|
||||
if statement.strip():
|
||||
self.db.execute(text(statement))
|
||||
|
||||
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
|
||||
self.learner = User(id=2, name="Learner", email="l@example.test", hashed_password="unused")
|
||||
self.db.add_all([self.mod, self.learner])
|
||||
for qid in (1, 2, 3):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Q{qid}",
|
||||
question_type="mcq", options=["a", "b"], correct_answer="a"))
|
||||
self.db.execute(text("""
|
||||
INSERT INTO question_tags (id, name, type, parent_id, sort_order) VALUES
|
||||
(10, 'Nervous System & Special Senses', 'system', NULL, 0),
|
||||
(11, 'Seizure', 'keyword', 10, 100),
|
||||
(12, 'Headache', 'keyword', 10, 100),
|
||||
(13, 'Cardiology', 'subject', NULL, 100)
|
||||
"""))
|
||||
self.db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (1, 11), (2, 11)"))
|
||||
self.db.commit()
|
||||
|
||||
self.user = self.mod
|
||||
app = FastAPI()
|
||||
app.include_router(tags.router, prefix="/tags")
|
||||
app.dependency_overrides[get_db] = lambda: self.db
|
||||
app.dependency_overrides[get_current_user] = lambda: self.user
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_the_tree_is_returned_with_parents(self):
|
||||
body = self.client.get("/tags").json()
|
||||
systems = {t["name"]: t for t in body["systems"]}
|
||||
self.assertIn("Nervous System & Special Senses", systems)
|
||||
keywords = {t["name"]: t["parent_id"] for t in body["keywords"]}
|
||||
# Symptoms hang off the system they present in.
|
||||
self.assertEqual(keywords["Seizure"], 10)
|
||||
self.assertEqual(keywords["Headache"], 10)
|
||||
|
||||
def test_a_moderator_can_add_a_top_level_entry_and_a_child(self):
|
||||
top = self.client.post("/tags/", json={"name": "Respiratory System", "type": "system"})
|
||||
self.assertEqual(top.status_code, 201, top.text)
|
||||
parent_id = top.json()["id"]
|
||||
|
||||
child = self.client.post("/tags/", json={"name": "Wheeze", "type": "keyword",
|
||||
"parent_id": parent_id})
|
||||
self.assertEqual(child.status_code, 201, child.text)
|
||||
# It shows up immediately, which is what every picker reads.
|
||||
body = self.client.get("/tags").json()
|
||||
self.assertIn("Wheeze", [t["name"] for t in body["keywords"]])
|
||||
|
||||
def test_a_child_must_match_its_parents_kind(self):
|
||||
response = self.client.post("/tags/", json={"name": "Nonsense", "type": "keyword",
|
||||
"parent_id": 13})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_duplicate_names_in_the_same_place_are_refused(self):
|
||||
self.assertEqual(self.client.post("/tags/", json={
|
||||
"name": "Seizure", "type": "keyword", "parent_id": 10}).status_code, 409)
|
||||
# The same name under a different parent is fine.
|
||||
self.assertEqual(self.client.post("/tags/", json={
|
||||
"name": "Seizure", "type": "keyword"}).status_code, 201)
|
||||
|
||||
def test_a_tag_cannot_become_its_own_descendants_child(self):
|
||||
self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 11}).status_code, 400)
|
||||
self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 10}).status_code, 400)
|
||||
|
||||
def test_renaming_and_reparenting(self):
|
||||
self.assertEqual(self.client.patch("/tags/12", json={"name": "Head pain"}).status_code, 200)
|
||||
self.assertEqual(self.db.execute(text(
|
||||
"SELECT name FROM question_tags WHERE id = 12")).scalar(), "Head pain")
|
||||
self.client.patch("/tags/12", json={"parent_id": None})
|
||||
self.assertIsNone(self.db.execute(text(
|
||||
"SELECT parent_id FROM question_tags WHERE id = 12")).scalar())
|
||||
|
||||
def test_questions_attached_to_a_tag_appear_in_its_count(self):
|
||||
response = self.client.post("/tags/12/questions", json={"question_ids": [1, 2, 3]})
|
||||
self.assertEqual(response.json()["added"], 3)
|
||||
counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
|
||||
self.assertEqual(counts["Headache"], 3)
|
||||
# Adding the same ones again is a no-op rather than a duplicate.
|
||||
self.assertEqual(self.client.post("/tags/12/questions",
|
||||
json={"question_ids": [1, 2]}).json()["added"], 0)
|
||||
|
||||
def test_deleting_reparents_children_rather_than_orphaning_them(self):
|
||||
self.client.delete("/tags/10")
|
||||
# Seizure and Headache were under it; they rise to top level, not nowhere.
|
||||
rows = self.db.execute(text(
|
||||
"SELECT parent_id FROM question_tags WHERE id IN (11, 12)")).fetchall()
|
||||
self.assertEqual([r[0] for r in rows], [None, None])
|
||||
|
||||
def test_deleting_can_move_its_questions_onto_another_tag(self):
|
||||
self.client.delete("/tags/11", params={"move_to": 12})
|
||||
counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
|
||||
self.assertEqual(counts["Headache"], 2)
|
||||
|
||||
def test_editing_the_taxonomy_is_moderator_only(self):
|
||||
self.user = self.learner
|
||||
self.assertEqual(self.client.post("/tags/", json={"name": "X", "type": "keyword"}).status_code, 403)
|
||||
self.assertEqual(self.client.patch("/tags/11", json={"name": "X"}).status_code, 403)
|
||||
self.assertEqual(self.client.delete("/tags/11").status_code, 403)
|
||||
self.assertEqual(self.client.post("/tags/11/questions",
|
||||
json={"question_ids": [1]}).status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -130,9 +130,11 @@ export default function CustomQuizPage() {
|
|||
const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
|
||||
const subjectTags = tags.subjects || []
|
||||
const keywordTags = tags.keywords || []
|
||||
const tagName = (id) => [...subjectTags, ...keywordTags].find(t => t.id === id)?.name
|
||||
const tagName = (id) => [...subjectTags, ...keywordTags, ...(tags.systems || [])].find(t => t.id === id)?.name
|
||||
|
||||
const systemTags = tags.systems || []
|
||||
const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean))
|
||||
const organSystems = summarise(tagIds.map(tagName).filter(n => n && systemTags.some(t => t.name === n)))
|
||||
const disciplines = summarise(tagIds.map(tagName).filter(n => n && subjectTags.some(t => t.name === n)))
|
||||
const symptoms = summarise(tagIds.map(tagName).filter(n => n && keywordTags.some(t => t.name === n)))
|
||||
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
|
||||
|
|
@ -260,7 +262,8 @@ export default function CustomQuizPage() {
|
|||
<span className="facet-row-label">Exams</span>
|
||||
<span className="facet-row-summary"><span className="facet-chip">Pediatrics Boards</span></span>
|
||||
</div>
|
||||
<FacetRow label="Systems" {...systems} onOpen={() => setOpenFacet('systems')} />
|
||||
<FacetRow label="Systems" {...organSystems} onOpen={() => setOpenFacet('organ-systems')} />
|
||||
<FacetRow label="Topics" {...systems} onOpen={() => setOpenFacet('systems')} />
|
||||
<FacetRow label="Disciplines" {...disciplines} onOpen={() => setOpenFacet('disciplines')} />
|
||||
<FacetRow label="Symptoms" {...symptoms} onOpen={() => setOpenFacet('symptoms')} />
|
||||
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
|
||||
|
|
@ -372,7 +375,24 @@ export default function CustomQuizPage() {
|
|||
{error && <p className="custom-test-error" role="alert">{error}</p>}
|
||||
</form>
|
||||
{/* ── Facet pickers ────────────────────────────────────────── */}
|
||||
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
<FacetPicker title="Systems" open={openFacet === 'organ-systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
|
||||
helper="Body systems. Flat by nature — there are only so many.">
|
||||
{query => {
|
||||
const shown = systemTags.filter(t => !query || t.name.toLowerCase().includes(query))
|
||||
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
|
||||
return shown.map(tag => (
|
||||
<label key={tag.id}>
|
||||
<input type="checkbox" checked={tagIds.includes(tag.id)}
|
||||
onChange={e => toggleTag(tag.id, e.target.checked)} />
|
||||
{tag.name}
|
||||
<span className="facet-panel-count">{tag.count}</span>
|
||||
</label>
|
||||
))
|
||||
}}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Topics" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setCategoryIds([])}
|
||||
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
|
||||
{query => (
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ describe('CustomQuizPage', () => {
|
|||
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
|
||||
|
||||
// Systems and Status are chosen inside their facet pickers, not inline.
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /Neonatal/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
|
|
@ -103,32 +103,32 @@ describe('CustomQuizPage', () => {
|
|||
|
||||
it('summarises each facet as All, a name, or a name with +N', async () => {
|
||||
renderBuilder()
|
||||
const systems = await screen.findByRole('button', { name: /^Systems/ })
|
||||
const systems = await screen.findByRole('button', { name: /^Topics/ })
|
||||
expect(systems).toHaveTextContent('All')
|
||||
|
||||
await userEvent.click(systems)
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
|
||||
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
|
||||
expect(screen.queryByText('+1')).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /Neonatal/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('+1')
|
||||
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('+1')
|
||||
})
|
||||
|
||||
it('closes a picker without losing the selection and resets it on demand', async () => {
|
||||
renderBuilder()
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close Systems' }))
|
||||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close Topics' }))
|
||||
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
|
||||
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
|
||||
})
|
||||
|
||||
it('searches across facets and toggles a hit in place', async () => {
|
||||
|
|
@ -153,11 +153,11 @@ describe('CustomQuizPage', () => {
|
|||
|
||||
it('resets every topic facet at once', async () => {
|
||||
renderBuilder()
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reset all topics' }))
|
||||
expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
|
||||
expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
|
||||
})
|
||||
|
||||
it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => {
|
||||
|
|
|
|||
|
|
@ -366,7 +366,9 @@ export default function QuestionBankPage() {
|
|||
const nameOf = (list, id) => list.find(t => t.id === id)?.name
|
||||
const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
|
||||
|
||||
const systemTags = tags.systems || []
|
||||
const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
|
||||
const organSystemsFacet = summarise(selectedIn(systemTags))
|
||||
const disciplinesFacet = summarise(selectedIn(subjectTags))
|
||||
const diseasesFacet = summarise(selectedIn(diseaseTags))
|
||||
const symptomsFacet = summarise(selectedIn(keywordTags))
|
||||
|
|
@ -728,7 +730,8 @@ export default function QuestionBankPage() {
|
|||
onOpen={() => setOpenFacet('status')} />
|
||||
<FacetRow label="Difficulty" summary={DIFFICULTY_LABEL[difficulty]} extra={0}
|
||||
chip={!!difficulty} onOpen={() => setOpenFacet('difficulty')} />
|
||||
<FacetRow label="Systems" {...systemsFacet} onOpen={() => setOpenFacet('systems')} />
|
||||
<FacetRow label="Systems" {...organSystemsFacet} onOpen={() => setOpenFacet('organ-systems')} />
|
||||
<FacetRow label="Topics" {...systemsFacet} onOpen={() => setOpenFacet('systems')} />
|
||||
<FacetRow label="Disciplines" {...disciplinesFacet} onOpen={() => setOpenFacet('disciplines')} />
|
||||
<FacetRow label="Diseases" {...diseasesFacet} onOpen={() => setOpenFacet('diseases')} />
|
||||
<FacetRow label="Symptoms" {...symptomsFacet} onOpen={() => setOpenFacet('symptoms')} />
|
||||
|
|
@ -907,7 +910,13 @@ export default function QuestionBankPage() {
|
|||
))}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
<FacetPicker title="Systems" open={openFacet === 'organ-systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setSelectedTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
|
||||
helper="Body systems. Flat by nature — there are only so many.">
|
||||
{query => tagChecklist(systemTags, query)}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Topics" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setFilterCatIds([])}
|
||||
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
|
||||
{query => (
|
||||
|
|
|
|||
Loading…
Reference in a new issue