feat: knowledge profile grouped by Articles, Systems or Disciplines
The same answers asked three ways, as AMBOSS does it: which reading to
go back to, which organ system is weak, which discipline is weak. It was
Systems/Subtopics, where "Systems" meant top-level categories — which
are disciplines, not systems — and "Subtopics" meant every category
below them.
* Articles (the default): rows are the published article behind a
category, so the row links straight to the reading.
* Systems: the 16 organ systems. No question is tagged with a system
directly — it carries a symptom keyword filed under one — so
membership rolls up through the keyword's parent.
* Disciplines: top-level categories, which is what the old "systems"
grouping actually was.
Only 1,502 of 2,948 questions carry a system tag, so the Systems tab
says so rather than showing half the bank as if it were the whole of it,
and relevance there is measured against what the grouping can see.
"Practise this topic" now practises the row you are looking at, on its
own axis. That needed system_ids on the builder — matched as "any tag
beneath this system", where the existing tag_ids is "every one of these
tags", so the two cannot be conflated.
Backend 228/228, frontend 266/266.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
9bc202d967
commit
b06da68f6b
9 changed files with 217 additions and 56 deletions
|
|
@ -528,12 +528,15 @@ def count_builder_questions(
|
|||
difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
|
||||
article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
|
||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
||||
system_ids: str | None = Query(None, description="Comma-separated organ system IDs (OR filter)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
ids = [int(part) for part in (article_ids or "").split(",") if part.strip().isdigit()]
|
||||
tag_list = [int(part) for part in (tag_ids or "").split(",") if part.strip().isdigit()]
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty, ids, tag_list).count()}
|
||||
systems = [int(part) for part in (system_ids or "").split(",") if part.strip().isdigit()]
|
||||
return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared,
|
||||
difficulty, ids, tag_list, systems).count()}
|
||||
|
||||
|
||||
@router.post("/builder")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from typing import Literal
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import func, inspect, or_
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.exam import Exam
|
||||
|
|
@ -239,7 +240,7 @@ def _category_rollup(categories):
|
|||
|
||||
@router.get("/recommendations")
|
||||
def study_recommendations(
|
||||
group: Literal["systems", "subtopics"] = "systems",
|
||||
group: Literal["articles", "disciplines", "systems"] = "disciplines",
|
||||
limit: int = Query(20, ge=1, le=60),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
|
|
@ -291,58 +292,130 @@ def study_recommendations(
|
|||
rolled.update(ancestry.get(cid, [cid]))
|
||||
return rolled
|
||||
|
||||
# An article is reached through the category it is filed under.
|
||||
articles: dict[int, Article] = {}
|
||||
for article in db.query(Article).filter(Article.status == "published",
|
||||
Article.category_id.isnot(None)).all():
|
||||
articles.setdefault(article.category_id, article)
|
||||
|
||||
# A question reaches an organ system through the symptom keyword it carries,
|
||||
# which is filed under one. No question is tagged with a system directly.
|
||||
# Raw SQL because question_tags has no ORM model; the tags router is the
|
||||
# same, and one spelling of this table is better than two. Only read when
|
||||
# the systems grouping is asked for — and only when the table is there,
|
||||
# since it is not part of the ORM metadata a fresh database is built from.
|
||||
system_names: dict[int, str] = {}
|
||||
systems_of_question: dict[int, set[int]] = defaultdict(set)
|
||||
if group == "systems" and inspect(db.get_bind()).has_table("question_tags"):
|
||||
for tag_id, name in db.execute(sa_text(
|
||||
"SELECT id, name FROM question_tags WHERE type = 'system' AND parent_id IS NULL")).all():
|
||||
system_names[tag_id] = name
|
||||
if system_names:
|
||||
for question_id, parent in db.execute(sa_text('''
|
||||
SELECT l.question_id, t.parent_id
|
||||
FROM question_tag_links l
|
||||
JOIN question_tags t ON t.id = l.tag_id
|
||||
WHERE t.parent_id IS NOT NULL
|
||||
''')).all():
|
||||
if parent in system_names:
|
||||
systems_of_question[question_id].add(parent)
|
||||
|
||||
# Three ways to ask the same question of the same answers: which reading to
|
||||
# go back to, which discipline is weak, which organ system is weak. The
|
||||
# scoring below does not care which; only the membership differs.
|
||||
def groups_for(question_id, primary) -> set[int]:
|
||||
if group == "systems":
|
||||
return systems_of_question.get(question_id, set())
|
||||
rolled = categories_for(question_id, primary)
|
||||
if group == "articles":
|
||||
return {articles[cid].id for cid in rolled if cid in articles}
|
||||
return rolled & top_level
|
||||
|
||||
answered: dict[int, int] = defaultdict(int)
|
||||
correct: dict[int, int] = defaultdict(int)
|
||||
seen_questions: dict[int, set[int]] = defaultdict(set)
|
||||
total_answers = len(answered_rows)
|
||||
total_correct = sum(1 for _, is_correct, _ in answered_rows if is_correct)
|
||||
for question_id, is_correct, primary in answered_rows:
|
||||
for cid in categories_for(question_id, primary):
|
||||
answered[cid] += 1
|
||||
seen_questions[cid].add(question_id)
|
||||
for key in groups_for(question_id, primary):
|
||||
answered[key] += 1
|
||||
seen_questions[key].add(question_id)
|
||||
if is_correct:
|
||||
correct[cid] += 1
|
||||
correct[key] += 1
|
||||
|
||||
# ── How much bank material each category holds ─────────────────
|
||||
# ── How much bank material each group holds ────────────────────
|
||||
available: dict[int, int] = defaultdict(int)
|
||||
bank_total = 0
|
||||
grouped_total = 0
|
||||
for question_id, primary in db.query(Question.id, Question.question_category_id).filter(
|
||||
shareable_question_predicate(),
|
||||
*([exam_filter] if exam_filter is not None else [])).all():
|
||||
bank_total += 1
|
||||
for cid in categories_for(question_id, primary):
|
||||
available[cid] += 1
|
||||
|
||||
articles = {}
|
||||
for article in db.query(Article).filter(Article.status == "published",
|
||||
Article.category_id.isnot(None)).all():
|
||||
articles.setdefault(article.category_id, article)
|
||||
keys = groups_for(question_id, primary)
|
||||
if keys:
|
||||
grouped_total += 1
|
||||
for key in keys:
|
||||
available[key] += 1
|
||||
|
||||
overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
|
||||
unlocked = total_answers >= READINESS_UNLOCK_ANSWERS
|
||||
|
||||
scope = top_level if group == "systems" else {cat.id for cat in categories if cat.parent_id is not None}
|
||||
# What each row is called, and what it links on to.
|
||||
article_by_id = {a.id: a for a in articles.values()}
|
||||
category_of_article = {a.id: cid for cid, a in articles.items()}
|
||||
|
||||
def describe(key):
|
||||
if group == "articles":
|
||||
article = article_by_id.get(key)
|
||||
category_id = category_of_article.get(key)
|
||||
return {
|
||||
"name": article.title if article else "Untitled",
|
||||
"parent_name": names.get(parents.get(category_id)) if category_id else None,
|
||||
"category_id": category_id,
|
||||
"article_id": key,
|
||||
"article_title": article.title if article else None,
|
||||
"system_id": None,
|
||||
}
|
||||
if group == "systems":
|
||||
return {
|
||||
"name": system_names.get(key, "Unfiled"),
|
||||
"parent_name": None,
|
||||
"category_id": None,
|
||||
"article_id": None,
|
||||
"article_title": None,
|
||||
"system_id": key,
|
||||
}
|
||||
article = articles.get(key)
|
||||
return {
|
||||
"name": names.get(key, "Uncategorized"),
|
||||
"parent_name": names.get(parents.get(key)),
|
||||
"category_id": key,
|
||||
"article_id": article.id if article else None,
|
||||
"article_title": article.title if article else None,
|
||||
"system_id": None,
|
||||
}
|
||||
|
||||
rows = []
|
||||
for cid in scope:
|
||||
seen = len(seen_questions.get(cid, ()))
|
||||
pool = available.get(cid, 0)
|
||||
for key in set(available) | set(seen_questions):
|
||||
seen = len(seen_questions.get(key, ()))
|
||||
pool = available.get(key, 0)
|
||||
if pool == 0 and seen == 0:
|
||||
continue
|
||||
n = answered.get(cid, 0)
|
||||
c = correct.get(cid, 0)
|
||||
n = answered.get(key, 0)
|
||||
c = correct.get(key, 0)
|
||||
accuracy = round(100 * c / n, 1) if n else None
|
||||
readiness = None
|
||||
if unlocked and n:
|
||||
shrunk = (c + READINESS_PRIOR_ANSWERS * overall_accuracy) / (n + READINESS_PRIOR_ANSWERS)
|
||||
readiness = round(100 * shrunk, 1)
|
||||
relevance = round(100 * pool / bank_total, 1) if bank_total else 0.0
|
||||
# Relevance is measured against the material this grouping can see, not
|
||||
# the whole bank: half the bank carries no system tag, and dividing by
|
||||
# the whole bank would make every system look half as relevant as it is.
|
||||
denominator = grouped_total if group == "systems" else bank_total
|
||||
relevance = round(100 * pool / denominator, 1) if denominator else 0.0
|
||||
coverage = round(100 * seen / pool, 1) if pool else 0.0
|
||||
article = articles.get(cid)
|
||||
rows.append({
|
||||
"category_id": cid,
|
||||
"name": names.get(cid, "Uncategorized"),
|
||||
"parent_id": parents.get(cid),
|
||||
"parent_name": names.get(parents.get(cid)),
|
||||
"key": key,
|
||||
"answered": n,
|
||||
"correct": c,
|
||||
"seen_questions": seen,
|
||||
|
|
@ -352,8 +425,7 @@ def study_recommendations(
|
|||
"readiness": readiness,
|
||||
"relevance": relevance,
|
||||
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
|
||||
"article_id": article.id if article else None,
|
||||
"article_title": article.title if article else None,
|
||||
**describe(key),
|
||||
})
|
||||
|
||||
# Priority: weak-and-relevant first, then untouched material by relevance.
|
||||
|
|
@ -364,12 +436,9 @@ def study_recommendations(
|
|||
unseen = 1 - (row["coverage"] / 100)
|
||||
row["priority"] = round(max(gap, 0.0) * (row["relevance"] / 100) + 0.25 * unseen * (row["relevance"] / 100), 5)
|
||||
rows.sort(key=lambda row: (-row["priority"], -row["relevance"], row["name"]))
|
||||
focus_ids = {row["category_id"] for row in rows[:3] if row["answered"]}
|
||||
focus_keys = {row["key"] for row in rows[:3] if row["answered"]}
|
||||
for row in rows:
|
||||
if row["category_id"] in focus_ids:
|
||||
row["is_focus_area"] = True
|
||||
else:
|
||||
row["is_focus_area"] = False
|
||||
row["is_focus_area"] = row["key"] in focus_keys
|
||||
|
||||
return {
|
||||
"group": group,
|
||||
|
|
@ -380,6 +449,8 @@ def study_recommendations(
|
|||
"total_answered": total_answers,
|
||||
"unique_questions_seen": len({row[0] for row in answered_rows}),
|
||||
"bank_total": bank_total,
|
||||
# For systems: how much of the bank this grouping can actually see.
|
||||
"grouped_total": grouped_total,
|
||||
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
|
||||
"focus_areas": rows[:limit],
|
||||
"basis": (
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ def bank_query(db, user):
|
|||
return db.query(Question).filter(bank_question_predicate(user))
|
||||
|
||||
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=()):
|
||||
def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=(), system_ids=()):
|
||||
query = bank_query(db, user)
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
|
|
@ -107,6 +107,21 @@ def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, di
|
|||
query = query.filter(Question.id.in_(matching))
|
||||
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"):
|
||||
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))
|
||||
if category_ids:
|
||||
ids = category_descendants(db.query(QuestionCategory).all(), category_ids)
|
||||
query = query.filter(or_(
|
||||
|
|
@ -166,6 +181,9 @@ class GenerateTestRequest(TestOptions):
|
|||
algorithm: Literal["random", "adaptive"] = "random"
|
||||
article_ids: list[int] = Field(default_factory=list)
|
||||
tag_ids: list[int] = Field(default_factory=list)
|
||||
#: Organ systems. Matched as "any tag beneath this system", where
|
||||
#: tag_ids is "every one of these tags".
|
||||
system_ids: list[int] = Field(default_factory=list)
|
||||
explicit_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
|
@ -243,7 +261,8 @@ def generate_test(db, user, data):
|
|||
if len(ids) < data.count:
|
||||
raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}")
|
||||
return create_saved_test(db, user, data, ids)
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared, data.difficulty, data.article_ids, data.tag_ids)
|
||||
query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared,
|
||||
data.difficulty, data.article_ids, data.tag_ids, data.system_ids)
|
||||
if data.explicit_ids:
|
||||
query = query.filter(Question.id.in_(list(dict.fromkeys(data.explicit_ids))))
|
||||
ids = [row[0] for row in query.with_entities(Question.id).all()]
|
||||
|
|
|
|||
|
|
@ -286,17 +286,36 @@ class RecommendationTests(unittest.TestCase):
|
|||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_relevance_and_coverage_roll_up_to_the_parent_system(self):
|
||||
def test_relevance_and_coverage_roll_up_to_the_discipline(self):
|
||||
data = self.recommend()
|
||||
self.assertEqual(data["group"], "disciplines")
|
||||
rows = {row["name"]: row for row in data["focus_areas"]}
|
||||
# Cardiology owns 4 of 6 bank questions through its child category.
|
||||
self.assertEqual(rows["Cardiology"]["available"], 4)
|
||||
self.assertEqual(rows["Cardiology"]["relevance"], 66.7)
|
||||
self.assertEqual(rows["Neurology"]["available"], 2)
|
||||
self.assertEqual(data["bank_total"], 6)
|
||||
# Systems grouping shows top-level categories only.
|
||||
# Disciplines are the top of the tree; a condition under one is not a row.
|
||||
self.assertNotIn("Kawasaki disease", rows)
|
||||
self.assertIn("Kawasaki disease", {r["name"] for r in self.recommend(group="subtopics")["focus_areas"]})
|
||||
|
||||
def test_the_same_answers_group_three_ways(self):
|
||||
# Articles: the reading to go back to, reached through its category.
|
||||
articles = self.recommend(group="articles")["focus_areas"]
|
||||
self.assertEqual([row["name"] for row in articles], ["Kawasaki disease"])
|
||||
row = articles[0]
|
||||
self.assertEqual((row["article_id"], row["category_id"]), (7, 1))
|
||||
self.assertEqual(row["available"], 4)
|
||||
|
||||
# Systems: no tag table on a fresh database, so there is nothing to
|
||||
# show — and that is reported as no rows, not as an error.
|
||||
systems = self.recommend(group="systems")
|
||||
self.assertEqual(systems["group"], "systems")
|
||||
self.assertEqual(systems["focus_areas"], [])
|
||||
|
||||
def test_an_unknown_grouping_is_refused_rather_than_guessed(self):
|
||||
self.assertEqual(
|
||||
self.client.get("/study-tools/recommendations", params={"group": "subtopics"}).status_code,
|
||||
422)
|
||||
|
||||
def test_readiness_stays_locked_until_enough_answers(self):
|
||||
self.answer(1, False)
|
||||
|
|
|
|||
|
|
@ -111,3 +111,10 @@
|
|||
|
||||
.an-exam { color: var(--primary); text-decoration: none; border-bottom: 2px solid currentColor; }
|
||||
.an-exam:hover { opacity: .8; }
|
||||
|
||||
/* How much of the bank a grouping can actually see. */
|
||||
.an-coverage-note {
|
||||
margin: 0 0 12px; padding: 9px 12px; font-size: 0.8rem; line-height: 1.55;
|
||||
color: var(--text-muted); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import './AnalysisPage.css'
|
|||
|
||||
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
|
||||
|
||||
function FocusRow({ row, onStartCategory }) {
|
||||
function FocusRow({ row, onPractise }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const coverageLabel = `${row.seen_questions}/${row.available}`
|
||||
const accuracyLabel = row.answered ? `${row.accuracy}% (${row.correct} of ${row.answered})` : 'Not attempted'
|
||||
|
|
@ -52,7 +52,10 @@ function FocusRow({ row, onStartCategory }) {
|
|||
Read {row.article_title}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onStartCategory(row.category_id)}>
|
||||
{/* Practise the row you are looking at, on its own axis: an
|
||||
article's questions, a discipline's, or everything filed under
|
||||
an organ system. */}
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onPractise(row)}>
|
||||
Practise this topic
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -64,7 +67,7 @@ function FocusRow({ row, onStartCategory }) {
|
|||
|
||||
export default function AnalysisPage() {
|
||||
const [tab, setTab] = useState('recommendations')
|
||||
const [group, setGroup] = useState('systems')
|
||||
const [group, setGroup] = useState('articles')
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
|
@ -84,7 +87,12 @@ export default function AnalysisPage() {
|
|||
|
||||
|
||||
const startAdaptive = () => navigate(`/study/new?adaptive=1&count=${count}`)
|
||||
const startCategory = (categoryId) => navigate(`/study/new?category=${categoryId}&count=${count}`)
|
||||
const startPractice = (row) => {
|
||||
const param = row.system_id ? `system=${row.system_id}`
|
||||
: row.article_id && group === 'articles' ? `article=${row.article_id}`
|
||||
: `category=${row.category_id}`
|
||||
navigate(`/study/new?${param}&count=${count}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnalysisShell>
|
||||
|
|
@ -161,12 +169,24 @@ export default function AnalysisPage() {
|
|||
|
||||
{tab === 'recommendations' ? (
|
||||
<>
|
||||
<div className="an-group-toggle">
|
||||
{[['systems', 'Systems'], ['subtopics', 'Subtopics']].map(([value, label]) => (
|
||||
<button key={value} aria-pressed={group === value} onClick={() => setGroup(value)}>{label}</button>
|
||||
{/* The same answers asked three ways: which reading to go back
|
||||
to, which discipline is weak, which organ system is weak. */}
|
||||
<div className="an-group-toggle" role="tablist" aria-label="Group knowledge profile by">
|
||||
{[['articles', 'Articles'], ['systems', 'Systems'], ['disciplines', 'Disciplines']].map(([value, label]) => (
|
||||
<button key={value} role="tab" aria-selected={group === value}
|
||||
aria-pressed={group === value} onClick={() => setGroup(value)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Only about half the bank carries an organ-system tag. Saying so
|
||||
is better than showing a partial picture as if it were whole. */}
|
||||
{group === 'systems' && data.grouped_total < data.bank_total && (
|
||||
<p className="an-coverage-note">
|
||||
Systems covers {data.grouped_total} of {data.bank_total} questions —
|
||||
the rest are not filed under an organ system yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.focus_areas.length === 0 ? (
|
||||
<div className="an-empty">No categorised questions yet — once questions are filed under a system, your focus areas appear here.</div>
|
||||
) : (
|
||||
|
|
@ -180,7 +200,7 @@ export default function AnalysisPage() {
|
|||
</div>
|
||||
<div className="an-focus-list">
|
||||
{data.focus_areas.map(row => (
|
||||
<FocusRow key={row.category_id} row={row} onStartCategory={startCategory} />
|
||||
<FocusRow key={row.key} row={row} onPractise={startPractice} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ const area = (over = {}) => ({
|
|||
})
|
||||
|
||||
const payload = (over = {}) => ({
|
||||
group: 'systems', unlocked: true, answers_needed: 0, total_answered: 60,
|
||||
unique_questions_seen: 42, bank_total: 330, overall_accuracy: 64.0,
|
||||
group: 'articles', unlocked: true, answers_needed: 0, total_answered: 60,
|
||||
unique_questions_seen: 42, bank_total: 330, grouped_total: 330, overall_accuracy: 64.0,
|
||||
focus_areas: [area()], basis: 'Readiness shrinks each category toward your overall accuracy.', ...over,
|
||||
})
|
||||
|
||||
|
|
@ -56,12 +56,26 @@ it('explains that readiness is locked until enough answers exist', async () => {
|
|||
expect(screen.getByText(/Answer 12 more questions/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reloads focus areas when the grouping changes', async () => {
|
||||
it('asks the same answers three ways: articles, systems, disciplines', async () => {
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await screen.findByText('Cardiology')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Subtopics' }))
|
||||
// Articles first: the reading to go back to is the most actionable answer.
|
||||
expect(api.get).toHaveBeenCalledWith('/study-tools/recommendations', { params: { group: 'articles' } })
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Systems' }))
|
||||
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith('/study-tools/recommendations',
|
||||
{ params: { group: 'subtopics' } }))
|
||||
{ params: { group: 'systems' } }))
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Disciplines' }))
|
||||
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith('/study-tools/recommendations',
|
||||
{ params: { group: 'disciplines' } }))
|
||||
})
|
||||
|
||||
it('says how much of the bank the systems grouping can actually see', async () => {
|
||||
api.get.mockResolvedValue({ data: payload({ group: 'systems', bank_total: 2948, grouped_total: 1502 }) })
|
||||
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Systems' }))
|
||||
expect(await screen.findByText(/Systems covers 1502 of 2948 questions/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the performance tab', async () => {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,14 @@ export default function CustomQuizPage() {
|
|||
const [submitting, setSubmitting] = useState(false)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
|
||||
const [articleIds, setArticleIds] = useState([])
|
||||
// Arriving from a knowledge-profile row: practise that article, or
|
||||
// everything filed under that organ system. A system is not a checkbox in
|
||||
// the picker — it is an axis over the symptom tags — so it is carried as an
|
||||
// opaque filter rather than shown as a selection.
|
||||
const [articleIds, setArticleIds] = useState(
|
||||
() => [...new Set(searchParams.getAll('article').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
|
||||
const [systemIds] = useState(
|
||||
() => [...new Set(searchParams.getAll('system').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
|
||||
const [tagIds, setTagIds] = useState([])
|
||||
const [articles, setArticles] = useState([])
|
||||
const [tags, setTags] = useState({ subjects: [], keywords: [] })
|
||||
|
|
@ -52,7 +59,7 @@ export default function CustomQuizPage() {
|
|||
const [globalSearch, setGlobalSearch] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, refresh])
|
||||
const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, systemIds, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
|
@ -76,6 +83,7 @@ export default function CustomQuizPage() {
|
|||
categoryIds.forEach(id => params.append('category_ids', id))
|
||||
articleIds.forEach(id => params.append('article_ids', id))
|
||||
tagIds.forEach(id => params.append('tag_ids', id))
|
||||
if (systemIds.length) params.append('system_ids', systemIds.join(','))
|
||||
api.get('/questions/builder/count', { params }).then(r => {
|
||||
if (active) { setAvailable(r.data.count); setCountKey(filterKey) }
|
||||
}).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') })
|
||||
|
|
@ -105,7 +113,7 @@ export default function CustomQuizPage() {
|
|||
title: title.trim(), category_ids: categoryIds, state, count: Number(count),
|
||||
expected_count: available, mode, time_limit_minutes: mode === 'timed' && time ? Number(time) : null,
|
||||
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
|
||||
article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds,
|
||||
article_ids: articleIds, tag_ids: tagIds, system_ids: systemIds, explicit_ids: explicitIds,
|
||||
})
|
||||
navigate(`/study/${result.data.id}`)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ describe('CustomQuizPage', () => {
|
|||
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
|
||||
title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${new Date().toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`, category_ids: [1, 2], state: 'unused', count: 10,
|
||||
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
|
||||
difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], explicit_ids: [],
|
||||
difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], system_ids: [], explicit_ids: [],
|
||||
})
|
||||
await screen.findByRole('heading', { name: 'Saved test' })
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue