diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py index 704435a..95e91eb 100644 --- a/backend/app/routers/question_categories.py +++ b/backend/app/routers/question_categories.py @@ -232,7 +232,33 @@ def delete_question_category(cat_id: int, move_to: int | None = None, db: Sessio if any(c.parent_id == cat_id for c in cats): raise HTTPException(400, "Move child categories before deleting this category") validate_parent(cats, cat_id, move_to) - db.query(Question).filter(Question.question_category_id == cat_id).update({"question_category_id": move_to}) + + # Where the questions go has to be said. Deleting a topic used to leave + # them filed nowhere: no discipline, no organ system, no relevance, no row + # on any tab of the analysis — in the bank and invisible to every page that + # counts them. An empty topic can go without being asked. + held = db.query(Question).filter(Question.question_category_id == cat_id).count() + linked = db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id).count() + if (held or linked) and move_to is None: + raise HTTPException(400, ( + f"{held or linked} question{'' if (held or linked) == 1 else 's'} are filed here. " + "Choose a topic to move them to before deleting this one.")) + + db.query(Question).filter(Question.question_category_id == cat_id).update( + {"question_category_id": move_to}) + if move_to is not None: + # The extra links go too, minus any that would duplicate a pair the + # destination already has. Written as bulk statements so they land + # before the category is deleted out from under them. + existing = select(QuestionCategoryLink.question_id).where( + QuestionCategoryLink.category_id == move_to) + db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id, + QuestionCategoryLink.question_id.in_(existing)).delete(synchronize_session=False) + db.query(QuestionCategoryLink).filter( + QuestionCategoryLink.category_id == cat_id).update( + {"category_id": move_to}, synchronize_session=False) db.delete(cat) db.commit() diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index dca4ca0..cd41a30 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -2,7 +2,6 @@ Tables are created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers. """ import logging -import uuid from typing import Literal @@ -56,37 +55,6 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur return result -@router.post("/classify") -def trigger_classification( - db: Session = Depends(get_db), - current_user: User = Depends(require_moderator), -): - """Admin/moderator: trigger background classification of untagged questions.""" - from app.tasks.quiz_tasks import classify_questions - - job_id = str(uuid.uuid4()) - classify_questions.delay(job_id, current_user.id) - return {"job_id": job_id, "status": "started"} - - -@router.get("/status/{job_id}") -def get_classification_status( - job_id: str, - current_user: User = Depends(get_current_user), -): - """Check classification job progress.""" - import json - import redis - from app.config import settings - - r = redis.from_url(settings.REDIS_URL, decode_responses=True) - status = r.get(f"classify:status:{job_id}") or "unknown" - steps_raw = r.lrange(f"classify:steps:{job_id}", 0, -1) - steps = [json.loads(s) for s in steps_raw] if steps_raw else [] - 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"] @@ -145,7 +113,6 @@ def create_tag(data: TagWrite, db: Session = Depends(get_db), 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 diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py index c92d7fc..8654f24 100644 --- a/backend/app/services/quiz_builder.py +++ b/backend/app/services/quiz_builder.py @@ -112,20 +112,27 @@ def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, di else: query = query.filter(Question.id.is_(None)) # No questions match all tags. if system_ids: - from sqlalchemy import inspect as sa_inspect - from sqlalchemy import text as sa_text - if not sa_inspect(db.get_bind()).has_table("question_tags"): + # A question is never filed under an organ system directly — the topic + # it sits under carries one. It used to reach a system through a + # symptom keyword the question happened to mention, and only about half + # of them mentioned one that had been filed. + # + # ANY of these systems, unlike tag_ids above, which is an AND: asking + # for cardiovascular and respiratory means either, because no question + # is both. + wanted = list(dict.fromkeys(system_ids)) + named = [cid for (cid,) in db.query(QuestionCategory.id).filter( + QuestionCategory.system_id.in_(wanted)).all()] + if not named: return query.filter(Question.id.is_(None)) - # A question is never tagged with an organ system directly — it carries - # a symptom keyword that is filed under one. So this matches ANY tag - # beneath the system, unlike tag_ids above, which is an AND across tags. - matching = list(db.execute(sa_text(""" - SELECT DISTINCT l.question_id - FROM question_tag_links l - JOIN question_tags t ON t.id = l.tag_id - WHERE t.parent_id = ANY(:system_ids) OR t.id = ANY(:system_ids) - """), {"system_ids": list(dict.fromkeys(system_ids))}).scalars()) - query = query.filter(Question.id.in_(matching)) if matching else query.filter(Question.id.is_(None)) + # And everything beneath them: a subtopic with no system of its own + # belongs to the system of the topic above it, which is the same rule + # the analysis groups by. + topics = category_descendants(db.query(QuestionCategory).all(), named) + query = query.filter(or_( + Question.question_category_id.in_(topics), + Question.id.in_(select(QuestionCategoryLink.question_id).where( + QuestionCategoryLink.category_id.in_(topics))))) if category_ids: ids = category_descendants(db.query(QuestionCategory).all(), category_ids) query = query.filter(or_( diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 552fec7..a0e275d 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -351,201 +351,6 @@ def extract_quiz( db.close() -CLASSIFY_EXPIRE = 3600 - - -def _push_classify_step(r, job_id: str, step: str, message: str): - key = f"classify:steps:{job_id}" - entry = json.dumps({"step": step, "message": message, "ts": time.time()}) - r.rpush(key, entry) - r.expire(key, CLASSIFY_EXPIRE) - - -def _create_classification_snapshot(db, job_id: str, user_id: int) -> tuple[int, int, int]: - row = db.execute(sa_text(""" - INSERT INTO question_classification_snapshots (job_id, created_by) - VALUES (:job_id, :user_id) - RETURNING id - """), {"job_id": job_id, "user_id": user_id}).fetchone() - snapshot_id = row[0] - - db.execute(sa_text(""" - INSERT INTO question_classification_snapshot_links (snapshot_id, question_id, tag_name, tag_type) - SELECT :snapshot_id, tl.question_id, t.name, t.type - FROM question_tag_links tl - JOIN question_tags t ON t.id = tl.tag_id - ON CONFLICT DO NOTHING - """), {"snapshot_id": snapshot_id}) - - stats = db.execute(sa_text(""" - SELECT COUNT(DISTINCT question_id) AS question_count, COUNT(*) AS link_count - FROM question_classification_snapshot_links - WHERE snapshot_id = :snapshot_id - """), {"snapshot_id": snapshot_id}).fetchone() - question_count = int(stats[0] or 0) - link_count = int(stats[1] or 0) - - db.execute(sa_text(""" - UPDATE question_classification_snapshots - SET question_count = :question_count, link_count = :link_count - WHERE id = :snapshot_id - """), { - "snapshot_id": snapshot_id, - "question_count": question_count, - "link_count": link_count, - }) - db.commit() - return snapshot_id, question_count, link_count - - -@celery_app.task(name="classify_questions", bind=True) -def classify_questions(self, job_id: str, user_id: int): - """Classify untagged questions using AI — subjects, diseases, keywords.""" - r = _redis() - r.set(f"classify:status:{job_id}", "running", ex=CLASSIFY_EXPIRE) - - db = SessionLocal() - try: - from app.models.question import Question - from app.services import ai_service - - _push_classify_step(r, job_id, "snapshot", "Saving rollback snapshot for current classifications...") - snapshot_id, snapshot_questions, snapshot_links = _create_classification_snapshot(db, job_id, user_id) - _push_classify_step( - r, - job_id, - "snapshot", - f"Saved rollback snapshot #{snapshot_id} with {snapshot_questions} tagged questions and {snapshot_links} tag assignments.", - ) - _push_classify_step(r, job_id, "start", "Finding untagged questions...") - - # Get IDs of questions that already have tags - tagged_ids_rows = db.execute(sa_text( - "SELECT DISTINCT question_id FROM question_tag_links" - )).fetchall() - tagged_ids = {row[0] for row in tagged_ids_rows} - - # Get all questions not yet tagged - all_questions = db.query(Question).all() - untagged = [q for q in all_questions if q.id not in tagged_ids] - - if not untagged: - _push_classify_step(r, job_id, "done", "All questions are already tagged.") - r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE) - return - - total = len(untagged) - _push_classify_step(r, job_id, "start", f"Found {total} untagged questions. Starting classification...") - - # Get AI model for keyword task - model_id, api_key = ai_service.get_model_for_task(db, "keyword") - - batch_size = 10 - classified = 0 - - for i in range(0, total, batch_size): - if r.get(f"classify:status:{job_id}") == "cancelled": - _push_classify_step(r, job_id, "cancelled", "Job cancelled.") - return - - batch = untagged[i:i + batch_size] - batch_num = (i // batch_size) + 1 - total_batches = (total + batch_size - 1) // batch_size - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num}/{total_batches}: classifying {len(batch)} questions...") - - # Build questions JSON for prompt - questions_json = json.dumps([ - {"id": q.id, "question_text": q.question_text[:500]} - for q in batch - ], indent=2) - - prompt = f"""Classify each medical question below. For each question, provide: -- subjects: 1-3 medical subjects/specialties (e.g., "Cardiology", "Infectious Disease", "Neonatology") -- diseases: 1-3 specific diseases/conditions mentioned (e.g., "Kawasaki Disease", "Pneumonia", "Type 1 Diabetes") -- keywords: 2-4 key clinical concepts (e.g., "fever workup", "antibiotic resistance", "fluid management") - -Return ONLY JSON: -{{"classifications": [ - {{"id": , "subjects": [...], "diseases": [...], "keywords": [...]}} -]}} - -Questions: -{questions_json}""" - - try: - raw = ai_service._call_model(prompt, model_id, api_key) - # Parse JSON from response - text = raw.strip() - if text.startswith("```"): - text = text.split("\n", 1)[1] if "\n" in text else text[3:] - if text.endswith("```"): - text = text[:-3] - text = text.strip() - data = json.loads(text) - classifications = data.get("classifications", []) - - # Store tags - for cls in classifications: - q_id = cls.get("id") - if not q_id: - continue - - for tag_type, tag_list in [("subject", cls.get("subjects", [])), - ("disease", cls.get("diseases", [])), - ("keyword", cls.get("keywords", []))]: - for tag_name in tag_list: - if not tag_name or not isinstance(tag_name, str): - continue - normalized = tag_name.strip().title() - if not normalized: - continue - - # Insert tag (ON CONFLICT DO NOTHING for case-insensitive uniqueness) - db.execute(sa_text(""" - INSERT INTO question_tags (name, type) - VALUES (:name, :type) - ON CONFLICT (LOWER(name), type) DO NOTHING - """), {"name": normalized, "type": tag_type}) - db.flush() - - # Get the tag ID - tag_row = db.execute(sa_text(""" - SELECT id FROM question_tags - WHERE LOWER(name) = LOWER(:name) AND type = :type - """), {"name": normalized, "type": tag_type}).fetchone() - - if tag_row: - db.execute(sa_text(""" - INSERT INTO question_tag_links (question_id, tag_id) - VALUES (:qid, :tid) - ON CONFLICT DO NOTHING - """), {"qid": q_id, "tid": tag_row[0]}) - - db.commit() - classified += len(batch) - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num}/{total_batches} done. {classified}/{total} classified.") - - except Exception as e: - logger.warning(f"Classification batch {batch_num} failed: {e}") - _push_classify_step(r, job_id, "progress", - f"Batch {batch_num} failed: {e}. Continuing...") - continue - - _push_classify_step(r, job_id, "done", f"Classification complete. {classified}/{total} questions classified.") - r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE) - - except Exception as e: - logger.exception(f"Classification failed for job {job_id}") - _push_classify_step(r, job_id, "error", f"Failed: {e}") - r.set(f"classify:status:{job_id}", "failed", ex=CLASSIFY_EXPIRE) - r.set(f"classify:error:{job_id}", str(e)[:500], ex=CLASSIFY_EXPIRE) - raise - finally: - db.close() - - @celery_app.task(name="retry_missing_embeddings") def retry_missing_embeddings(batch: int = 200) -> dict: """Backfill questions that have no usable vector. diff --git a/backend/scripts/retire_question_tags.py b/backend/scripts/retire_question_tags.py new file mode 100644 index 0000000..6131430 --- /dev/null +++ b/backend/scripts/retire_question_tags.py @@ -0,0 +1,88 @@ +"""Take down the scaffolding: the subject, disease and keyword tags. + +A model tagged every question once; `sanitize_tags` cleaned the vocabulary; +`convert_tags_to_categories` turned those tags into the category tree. The tree +is now the taxonomy — every question has a category, and every category carries +its organ system — so the tags underneath it have nothing left to do. + + subject became the discipline a topic sits under + disease became the topic itself + keyword was the old route to an organ system, which now comes from the + category, reaching 2,919 of 2,924 questions instead of 1,492 + +The sixteen `system` rows stay: `question_categories.system_id` points at them, +and they are the vocabulary that names each axis row. + +Everything removed is written to /backups first, as rows that could be replayed +— this deletes a quarter of a million facts about the bank, and "we can always +put it back" should be true rather than said. + +Idempotent, and a dry run by default: + + docker compose exec backend python -m scripts.retire_question_tags + docker compose exec backend python -m scripts.retire_question_tags --apply +""" +import json +import sys +from datetime import datetime +from pathlib import Path + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal + +GOING = ("subject", "disease", "keyword") +BACKUP_DIR = Path("/backups") + + +def main(apply: bool) -> int: + db = SessionLocal() + try: + tags = db.execute(sa_text( + "SELECT id, name, type, parent_id FROM question_tags WHERE type = ANY(:t)"), + {"t": list(GOING)}).mappings().all() + if not tags: + print("Nothing to retire — the tags are already gone.") + return 0 + ids = [row["id"] for row in tags] + links = db.execute(sa_text( + "SELECT question_id, tag_id FROM question_tag_links WHERE tag_id = ANY(:ids)"), + {"ids": ids}).mappings().all() + staying = db.execute(sa_text( + "SELECT count(*) FROM question_tags WHERE type = 'system'")).scalar() + orphaned = db.execute(sa_text( + "SELECT count(*) FROM question_categories WHERE system_id IS NOT NULL")).scalar() + + by_type = {t: sum(1 for row in tags if row["type"] == t) for t in GOING} + print("retiring") + for name, n in by_type.items(): + print(f" {name:<10} {n} tags") + print(f" links {len(links)}") + print(f"\nstaying") + print(f" system {staying} tags, named by {orphaned} topics") + + if not apply: + print("\ndry run. Pass --apply to write.") + return 0 + + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + path = BACKUP_DIR / f"question_tags_retired_{stamp}.json" + path.write_text(json.dumps({ + "retired_at": stamp, + "tags": [dict(row) for row in tags], + "links": [dict(row) for row in links], + }, indent=1)) + print(f"\nbacked up to {path} ({path.stat().st_size:,} bytes)") + + db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = ANY(:ids)"), {"ids": ids}) + db.execute(sa_text("DELETE FROM question_tags WHERE id = ANY(:ids)"), {"ids": ids}) + db.commit() + print(f"deleted {len(tags)} tags and {len(links)} links.") + return 0 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main("--apply" in sys.argv)) diff --git a/backend/tests/test_category_migration.py b/backend/tests/test_category_migration.py index c73b73e..25a9c08 100644 --- a/backend/tests/test_category_migration.py +++ b/backend/tests/test_category_migration.py @@ -6,7 +6,9 @@ import unittest from unittest.mock import patch os.environ["DATABASE_URL"] = "sqlite:///:memory:" +import test_quiz_builder as fixtures from app.database import Base # Initialize the app engine with disposable SQLite before offline URL override. +from app.models.question import Question from alembic import command from alembic.config import Config from alembic.script import ScriptDirectory @@ -39,3 +41,53 @@ class CategoryMigrationTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class DeletingATopicTests(unittest.TestCase): + """Where the questions go has to be said. + + Deleting a topic used to leave them filed nowhere — no discipline, no + organ system, no relevance, no row on any tab of the analysis. In the bank + and invisible to every page that counts them. + """ + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.db = self.bank.db + self.bank.user = self.bank.mod + + def tearDown(self): + self.bank.tearDown() + + def test_a_topic_holding_questions_will_not_go_quietly(self): + # Category 3 holds question 3 in the fixture bank. + response = self.client.delete('/question-categories/3') + self.assertEqual(response.status_code, 400, response.text) + self.assertIn('Choose a topic to move them to', response.json()['detail']) + self.assertIsNotNone(self.db.get(Question, 3).question_category_id) + + def test_naming_a_destination_moves_them_and_deletes_the_topic(self): + from app.models.question_category import QuestionCategory + response = self.client.delete('/question-categories/3', params={'move_to': 2}) + self.assertEqual(response.status_code, 204, response.text) + self.assertEqual(self.db.get(Question, 3).question_category_id, 2) + self.assertIsNone(self.db.get(QuestionCategory, 3)) + + def test_an_empty_topic_goes_without_being_asked(self): + from app.models.question_category import QuestionCategory + # Category 4 holds nothing. + self.assertEqual(self.client.delete('/question-categories/4').status_code, 204) + self.assertIsNone(self.db.get(QuestionCategory, 4)) + + def test_an_extra_link_moves_too_and_does_not_duplicate(self): + from app.models.question_category import QuestionCategoryLink + # Question 1 is filed under category 1 and also linked to 3. + self.db.add(QuestionCategoryLink(question_id=1, category_id=3)) + self.db.add(QuestionCategoryLink(question_id=1, category_id=2)) + self.db.commit() + self.assertEqual( + self.client.delete('/question-categories/3', params={'move_to': 2}).status_code, 204) + pairs = self.db.query(QuestionCategoryLink).filter_by(question_id=1, category_id=2).count() + self.assertEqual(pairs, 1) diff --git a/frontend/src/components/BackLink.css b/frontend/src/components/BackLink.css new file mode 100644 index 0000000..1254885 --- /dev/null +++ b/frontend/src/components/BackLink.css @@ -0,0 +1,5 @@ +.backlink { + display: inline-block; margin-bottom: 12px; + font-size: 0.85rem; text-decoration: none; color: var(--primary); +} +.backlink:hover { text-decoration: underline; } diff --git a/frontend/src/components/BackLink.jsx b/frontend/src/components/BackLink.jsx new file mode 100644 index 0000000..ff52098 --- /dev/null +++ b/frontend/src/components/BackLink.jsx @@ -0,0 +1,14 @@ +import { Link } from 'react-router-dom' +import './BackLink.css' + +/** + * The way back out of a page that has no menu of its own. + * + * One step, to where you came from — not to the top of Settings. A page + * reached from the Tools section returns to the Tools section, so following a + * link and changing your mind costs one click and lands you where you were + * looking, with the other tools still in front of you. + */ +export default function BackLink({ to, children }) { + return ← {children} +} diff --git a/frontend/src/components/SitePolicy.css b/frontend/src/components/SitePolicy.css index b765de5..816e1c6 100644 --- a/frontend/src/components/SitePolicy.css +++ b/frontend/src/components/SitePolicy.css @@ -50,3 +50,11 @@ .sp-code-actions { width: 100%; } .sp-code-actions .btn { flex: 1; } } + +/* Said once, where the switch that caused it is. */ +.sp-note { + margin: -4px 0 14px; padding: 10px 13px; border-radius: 9px; + font-size: 0.82rem; line-height: 1.55; + background: var(--wrong-bg); color: var(--wrong-fg); + border: 1px solid var(--wrong-bd); +} diff --git a/frontend/src/components/SitePolicy.jsx b/frontend/src/components/SitePolicy.jsx index 3eb73fb..25e77ee 100644 --- a/frontend/src/components/SitePolicy.jsx +++ b/frontend/src/components/SitePolicy.jsx @@ -15,7 +15,11 @@ const when = (value) => (value ? new Date(value).toLocaleDateString(undefined, * something a learner has already handed to someone — it stops new ones. */ export default function SitePolicy() { - const [flags, setFlags] = useState({ sharing_enabled: true, invite_only: false, tutor_in_quiz: true }) + const [flags, setFlags] = useState({ + registration_enabled: true, sharing_enabled: true, invite_only: false, + tutor_in_quiz: true, sso_only: false, + }) + const [sso, setSso] = useState({ configured: false, name: '' }) const [codes, setCodes] = useState([]) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) @@ -27,9 +31,15 @@ export default function SitePolicy() { Promise.all([api.get('/admin/settings'), api.get('/admin/invites')]) .then(([settings, invites]) => { setFlags({ + registration_enabled: settings.data.registration_enabled !== false, sharing_enabled: settings.data.sharing_enabled !== false, invite_only: settings.data.invite_only === true, tutor_in_quiz: settings.data.tutor_in_quiz !== false, + sso_only: settings.data.sso_only === true, + }) + setSso({ + configured: !!settings.data.sso_configured, + name: settings.data.sso_provider_name || 'your identity provider', }) setCodes(invites.data || []) }) @@ -74,8 +84,31 @@ export default function SitePolicy() {
{error &&

{error}

} + {/* First, because it decides whether any of the rest is reachable. It + was settable through the API and shown nowhere, so a site could be + closed to new members with no switch anywhere to reopen it — which is + exactly what happened here. */} + + {!flags.registration_enabled && ( +

+ Registration is closed, so the settings below have nothing to act on + until it is reopened. +

+ )} + + - {flags.invite_only && ( + {sso.configured && ( + + )} + + {flags.invite_only && flags.registration_enabled && (

Invite codes {open.length} unused

diff --git a/frontend/src/pages/AccessPage.jsx b/frontend/src/pages/AccessPage.jsx index 6e1f333..d3e3a8a 100644 --- a/frontend/src/pages/AccessPage.jsx +++ b/frontend/src/pages/AccessPage.jsx @@ -176,7 +176,7 @@ export default function AccessPage() { articles. Image libraries are granted one by one.

- Back to settings + Back to Tools {error &&

{error}

} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index b2c34fe..1acab9e 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' -import { Link } from 'react-router-dom' import api from '../api/client' import './CategoriesPage.css' +import BackLink from '../components/BackLink' const apiError = (err, fallback) => { const detail = err?.response?.data?.detail @@ -18,17 +18,16 @@ const apiError = (err, fallback) => { * whoever is curating them they are the same job — the storage split is an * implementation detail, not something to make somebody navigate around. */ +// Two axes, because there are two. Symptoms, Diseases and Subjects were tag +// vocabularies that a model wrote per question; they were converted into this +// topic tree and then kept on as tabs that no longer decided anything. They +// have been retired, and their tabs with them — a tab that always reads zero +// teaches people the page is broken. const FACETS = [ { key: 'topics', label: 'Topics', singular: 'topic', source: 'category', blurb: 'The discipline tree — Cardiology, Neonatology, and what sits beneath them.' }, { key: 'systems', label: 'Systems', singular: 'system', source: 'tag', type: 'system', group: 'systems', - blurb: 'Organ systems, deliberately flat. Symptoms and diseases hang off these.' }, - { key: 'symptoms', label: 'Symptoms', singular: 'symptom', source: 'tag', type: 'keyword', group: 'keywords', - blurb: 'Presenting complaints, grouped by the system they present in.' }, - { key: 'diseases', label: 'Diseases', singular: 'disease', source: 'tag', type: 'disease', group: 'diseases', - blurb: 'Named conditions.' }, - { key: 'subjects', label: 'Subjects', singular: 'subject', source: 'tag', type: 'subject', group: 'subjects', - blurb: 'Broad exam subjects.' }, + blurb: 'Organ systems, deliberately flat. A topic carries one, which is how a question reaches it.' }, ] // Symptoms alone run to four thousand rows. Drawing them all costs a second of @@ -384,13 +383,13 @@ export default function CategoriesPage() { return (
+ Tools

Taxonomy

Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.

- Questions diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx index 2ec4348..865beff 100644 --- a/frontend/src/pages/CategoriesPage.test.jsx +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -101,14 +101,30 @@ it('deletes a leaf category, rehoming its questions', async () => { { params: { move_to: 3 } })) }) -it('leaves the questions unfiled when no destination is picked', async () => { +it('the server refuses a delete with nowhere to put the questions', async () => { mount() await screen.findByText('Root') - api.delete.mockResolvedValue({}) + // A topic emptied of its filing leaves its questions with no discipline, no + // organ system and no row on any tab of the analysis, so the server will not + // do it. The page shows what it said. + api.delete.mockRejectedValue({ response: { data: { + detail: '4 questions are filed here. Choose a topic to move them to before deleting this one.' } } }) await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) await userEvent.click(screen.getByRole('button', { name: 'Delete Child' })) await userEvent.click(screen.getByRole('button', { name: 'Delete topic' })) - await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2', { params: {} })) + expect(await screen.findByText(/Choose a topic to move them to/)).toBeInTheDocument() +}) + +it('offers only the two axes there are', async () => { + mount() + await screen.findByText('Root') + // Symptoms, Diseases and Subjects were tag vocabularies converted into this + // tree and then kept on as tabs that always read zero. + expect(screen.getByRole('tab', { name: /Topics/ })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Systems/ })).toBeInTheDocument() + for (const gone of ['Symptoms', 'Diseases', 'Subjects']) { + expect(screen.queryByRole('tab', { name: new RegExp(gone) })).toBeNull() + } }) it('refuses to delete a category that still has subcategories', async () => { @@ -154,65 +170,6 @@ it('surfaces a server refusal', async () => { // ---- tag facets ---- -it('shows symptoms under the system they present in', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - - // The system is drawn in as a heading, or its 726 children would have no root. - const heading = (await screen.findByText('Respiratory System')).closest('.cat-row') - expect(within(heading).getByText('system')).toBeInTheDocument() - // A heading is not editable from this tab — it belongs to the Systems tab. - expect(within(heading).queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument() - - expect(screen.queryByText('Cough')).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Expand Respiratory System' })) - expect(screen.getByText('Cough')).toBeInTheDocument() - - // A system nobody files under is not drawn at all. - expect(screen.queryByText('Cardiovascular System')).not.toBeInTheDocument() - // An ungrouped symptom stays at the top level rather than disappearing. - expect(screen.getByText('Fatigue')).toBeInTheDocument() -}) - -it('creates a symptom under a system, which the tags API allows', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - api.post.mockResolvedValue({ data: { id: 99 } }) - - await userEvent.click(screen.getByRole('button', { name: '+ New symptom' })) - await userEvent.type(screen.getByLabelText('New symptom name'), 'Stridor') - await userEvent.selectOptions(screen.getByLabelText('New symptom parent'), '10') - await userEvent.click(screen.getByRole('button', { name: 'Create' })) - - await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/', - { name: 'Stridor', type: 'keyword', parent_id: 10, sort_order: 100 })) - expect(await screen.findByRole('status')).toHaveTextContent('in every picker now') -}) - -it('offers systems as parents on the symptom tab', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - await userEvent.click(await screen.findByRole('button', { name: '+ New symptom' })) - const parent = screen.getByLabelText('New symptom parent') - expect([...parent.options].map(o => o.text)).toContain('Respiratory System (system)') - expect([...parent.options].map(o => o.text)).toContain('Fatigue') -}) - -it('deletes a tag, saying its children rise rather than refusing', async () => { - mount() - await screen.findByText('Root') - await openFacet('Symptoms') - api.delete.mockResolvedValue({}) - - await userEvent.click(await screen.findByRole('button', { name: 'Delete Fatigue' })) - expect(screen.getByRole('alert')).toHaveTextContent('Move its 3 questions to') - await userEvent.click(screen.getByRole('button', { name: 'Delete symptom' })) - await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/tags/21', { params: {} })) -}) - it('does not search the question bank from here', async () => { mount() await screen.findByText('Root') diff --git a/frontend/src/pages/DocumentDetailPage.css b/frontend/src/pages/DocumentDetailPage.css index e6fc1f1..6748faf 100644 --- a/frontend/src/pages/DocumentDetailPage.css +++ b/frontend/src/pages/DocumentDetailPage.css @@ -1,8 +1,3 @@ -.doc-back { - display: inline-block; margin-bottom: 10px; - font-size: 0.85rem; text-decoration: none; color: var(--primary); -} -.doc-back:hover { text-decoration: underline; } /* ── A section and what can be done to it ───────────────────────────── The name took whatever width was left after a button group that would diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index cc60a75..af93562 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' import './DocumentDetailPage.css' +import BackLink from '../components/BackLink' function ExtractionProgress({ jobId, onDone, onClose, label = 'Extracting Questions' }) { const [steps, setSteps] = useState([]) @@ -292,7 +293,7 @@ export default function DocumentDetailPage() { )} {/* Reached from the Tools workbench, which has no menu of its own — so without this there is no way back but the browser button. */} - ← Tools + Question workbench
diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx index 126ea46..b1e6f6a 100644 --- a/frontend/src/pages/HandbookPage.jsx +++ b/frontend/src/pages/HandbookPage.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import api from '../api/client' import './HandbookPage.css' +import BackLink from '../components/BackLink' /** * How this site works, for the people who maintain it. @@ -54,6 +55,7 @@ function Prompt() { export default function HandbookPage() { return (
+ Tools

Handbook

diff --git a/frontend/src/pages/JobsPage.jsx b/frontend/src/pages/JobsPage.jsx index 1388988..3c92408 100644 --- a/frontend/src/pages/JobsPage.jsx +++ b/frontend/src/pages/JobsPage.jsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' +import BackLink from '../components/BackLink' function JobDetail({ job }) { const [steps, setSteps] = useState([]) @@ -157,6 +158,7 @@ export default function JobsPage() { return (

+ Tools

Extraction Jobs

diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 7dbd0d9..f3d7bf7 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -222,6 +222,8 @@ function NextcloudSection() { * page's own sections rather than behind a link to a second dashboard. */ function ToolsSection() { + // Editorial is not here: it has its own entry in the section bar, and a card + // pointing at it would be a second door to the same room. return (

@@ -229,7 +231,6 @@ function ToolsSection() { { to: '/handbook', icon: '📖', label: 'Handbook', desc: 'How the parts that are not obvious work' }, { to: '/tools', icon: '🧪', label: 'Question workbench', desc: 'PDFs in, drafts read, questions out' }, { to: '/categories', icon: '🗂️', label: 'Taxonomy', desc: 'Topics, systems, symptoms, diseases' }, - { to: '/editorial', icon: '✍️', label: 'Editorial', desc: 'Article drafts and references' }, { to: '/access', icon: '🔑', label: 'Access', desc: 'Who may edit what' }, { to: '/trash', icon: '🗑️', label: 'Trash', desc: 'Restore deleted questions' }, { to: '/jobs', icon: '📋', label: 'Extraction jobs', desc: 'Extraction history' }, diff --git a/frontend/src/pages/ToolsPage.css b/frontend/src/pages/ToolsPage.css index 71c1158..ced58ea 100644 --- a/frontend/src/pages/ToolsPage.css +++ b/frontend/src/pages/ToolsPage.css @@ -1,10 +1,5 @@ .tools { max-width: 900px; margin: 0 auto; padding-bottom: 64px; } .tools-head { margin-bottom: 24px; } -.tools-back { - display: inline-block; margin-bottom: 10px; - font-size: 0.85rem; text-decoration: none; color: var(--primary); -} -.tools-back:hover { text-decoration: underline; } .tools-head h1 { margin: 0 0 8px; font-size: 1.5rem; font-weight: 700; } .tools-head p { margin: 0; font-size: 0.92rem; line-height: 1.65; color: var(--text-muted); max-width: 66ch; } .tools-error { margin: 0 0 14px; font-size: 0.85rem; color: var(--wrong-fg); } diff --git a/frontend/src/pages/ToolsPage.jsx b/frontend/src/pages/ToolsPage.jsx index 5e7536e..c043772 100644 --- a/frontend/src/pages/ToolsPage.jsx +++ b/frontend/src/pages/ToolsPage.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' import './ToolsPage.css' +import BackLink from '../components/BackLink' const DraftBatch = lazy(() => import('../components/DraftBatch')) @@ -51,7 +52,7 @@ export default function ToolsPage() { {/* The way back. This page is a workbench rather than part of the study flow, and without a trail out of it you are somewhere that looks like a different application. */} - ← Settings + Tools in Settings

Tools

A document goes in, a model proposes questions, you read them, and the diff --git a/frontend/src/pages/TrashPage.jsx b/frontend/src/pages/TrashPage.jsx index d64bfac..a1a0b83 100644 --- a/frontend/src/pages/TrashPage.jsx +++ b/frontend/src/pages/TrashPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' +import BackLink from '../components/BackLink' const when = value => (value ? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : '') @@ -46,6 +47,7 @@ export default function TrashPage() { return (

+ Tools

Trash