fix: put the disciplines and diseases back, and order Performance as shown
Retiring three vocabularies at once was my call and the wrong one. Keyword had to go — it was the old route to an organ system, which a topic now carries, and that took Systems from half the bank to all of it. Subject and disease went with it on the argument that the topic tree says the same thing. It mostly does, and "mostly" is not a reason to remove the vocabulary people had learned to filter by. 203 subjects and 2,275 diseases are back, with their 14,029 links, and the Disciplines and Diseases pickers with them. Keywords stay retired. The backup I wrote before deleting was not where I said it was: `./backups` is mounted on db-backup, not on backend, so the file went with the next container rebuild. The rows came from the nightly dump instead, which is what that dump is for. scripts/restore_subject_disease_tags reads a pg_dump extract, is idempotent, and resets the sequence afterwards so the next tag created by hand does not collide with a restored one. /tags serves subjects and diseases from their own links again, and systems through the topics that carry them. The Performance tab is ordered as the reference has it: the trend beside the split it is a trend in, and Completion's four figures underneath. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
a16e45a7f1
commit
6240a1c9da
6 changed files with 182 additions and 28 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
"""Organ systems: the vocabulary, and how many questions reach each one.
|
"""The vocabularies a question can be filtered by, and how many it holds.
|
||||||
|
|
||||||
Once this served four vocabularies a model wrote per question — subjects,
|
Three of the four a model once wrote per question survive: subjects, diseases
|
||||||
diseases, keywords, systems. The first three became the category tree and have
|
and the sixteen organ systems. Keywords are retired — they were the old route
|
||||||
been retired; what is left is the sixteen systems, which a topic points at.
|
to a system, which a topic now carries directly.
|
||||||
|
|
||||||
The table is created via raw SQL in setup_pgvector() to avoid race conditions
|
The table is created via raw SQL in setup_pgvector() to avoid race conditions
|
||||||
with multiple workers, so there is no ORM class for it.
|
with multiple workers, so there is no ORM class for it.
|
||||||
|
|
@ -36,10 +36,14 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur
|
||||||
so the number here is the number the filter will return.
|
so the number here is the number the filter will return.
|
||||||
|
|
||||||
It used to come from a symptom keyword the question happened to mention.
|
It used to come from a symptom keyword the question happened to mention.
|
||||||
Those keywords are retired, and with them the subject and disease
|
Those keywords are retired — the topic carries the system now, which took
|
||||||
vocabularies that had already become the topic tree. The shape of the
|
the Systems axis from about half the bank to all of it.
|
||||||
response is kept so nothing that reads it has to be rewritten to find an
|
|
||||||
empty list where it expected one.
|
Subjects and diseases are not retired. They were taken down with the
|
||||||
|
keywords on the argument that the topic tree says the same thing; it mostly
|
||||||
|
does, but they are the vocabulary people had learned to filter by, and
|
||||||
|
"mostly" is not a reason to remove somebody's tools. They are counted here
|
||||||
|
the way they always were, from the links a question carries directly.
|
||||||
"""
|
"""
|
||||||
exam_id = getattr(current_user, "active_exam_id", None)
|
exam_id = getattr(current_user, "active_exam_id", None)
|
||||||
systems = db.execute(text(
|
systems = db.execute(text(
|
||||||
|
|
@ -85,8 +89,31 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur
|
||||||
if system:
|
if system:
|
||||||
counts[system].add(qid)
|
counts[system].add(qid)
|
||||||
|
|
||||||
|
# The two vocabularies that survived retirement, counted the way they
|
||||||
|
# always were: a question carries them directly.
|
||||||
|
vocab: dict[str, list[dict]] = {"subjects": [], "diseases": []}
|
||||||
|
of_type = {"subject": "subjects", "disease": "diseases"}
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT t.id, t.name, t.type, t.parent_id,
|
||||||
|
COUNT(DISTINCT tl.question_id) AS count
|
||||||
|
FROM question_tags t
|
||||||
|
LEFT JOIN question_tag_links tl ON tl.tag_id = t.id
|
||||||
|
WHERE t.type IN ('subject', 'disease')
|
||||||
|
AND (t.exam_id IS NULL OR :exam_id IS NULL OR t.exam_id = :exam_id)
|
||||||
|
GROUP BY t.id, t.name, t.type, t.parent_id
|
||||||
|
ORDER BY count DESC, t.name
|
||||||
|
"""), {"exam_id": exam_id}).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
if exam_id and row.count == 0:
|
||||||
|
continue
|
||||||
|
vocab[of_type[row.type]].append({
|
||||||
|
"id": row.id, "name": row.name, "count": row.count, "parent_id": row.parent_id})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"subjects": [], "diseases": [], "keywords": [],
|
**vocab,
|
||||||
|
# Retired: this was the route to an organ system before a topic carried
|
||||||
|
# one, and nothing reads it now.
|
||||||
|
"keywords": [],
|
||||||
"systems": [{"id": row.id, "name": row.name, "count": len(counts.get(row.id, ())),
|
"systems": [{"id": row.id, "name": row.name, "count": len(counts.get(row.id, ())),
|
||||||
"parent_id": None} for row in systems],
|
"parent_id": None} for row in systems],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
94
backend/scripts/restore_subject_disease_tags.py
Normal file
94
backend/scripts/restore_subject_disease_tags.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Put the discipline and disease vocabularies back.
|
||||||
|
|
||||||
|
`retire_question_tags` took three vocabularies down together: subject, disease
|
||||||
|
and keyword. Keyword was the one that had to go — it was the old route to an
|
||||||
|
organ system, which the topic tree now carries properly. Subject and disease
|
||||||
|
were removed on the argument that the tree already says the same thing, and
|
||||||
|
that argument was mine to make and not mine to act on alone.
|
||||||
|
|
||||||
|
So this restores those two and leaves keyword retired. Read from a plain
|
||||||
|
tab-separated extract of the two tables — the shape `pg_dump` writes — so it
|
||||||
|
works from a nightly dump as well as from a purpose-made file.
|
||||||
|
|
||||||
|
docker compose exec backend python -m scripts.restore_subject_disease_tags \\
|
||||||
|
--tags /backups/tags.tsv --links /backups/links.tsv
|
||||||
|
…same, with --apply
|
||||||
|
|
||||||
|
Idempotent: a tag id that is already present is left alone, and so is a link
|
||||||
|
that already exists.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
RESTORING = ("subject", "disease")
|
||||||
|
#: id, name, type, created_at, exam_id, parent_id, sort_order — pg_dump's order.
|
||||||
|
COLUMNS = ("id", "name", "type", "created_at", "exam_id", "parent_id", "sort_order")
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: str) -> list[list[str]]:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
return [line.rstrip("\n").split("\t") for line in handle if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def value(raw: str):
|
||||||
|
"""`pg_dump` writes an unquoted \\N for null."""
|
||||||
|
return None if raw == "\\N" else raw
|
||||||
|
|
||||||
|
|
||||||
|
def main(tags_path: str, links_path: str, apply: bool) -> int:
|
||||||
|
wanted = [row for row in read(tags_path) if row[2] in RESTORING]
|
||||||
|
if not wanted:
|
||||||
|
print(f"! no {' or '.join(RESTORING)} rows in {tags_path}")
|
||||||
|
return 1
|
||||||
|
ids = {row[0] for row in wanted}
|
||||||
|
links = [row for row in read(links_path) if row[1] in ids]
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
present = {str(r[0]) for r in db.execute(sa_text(
|
||||||
|
"SELECT id FROM question_tags WHERE type = ANY(:t)"), {"t": list(RESTORING)}).all()}
|
||||||
|
new_tags = [row for row in wanted if row[0] not in present]
|
||||||
|
|
||||||
|
counts = {kind: sum(1 for row in new_tags if row[2] == kind) for kind in RESTORING}
|
||||||
|
print(f"in the extract {len(wanted)} tags, {len(links)} links")
|
||||||
|
print(f"already present {len(wanted) - len(new_tags)}")
|
||||||
|
for kind, n in counts.items():
|
||||||
|
print(f" {kind:<9} {n} to restore")
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
print("\ndry run. Pass --apply to write.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for row in new_tags:
|
||||||
|
db.execute(sa_text(
|
||||||
|
"INSERT INTO question_tags (id, name, type, created_at, exam_id, parent_id, sort_order) "
|
||||||
|
"VALUES (:id, :name, :type, :created_at, :exam_id, :parent_id, :sort_order) "
|
||||||
|
"ON CONFLICT (id) DO NOTHING"),
|
||||||
|
{col: value(raw) for col, raw in zip(COLUMNS, row)})
|
||||||
|
for question_id, tag_id in links:
|
||||||
|
db.execute(sa_text(
|
||||||
|
"INSERT INTO question_tag_links (question_id, tag_id) VALUES (:q, :t) "
|
||||||
|
"ON CONFLICT DO NOTHING"), {"q": int(question_id), "t": int(tag_id)})
|
||||||
|
# The sequence has to clear the ids just written, or the next tag
|
||||||
|
# created by hand collides with a restored one.
|
||||||
|
db.execute(sa_text(
|
||||||
|
"SELECT setval(pg_get_serial_sequence('question_tags', 'id'), "
|
||||||
|
"GREATEST((SELECT MAX(id) FROM question_tags), 1))"))
|
||||||
|
db.commit()
|
||||||
|
print(f"\nrestored {len(new_tags)} tags and {len(links)} links.")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--tags", required=True)
|
||||||
|
parser.add_argument("--links", required=True)
|
||||||
|
parser.add_argument("--apply", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
sys.exit(main(args.tags, args.links, args.apply))
|
||||||
|
|
@ -83,12 +83,14 @@ class TagHierarchyTests(unittest.TestCase):
|
||||||
self.assertEqual(systems["Nervous System & Special Senses"], 2)
|
self.assertEqual(systems["Nervous System & Special Senses"], 2)
|
||||||
self.assertEqual(systems["Respiratory System"], 1)
|
self.assertEqual(systems["Respiratory System"], 1)
|
||||||
|
|
||||||
def test_the_retired_vocabularies_answer_empty_rather_than_missing(self):
|
def test_keywords_are_retired_and_answer_empty(self):
|
||||||
body = self.client.get("/tags").json()
|
body = self.client.get("/tags").json()
|
||||||
# Subjects, diseases and keywords became the topic tree. The shape of
|
# Keywords were the old route to an organ system, which a topic now
|
||||||
# the response is kept so nothing reading it has to be rewritten.
|
# carries. Subjects and diseases are still served — they are simply
|
||||||
for gone in ("subjects", "diseases", "keywords"):
|
# not in this fixture. The key stays so nothing reading it breaks.
|
||||||
self.assertEqual(body[gone], [])
|
self.assertEqual(body["keywords"], [])
|
||||||
|
for kept in ("subjects", "diseases", "systems"):
|
||||||
|
self.assertIn(kept, body)
|
||||||
|
|
||||||
def test_a_moderator_can_add_a_system(self):
|
def test_a_moderator_can_add_a_system(self):
|
||||||
made = self.client.post("/tags/", json={"name": "Endocrine System", "type": "system"})
|
made = self.client.post("/tags/", json={"name": "Endocrine System", "type": "system"})
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 18px; align-items: start;
|
gap: 18px; align-items: start;
|
||||||
}
|
}
|
||||||
.an-perf-top .an-completion { margin: 24px 0 0; }
|
.an-perf-top > * { margin-top: 24px; }
|
||||||
.an-split-card {
|
.an-split-card {
|
||||||
margin-top: 24px; padding: 16px 18px 18px;
|
margin-top: 24px; padding: 16px 18px 18px;
|
||||||
border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg);
|
border: 1px solid var(--border); border-radius: 12px; background: var(--card-bg);
|
||||||
|
|
|
||||||
|
|
@ -524,11 +524,14 @@ export default function AnalysisPage() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Readiness />
|
<Readiness />
|
||||||
<OverTime />
|
{/* The chart and the donut read together — the trend beside the
|
||||||
|
split it is a trend in — and Completion is four plain figures
|
||||||
|
underneath, which is the order the page is read in. */}
|
||||||
<div className="an-perf-top">
|
<div className="an-perf-top">
|
||||||
<Completion />
|
<OverTime />
|
||||||
<AnswerSplit />
|
<AnswerSplit />
|
||||||
</div>
|
</div>
|
||||||
|
<Completion />
|
||||||
<CategoryPerformance />
|
<CategoryPerformance />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -139,11 +139,16 @@ export default function CustomQuizPage() {
|
||||||
}, [categories])
|
}, [categories])
|
||||||
|
|
||||||
const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
|
const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
|
||||||
const tagName = (id) => (tags.systems || []).find(t => t.id === id)?.name
|
const subjectTags = tags.subjects || []
|
||||||
|
const diseaseTags = tags.diseases || []
|
||||||
const systemTags = tags.systems || []
|
const systemTags = tags.systems || []
|
||||||
|
const tagName = (id) => [...subjectTags, ...diseaseTags, ...systemTags]
|
||||||
|
.find(t => t.id === id)?.name
|
||||||
|
|
||||||
const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean))
|
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 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 diseases = summarise(tagIds.map(tagName).filter(n => n && diseaseTags.some(t => t.name === n)))
|
||||||
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
|
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
|
||||||
const savedNames = [
|
const savedNames = [
|
||||||
...(state === 'bookmarked' ? ['Bookmarked questions'] : []),
|
...(state === 'bookmarked' ? ['Bookmarked questions'] : []),
|
||||||
|
|
@ -197,9 +202,14 @@ export default function CustomQuizPage() {
|
||||||
out.push({ key: `c${cat.id}`, name: cat.name, facet: 'Topics', on: categoryIds.includes(cat.id), toggle: on => toggleCategory(cat.id, on) })
|
out.push({ key: `c${cat.id}`, name: cat.name, facet: 'Topics', on: categoryIds.includes(cat.id), toggle: on => toggleCategory(cat.id, on) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const tag of systemTags) {
|
for (const [group, facet, prefix] of [[systemTags, 'Systems', 'y'],
|
||||||
|
[subjectTags, 'Disciplines', 's'],
|
||||||
|
[diseaseTags, 'Diseases', 'd']]) {
|
||||||
|
for (const tag of group) {
|
||||||
if (tag.name.toLowerCase().includes(query)) {
|
if (tag.name.toLowerCase().includes(query)) {
|
||||||
out.push({ key: `y${tag.id}`, name: tag.name, facet: 'Systems', on: tagIds.includes(tag.id), toggle: on => toggleTag(tag.id, on) })
|
out.push({ key: `${prefix}${tag.id}`, name: tag.name, facet,
|
||||||
|
on: tagIds.includes(tag.id), toggle: on => toggleTag(tag.id, on) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const article of articles) {
|
for (const article of articles) {
|
||||||
|
|
@ -208,7 +218,8 @@ export default function CustomQuizPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.slice(0, 40)
|
return out.slice(0, 40)
|
||||||
}, [globalSearch, categories, systemTags, articles, categoryIds, tagIds, articleIds])
|
}, [globalSearch, categories, systemTags, subjectTags, diseaseTags, articles,
|
||||||
|
categoryIds, tagIds, articleIds])
|
||||||
|
|
||||||
const resetTopics = () => {
|
const resetTopics = () => {
|
||||||
setCategoryIds([]); setTagIds([]); setArticleIds([]); setPresetIds([])
|
setCategoryIds([]); setTagIds([]); setArticleIds([]); setPresetIds([])
|
||||||
|
|
@ -266,6 +277,8 @@ export default function CustomQuizPage() {
|
||||||
</div>
|
</div>
|
||||||
<FacetRow label="Systems" {...organSystems} onOpen={() => setOpenFacet('organ-systems')} />
|
<FacetRow label="Systems" {...organSystems} onOpen={() => setOpenFacet('organ-systems')} />
|
||||||
<FacetRow label="Topics" {...systems} onOpen={() => setOpenFacet('systems')} />
|
<FacetRow label="Topics" {...systems} onOpen={() => setOpenFacet('systems')} />
|
||||||
|
<FacetRow label="Disciplines" {...disciplines} onOpen={() => setOpenFacet('disciplines')} />
|
||||||
|
<FacetRow label="Diseases" {...diseases} onOpen={() => setOpenFacet('diseases')} />
|
||||||
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
|
<FacetRow label="Articles" {...articleSummary} onOpen={() => setOpenFacet('articles')} />
|
||||||
<FacetRow label="Saved questions" {...saved} onOpen={() => setOpenFacet('saved')} />
|
<FacetRow label="Saved questions" {...saved} onOpen={() => setOpenFacet('saved')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -405,11 +418,26 @@ export default function CustomQuizPage() {
|
||||||
)}
|
)}
|
||||||
</FacetPicker>
|
</FacetPicker>
|
||||||
|
|
||||||
{/* Disciplines and Symptoms & keywords were two more pickers here, over
|
{/* Symptoms & keywords used to sit here too, over 4,281 keyword tags.
|
||||||
the 203 subject tags and 4,281 keyword tags a model had written per
|
Those were the old route to an organ system, which a topic now
|
||||||
question. Both vocabularies were converted into Topics — 673 of them,
|
carries directly, and they are retired. Disciplines and Diseases are
|
||||||
in a tree, which is the same axis said once and said better — and
|
not: the topic tree mostly says the same thing, and "mostly" is not a
|
||||||
have been retired. Topics is where a discipline is chosen. */}
|
reason to take away the vocabulary people filter by. */}
|
||||||
|
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => setOpenFacet(null)}
|
||||||
|
onReset={() => setTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
|
||||||
|
selected={subjectTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))}
|
||||||
|
onRemove={id => toggleTag(id, false)}
|
||||||
|
helper="By default, all disciplines are included unless filters are selected.">
|
||||||
|
{query => checkList(subjectTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
|
||||||
|
</FacetPicker>
|
||||||
|
|
||||||
|
<FacetPicker title="Diseases" open={openFacet === 'diseases'} onClose={() => setOpenFacet(null)}
|
||||||
|
onReset={() => setTagIds(ids => ids.filter(id => !diseaseTags.some(t => t.id === id)))}
|
||||||
|
selected={diseaseTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))}
|
||||||
|
onRemove={id => toggleTag(id, false)}
|
||||||
|
helper="Named conditions. By default all are included.">
|
||||||
|
{query => checkList(diseaseTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)}
|
||||||
|
</FacetPicker>
|
||||||
|
|
||||||
<FacetPicker title="Articles" open={openFacet === 'articles'} onClose={() => setOpenFacet(null)}
|
<FacetPicker title="Articles" open={openFacet === 'articles'} onClose={() => setOpenFacet(null)}
|
||||||
onReset={() => setArticleIds([])}
|
onReset={() => setArticleIds([])}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue