feat: retire the tags, and stop settings from hiding from the page that

claims to hold them

The scaffolding is down. 203 subject, 2,275 disease and 4,281 keyword
tags, and 25,356 links, deleted — backed up first to a 1.9MB JSON of
replayable rows, because "we can always put it back" should be true
rather than said. The 16 system rows stay: categories point at them.

With them go the things that only existed to feed them — the
classify_questions task, its snapshot helpers, POST /tags/classify and
its status poll — and the three Taxonomy tabs that would now always read
zero. A tab showing 0 forever teaches people the page is broken.

The organ-system filter in the session builder moved onto categories with
the rest, including everything beneath a matched topic, so it groups the
way the analysis does.

Registration: `settings:registration_enabled` was set to false, and there
was no switch anywhere on the site to set it back. The API had always
accepted it; the Site policy page had never shown it. So the site could
be closed to new members with the admin looking at three switches, all
correct, and no way to see the one that was actually refusing them. It is
now the first switch on that page, and says plainly that the ones below
it have nothing to act on while it is off. The SSO-only flag was hidden
the same way and is shown when SSO is configured.

Deleting a topic no longer silently unfiles its questions. It asks where
they go, and says how many are waiting, unless the topic is empty — the
same rule promotion now follows. Its extra category links move too,
minus any that would duplicate a pair the destination already has.

Back links: Trash, Extraction jobs, Taxonomy and the Handbook had none at
all, and Access pointed at the wrong section. They are one component now,
each returning one step to the section it was opened from. Editorial has
its own entry in the section bar, so its Tools card is gone rather than
being a second door to the same room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 05:06:58 +02:00
parent 8614792bdf
commit ebcc11ae12
21 changed files with 304 additions and 330 deletions

View file

@ -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()

View file

@ -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

View file

@ -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_(

View file

@ -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": <question_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.

View file

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

View file

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

View file

@ -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; }

View file

@ -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 <Link className="backlink" to={to}> {children}</Link>
}

View file

@ -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);
}

View file

@ -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() {
<div className="sp">
{error && <p className="sp-error" role="alert">{error}</p>}
{/* 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. */}
<label className="sp-switch">
<input type="checkbox" checked={flags.invite_only} disabled={busy}
<input type="checkbox" checked={flags.registration_enabled} disabled={busy}
onChange={e => toggle('registration_enabled', e.target.checked)} />
<span>
<strong>Anyone may register</strong>
<small>
Turn this off and nobody new can join at all, invite code or not.
People who already have an account keep it.
</small>
</span>
</label>
{!flags.registration_enabled && (
<p className="sp-note" role="status">
Registration is closed, so the settings below have nothing to act on
until it is reopened.
</p>
)}
<label className="sp-switch">
<input type="checkbox" checked={flags.invite_only} disabled={busy || !flags.registration_enabled}
onChange={e => toggle('invite_only', e.target.checked)} />
<span>
<strong>Invite only</strong>
@ -112,7 +145,21 @@ export default function SitePolicy() {
</span>
</label>
{flags.invite_only && (
{sso.configured && (
<label className="sp-switch">
<input type="checkbox" checked={flags.sso_only} disabled={busy}
onChange={e => toggle('sso_only', e.target.checked)} />
<span>
<strong>Sign in with {sso.name} only</strong>
<small>
Turns off the email and password form. Only meaningful because
single sign-on is configured for this site.
</small>
</span>
</label>
)}
{flags.invite_only && flags.registration_enabled && (
<section className="sp-codes">
<h3>Invite codes <small>{open.length} unused</small></h3>

View file

@ -176,7 +176,7 @@ export default function AccessPage() {
articles. Image libraries are granted one by one.
</p>
</div>
<Link className="btn btn-secondary btn-sm" to="/settings?s=admin">Back to settings</Link>
<Link className="btn btn-secondary btn-sm" to="/settings?s=tools">Back to Tools</Link>
</div>
{error && <p className="ac-error" role="alert">{error}</p>}

View file

@ -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 (
<div className="cat-page">
<BackLink to="/settings?s=tools">Tools</BackLink>
<div className="cat-header">
<div>
<h1>Taxonomy</h1>
<p>Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.</p>
</div>
<div className="cat-header-actions">
<Link className="btn btn-secondary" to="/questions/manage">Questions</Link>
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>
+ New {facet.singular}
</button>

View file

@ -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')

View file

@ -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

View file

@ -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. */}
<Link className="doc-back" to="/tools"> Tools</Link>
<BackLink to="/tools">Question workbench</BackLink>
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>

View file

@ -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 (
<div className="hb">
<BackLink to="/settings?s=tools">Tools</BackLink>
<div className="hb-head">
<h1>Handbook</h1>
<p>

View file

@ -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 (
<div style={{ maxWidth: 760, margin: '0 auto' }}>
<BackLink to="/settings?s=tools">Tools</BackLink>
<div className="card" style={{ marginBottom: 16 }}>
<h2>Extraction Jobs</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginTop: 4 }}>

View file

@ -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 (
<Section title="Tools" description="Editing surfaces that have a page of their own.">
<div className="set-cards">
@ -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' },

View file

@ -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); }

View file

@ -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. */}
<Link className="tools-back" to="/settings?s=tools"> Settings</Link>
<BackLink to="/settings?s=tools">Tools in Settings</BackLink>
<h1>Tools</h1>
<p>
A document goes in, a model proposes questions, you read them, and the

View file

@ -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 (
<div style={{ maxWidth: 820, margin: '0 auto' }}>
<BackLink to="/settings?s=tools">Tools</BackLink>
<div className="card" style={{ marginBottom: 16 }}>
<h2>Trash</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginTop: 4 }}>