From b80e188eaea7db101bb63803946763c76deb1c5c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 03:20:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20one=20session's=20topics,=20asked=20the?= =?UTF-8?q?=20same=20three=20ways=20=E2=80=94=20and=20a=20way=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session analysis ranked its weakest topics by primary category only, while the Analysis page asked the same question three ways and rolled answers up the category tree. Two sets of rules for "where does this question belong" is two pages that can disagree about a learner and neither able to explain why. So the rules moved to services/knowledge_groups.py: ancestor roll-up, article reached through its category, organ system reached through the symptom keyword. study_tools now asks that service instead of building the lookups inline, and GET /attempts/{id}/recommendations gives one session the same Articles / Disciplines / Systems switch. Grouping is its own call, so changing it does not re-read the question table and the peer statistics beside it. A running exam ranks nothing — marking it there would answer the question the exam is asking. The ungrouped `recommendations` key is gone from the analysis payload along with the code that built it. And the document page had no way back. It is reached from the Tools workbench, which by design has no menu of its own, so leaving it meant the browser button. It opens onto Tools now, as Tools opens onto Settings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/attempts.py | 54 ++++-- backend/app/routers/study_tools.py | 111 +----------- backend/app/services/knowledge_groups.py | 164 ++++++++++++++++++ backend/tests/test_session_lifecycle.py | 10 +- backend/tests/test_study_tools.py | 84 +++++++++ docs/TODO.md | 17 +- frontend/src/components/AnalysisShell.css | 12 ++ frontend/src/pages/AnalysisPage.css | 7 - frontend/src/pages/AnalysisPage.jsx | 2 +- frontend/src/pages/AnalysisSessionPage.jsx | 49 +++++- .../src/pages/AnalysisSessionPage.test.jsx | 42 ++++- frontend/src/pages/DocumentDetailPage.css | 5 + frontend/src/pages/DocumentDetailPage.jsx | 3 + 13 files changed, 417 insertions(+), 143 deletions(-) create mode 100644 backend/app/services/knowledge_groups.py diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 39c63f4..a55ab59 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from sqlalchemy import case, func from app.services.attempt_expiry import active_key, load_saved, progress_key, settle_if_expired +from app.services.knowledge_groups import Grouping, score_rows from app.services.question_figures import figures_for_questions from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes, unmark_block_complete from app.database import get_db @@ -748,9 +749,6 @@ def quiz_analysis( "seconds_total": None, "seconds_per_question": None, "questions": detail, - # Nothing answered is nothing to recommend from. An empty list says that - # more plainly than a list of every topic at zero per cent would. - "recommendations": [], "plan": plan_context_for_quizzes(db, current_user.id, [quiz_id]).get(quiz_id), "not_started": True, } @@ -824,6 +822,39 @@ def _rows_from_progress(db: Session, user_id: int, attempt: QuizAttempt) -> list return [_LiveAnswer(question.id, answer, correct) for question, answer, correct in graded] +@router.get("/{attempt_id}/recommendations") +def attempt_recommendations( + attempt_id: int, + group: Literal["articles", "disciplines", "systems"] = "disciplines", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """This session's weakest topics, asked three ways. + + Which reading to go back to, which discipline is weak, which organ system + is weak — the same rules the Analysis page uses, from the same place, so + the two cannot disagree about where a question belongs. + + A session still running is not marked, so there is nothing to rank: an + exam would be answering the question it is asking. + """ + attempt = db.query(QuizAttempt).filter( + QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id).first() + if not attempt: + raise HTTPException(404, "Attempt not found") + + graded = attempt.completed_at is not None or attempt.mode != "exam" + rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all() if graded else [] + question_ids = [row.question_id for row in rows if row.user_answer] + primary = dict(db.query(Question.id, Question.question_category_id).filter( + Question.id.in_(question_ids)).all()) if question_ids else {} + + grouping = Grouping(db, group) + marks = [(row.question_id, primary.get(row.question_id), bool(row.is_correct)) + for row in rows if row.user_answer] + return {"group": group, "rows": score_rows(grouping, marks)} + + @router.get("/{attempt_id}/analysis") def attempt_analysis( attempt_id: int, @@ -917,19 +948,6 @@ def attempt_analysis( if attempt.completed_at and attempt.started_at: elapsed = int((attempt.completed_at - attempt.started_at).total_seconds()) - # Where to go back to, worst first, counting only what was actually attempted. - by_category: dict[str, list[bool]] = {} - for row in rows: - question = questions.get(row.question_id) - name = categories.get(getattr(question, "question_category_id", None)) - if name and row.user_answer and graded: - by_category.setdefault(name, []).append(bool(row.is_correct)) - recommendations = sorted( - ({"name": name, "correct": sum(marks), "total": len(marks), - "percent": round(100 * sum(marks) / len(marks))} for name, marks in by_category.items()), - key=lambda row: (row["percent"], -row["total"]), - ) - return { "attempt_id": attempt.id, "quiz_id": attempt.quiz_id, @@ -946,7 +964,9 @@ def attempt_analysis( "seconds_total": elapsed, "seconds_per_question": round(sum(timed) / len(timed)) if timed else None, "questions": detail, - "recommendations": recommendations[:8], + # Where to go back to is its own call now — /recommendations, grouped + # by article, discipline or system — so changing the grouping does not + # re-read the question table and the peer statistics beside it. # Present when a study-plan block produced this session: the way back # to the plan and on to the next block. "plan": plan_context_for_quizzes(db, current_user.id, [attempt.quiz_id]).get(attempt.quiz_id), diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 30365ec..ea43b77 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -10,6 +10,7 @@ from sqlalchemy import text as sa_text from sqlalchemy.orm import Session from app.models.exam import Exam +from app.services.knowledge_groups import Grouping from app.services.quiz_builder import exam_scope_predicate from app.database import get_db from app.models.article import Article @@ -228,20 +229,6 @@ READINESS_UNLOCK_ANSWERS = 40 READINESS_PRIOR_ANSWERS = 8 -def _category_rollup(categories): - """Map every category to itself plus all of its ancestors, for roll-up counting.""" - parents = {cat.id: cat.parent_id for cat in categories} - ancestry: dict[int, list[int]] = {} - for cid in parents: - chain, cursor, guard = [], cid, 0 - while cursor is not None and guard < 12: - chain.append(cursor) - cursor = parents.get(cursor) - guard += 1 - ancestry[cid] = chain - return ancestry - - @router.get("/completion") def completion( days: int | None = Query(None, ge=1, le=3650), @@ -555,10 +542,12 @@ def study_recommendations( active_exam = db.get(Exam, user.active_exam_id) if getattr(user, "active_exam_id", None) else None categories = db.query(QuestionCategory).all() - ancestry = _category_rollup(categories) - names = {cat.id: cat.name for cat in categories} - parents = {cat.id: cat.parent_id for cat in categories} - top_level = {cat.id for cat in categories if cat.parent_id is None} + # Membership — which article, discipline or system a question counts + # towards — is the same question a session analysis asks, so both ask it + # of the same place. + grouping = Grouping(db, group, categories) + names = grouping.names + parents = grouping.parents # ── What the learner has answered ────────────────────────────── answered_rows = db.query( @@ -578,56 +567,7 @@ def study_recommendations( *([exam_filter] if exam_filter is not None else []), ).all() - extra_links: dict[int, set[int]] = defaultdict(set) - for question_id, category_id in db.query( - QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all(): - extra_links[question_id].add(category_id) - - def categories_for(question_id, primary): - direct = extra_links.get(question_id, set()) | ({primary} if primary else set()) - rolled = set() - for cid in direct: - 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 + groups_for = grouping.keys_for answered: dict[int, int] = defaultdict(int) correct: dict[int, int] = defaultdict(int) @@ -658,40 +598,7 @@ def study_recommendations( overall_accuracy = (total_correct / total_answers) if total_answers else 0.0 unlocked = total_answers >= READINESS_UNLOCK_ANSWERS - # 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, - } + describe = grouping.describe # ── Relevance from the board's own outline ─────────────────────────── # A topic's relevance is the share of the real paper it accounts for, which diff --git a/backend/app/services/knowledge_groups.py b/backend/app/services/knowledge_groups.py new file mode 100644 index 0000000..54d6b4c --- /dev/null +++ b/backend/app/services/knowledge_groups.py @@ -0,0 +1,164 @@ +"""Three ways to ask the same question of the same answers. + +Which reading to go back to (**articles**), which discipline is weak +(**disciplines**), which organ system is weak (**systems**). Only the +membership differs; whatever is being counted — a lifetime of answers on the +Analysis page, or the dozen questions in one session — the rules for what +belongs where must be the same, or the two pages disagree about a learner in +ways neither can explain. + +So the rules live here once, and both callers ask this. +""" +from collections import defaultdict + +from sqlalchemy import inspect +from sqlalchemy import text as sa_text +from sqlalchemy.orm import Session + +from app.models.article import Article +from app.models.question_category import QuestionCategory, QuestionCategoryLink + +GROUPS = ("articles", "disciplines", "systems") + + +def category_ancestry(categories) -> dict[int, list[int]]: + """Every category with the chain of parents above it, itself included. + + A question filed under "Neonatal jaundice" counts towards "Neonatology" + too, or a discipline would only ever score on questions filed at its own + level — which is none of them. + """ + parents = {cat.id: cat.parent_id for cat in categories} + ancestry: dict[int, list[int]] = {} + for cid in parents: + chain, walk, guard = [], cid, 0 + # Guarded rather than trusted: a cycle in the tree would hang the page, + # and the depth of a real taxonomy is nowhere near this. + while walk is not None and guard < 20: + chain.append(walk) + walk = parents.get(walk) + guard += 1 + ancestry[cid] = chain + return ancestry + + +class Grouping: + """The membership rules for one grouping, built once and asked many times.""" + + def __init__(self, db: Session, group: str, categories=None): + if group not in GROUPS: + raise ValueError(f"unknown grouping: {group}") + self.group = group + self.categories = categories if categories is not None else db.query(QuestionCategory).all() + self.names = {cat.id: cat.name for cat in self.categories} + self.parents = {cat.id: cat.parent_id for cat in self.categories} + self.top_level = {cat.id for cat in self.categories if cat.parent_id is None} + self.ancestry = category_ancestry(self.categories) + + self.extra_links: dict[int, set[int]] = defaultdict(set) + for question_id, category_id in db.query( + QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all(): + self.extra_links[question_id].add(category_id) + + # An article is reached through the category it is filed under. + self.articles: dict[int, Article] = {} + for article in db.query(Article).filter(Article.status == "published", + Article.category_id.isnot(None)).all(): + self.articles.setdefault(article.category_id, article) + self.article_by_id = {a.id: a for a in self.articles.values()} + self.category_of_article = {a.id: cid for cid, a in self.articles.items()} + + # 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 for the systems grouping, and only when the table + # is there — it is not part of the ORM metadata a fresh database is + # built from. + self.system_names: dict[int, str] = {} + self.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(): + self.system_names[tag_id] = name + if self.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 self.system_names: + self.systems_of_question[question_id].add(parent) + + def categories_for(self, question_id: int, primary: int | None) -> set[int]: + """Every category a question counts towards, ancestors included.""" + direct = self.extra_links.get(question_id, set()) | ({primary} if primary else set()) + rolled: set[int] = set() + for cid in direct: + rolled.update(self.ancestry.get(cid, [cid])) + return rolled + + def keys_for(self, question_id: int, primary: int | None) -> set[int]: + """Which rows of this grouping the question belongs to.""" + if self.group == "systems": + return self.systems_of_question.get(question_id, set()) + rolled = self.categories_for(question_id, primary) + if self.group == "articles": + return {self.articles[cid].id for cid in rolled if cid in self.articles} + return rolled & self.top_level + + def describe(self, key: int) -> dict: + """What one row is called, and what it links on to.""" + if self.group == "articles": + article = self.article_by_id.get(key) + category_id = self.category_of_article.get(key) + return { + "name": article.title if article else "Untitled", + "parent_name": self.names.get(self.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 self.group == "systems": + return { + "name": self.system_names.get(key, "Unfiled"), + "parent_name": None, + "category_id": None, + "article_id": None, + "article_title": None, + "system_id": key, + } + article = self.articles.get(key) + return { + "name": self.names.get(key, "Uncategorized"), + "parent_name": self.names.get(self.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, + } + + +def score_rows(grouping: Grouping, marks) -> list[dict]: + """Rank one set of answers by how they went, worst first. + + `marks` is (question_id, primary_category_id, was_correct). Used for a + single session, where there is no bank coverage to weigh and no readiness + to shrink — just what was answered, and how much of it was right. + """ + answered: dict[int, list[bool]] = defaultdict(list) + for question_id, primary, is_correct in marks: + for key in grouping.keys_for(question_id, primary): + answered[key].append(bool(is_correct)) + rows = [ + {**grouping.describe(key), + "correct": sum(hits), "total": len(hits), + "percent": round(100 * sum(hits) / len(hits))} + for key, hits in answered.items() if hits + ] + # Weakest first, and among equals the one with more answers behind it — + # a 50% on eight questions is a firmer finding than a 50% on two. + rows.sort(key=lambda row: (row["percent"], -row["total"])) + return rows diff --git a/backend/tests/test_session_lifecycle.py b/backend/tests/test_session_lifecycle.py index 5074835..3819907 100644 --- a/backend/tests/test_session_lifecycle.py +++ b/backend/tests/test_session_lifecycle.py @@ -228,7 +228,8 @@ class QuizAnalysisTests(unittest.TestCase): self.assertEqual({q["status"] for q in body["questions"]}, {"skipped"}) self.assertEqual([q["position"] for q in body["questions"]], list(range(1, len(body["questions"]) + 1))) - self.assertEqual(body["recommendations"], []) + # Where to go back to is its own call, grouped three ways. + self.assertNotIn("recommendations", body) def test_once_sat_the_quiz_address_answers_with_the_real_analysis(self): quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] @@ -311,8 +312,11 @@ class LiveAnalysisTests(unittest.TestCase): self.assertEqual(statuses, {"answered", "unanswered"}) self.assertNotIn("correct", statuses) self.assertNotIn("incorrect", statuses) - # And nothing to go back to yet: a recommendation is a verdict. - self.assertEqual(body["recommendations"], []) + # And nothing to go back to yet: a recommendation is a verdict, and an + # exam still running has not reached one. + recs = self.client.get(f"/attempts/{aid}/recommendations") + self.assertEqual(recs.status_code, 200, recs.text) + self.assertEqual(recs.json()["rows"], []) def test_a_finished_exam_is_marked(self): quiz_id = self.bank.generate(is_shared=True, category_ids=[1], count=2).json()["id"] diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index 5050d72..6ac8eea 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -531,3 +531,87 @@ class ReadinessTests(CompletionTests): self.assertIsNone(data['score']) self.assertFalse(data['unlocked']) self.assertFalse(data['peer']['unlocked']) + + +class SessionRecommendationTests(CompletionTests): + """One session's weakest topics, asked the same three ways as the page.""" + + def setUp(self): + super().setUp() + from app.routers import attempts + self.client.app.include_router(attempts.router, prefix='/attempts') + + def rows(self, attempt_id, group='disciplines'): + response = self.client.get(f'/attempts/{attempt_id}/recommendations', + params={'group': group}) + self.assertEqual(response.status_code, 200, response.text) + return response.json()['rows'] + + def sit(self, marks): + """One completed session answering (question_id, correct) pairs.""" + from datetime import datetime + + from app.models.attempt import AttemptAnswer, QuizAttempt + attempt = QuizAttempt(user_id=1, quiz_id=1, total_questions=len(marks), + mode='study', score=sum(1 for _, c in marks if c), + completed_at=datetime.utcnow()) + self.bank.db.add(attempt) + self.bank.db.flush() + for qid, correct in marks: + self.bank.db.add(AttemptAnswer( + attempt_id=attempt.id, question_id=qid, is_correct=correct, + user_answer='yes' if correct else 'no', seconds_spent=30)) + self.bank.db.commit() + return attempt.id + + def test_topics_are_ranked_weakest_first(self): + # The fixture tree is Root > Child > Leaf, so everything rolls up to + # Root. A second discipline is made by filing question 2 under the + # other root as well — an extra category link, which counts like a + # primary one. + from app.models.question_category import QuestionCategoryLink + self.bank.db.add(QuestionCategoryLink(question_id=2, category_id=4)) + self.bank.db.commit() + + aid = self.sit([(1, False), (2, True)]) + rows = {row['name']: row for row in self.rows(aid)} + self.assertEqual(rows['Root']['percent'], 50) + self.assertEqual(rows['Empty']['percent'], 100) + # Weakest first: that is the order a learner reads down. + self.assertEqual([row['name'] for row in self.rows(aid)], ['Root', 'Empty']) + + def test_a_question_counts_towards_the_discipline_above_it(self): + # Question 3 is filed under Leaf, two levels down. The discipline it + # counts towards is the root, or a discipline would only ever score on + # questions filed at its own level — which is none of them. + aid = self.sit([(3, False)]) + rows = self.rows(aid) + self.assertEqual([row['name'] for row in rows], ['Root']) + self.assertEqual(rows[0]['total'], 1) + + def test_an_unanswered_question_is_not_a_wrong_topic(self): + from app.models.attempt import AttemptAnswer + aid = self.sit([(1, False)]) + self.bank.db.add(AttemptAnswer(attempt_id=aid, question_id=2, + is_correct=False, user_answer='', seconds_spent=0)) + self.bank.db.commit() + total = sum(row['total'] for row in self.rows(aid)) + self.assertEqual(total, 1) + + def test_a_running_exam_is_not_ranked(self): + from app.models.attempt import AttemptAnswer, QuizAttempt + attempt = QuizAttempt(user_id=1, quiz_id=1, mode='exam', total_questions=1) + self.bank.db.add(attempt) + self.bank.db.flush() + self.bank.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=1, + is_correct=True, user_answer='yes', seconds_spent=10)) + self.bank.db.commit() + # Marking it here would let a learner see whether an answer was right + # and go back and change it. + self.assertEqual(self.rows(attempt.id), []) + + def test_somebody_else_s_session_is_not_readable(self): + aid = self.sit([(1, True)]) + self.bank.user = self.bank.peer + self.assertEqual( + self.client.get(f'/attempts/{aid}/recommendations').status_code, 404) diff --git a/docs/TODO.md b/docs/TODO.md index 2a4f6d4..4b0c92e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -346,13 +346,16 @@ Analysis**, which has three tabs. ### Session Analysis -- [ ] **A rail of latest sessions** on the left, collapsible — mode, title, - answered/total and a progress bar each — with that session's results in - the main area: the same four figures, the same donut, and study - recommendations under Articles / Disciplines / Systems tabs. -- [ ] **Remove the overall figures from the session view.** Now that the - Performance tab holds them, a session page showing lifetime totals - answers a question nobody asked while standing in front of one session. +- [x] **A rail of latest sessions** — done. `AnalysisShell` owns the rail and + the session list for both views. The session's recommendations now carry + the Articles / Disciplines / Systems switch too, from + `GET /attempts/{id}/recommendations` — and the membership rules + (ancestor roll-up, article by category, system by symptom keyword) moved + into `services/knowledge_groups.py` so the lifetime view and one session + cannot disagree about where a question belongs. +- [x] **Remove the overall figures from the session view.** The four figures + there are the session's own. The old ungrouped `recommendations` key is + gone from the analysis payload with them. ### Not us diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css index 2d7eaec..5db557b 100644 --- a/frontend/src/components/AnalysisShell.css +++ b/frontend/src/components/AnalysisShell.css @@ -131,3 +131,15 @@ .ax-rail-show { display: none; } .ax-main { padding-top: 0; } } + +/* The Articles / Disciplines / Systems switch. Both analysis views carry one — + the lifetime picture and a single session — so it is styled where they both + already look, keyed on aria-selected because these are tabs. */ +.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } +.an-group-toggle button { + border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); + border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer; +} +.an-group-toggle button[aria-selected='true'] { + background: var(--primary); border-color: var(--primary); color: var(--primary-fg); +} diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index a785896..5b8adda 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -44,13 +44,6 @@ } /* ── Focus areas ──────────────────────────────────────────────────── */ -.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } -.an-group-toggle button { - border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); - border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer; -} -.an-group-toggle button[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); } - .an-focus-head { display: grid; grid-template-columns: 28px 1fr 90px 90px 110px; gap: 12px; padding: 0 14px 7px; align-items: center; diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index b1c771e..6e096f9 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -488,7 +488,7 @@ export default function AnalysisPage() {
{[['articles', 'Articles'], ['systems', 'Systems'], ['disciplines', 'Disciplines']].map(([value, label]) => ( + onClick={() => setGroup(value)}>{label} ))}
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index 656675d..2524c47 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -46,6 +46,11 @@ export default function AnalysisSessionPage() { // Ten at a time: a session of forty is a table nobody reads to the end of. const [page, setPage] = useState(0) const [repeating, setRepeating] = useState(false) + // Which way to slice this session's answers. Fetched on its own rather than + // with the analysis: changing the grouping should not re-read the question + // table and the peer statistics beside it. + const [group, setGroup] = useState('disciplines') + const [recs, setRecs] = useState(null) const navigate = useNavigate() const load = useCallback(() => { @@ -59,6 +64,17 @@ export default function AnalysisSessionPage() { useEffect(() => { load() }, [load]) + useEffect(() => { + const id = data?.attempt_id ?? attemptId + if (!id) return undefined + let live = true + setRecs(null) + api.get(`/attempts/${id}/recommendations`, { params: { group } }) + .then(res => { if (live) setRecs(res.data?.rows || []) }) + .catch(() => { if (live) setRecs([]) }) + return () => { live = false } + }, [data?.attempt_id, attemptId, group]) + const rows = useMemo( () => (data ? [...data.questions].sort(SORTS[sort]) : []), [data, sort]) const PER_PAGE = 10 @@ -198,13 +214,36 @@ export default function AnalysisSessionPage() {

Study recommendations

- {data.recommendations.length === 0 ? ( -

Answer some questions and the weakest topics will show here.

+ {/* The same answers asked three ways: which reading to go back to, + which discipline is weak, which organ system is weak. The + membership rules are the ones the Analysis page uses — one + service answers both, so the two cannot disagree about where a + question belongs. */} +
+ {[['articles', 'Articles'], ['disciplines', 'Disciplines'], ['systems', 'Systems']].map(([value, label]) => ( + + ))} +
+ {recs === null ? ( +

Loading…

+ ) : recs.length === 0 ? ( +

+ {group === 'systems' + ? 'None of these questions carries an organ-system keyword yet.' + : group === 'articles' + ? 'No published article covers these questions yet.' + : 'Answer some questions and the weakest topics will show here.'} +

) : (
    - {data.recommendations.map(rec => ( -
  • - {rec.name} + {recs.map(rec => ( +
  • + + {rec.article_id + ? {rec.name} + : rec.name} + diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index c079aa0..38a9c2b 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -33,8 +33,25 @@ const SAT = { recommendations: [], plan: null, not_started: false, } -const mock = (payload) => api.get.mockImplementation(url => { +const RECS = { + disciplines: [ + { name: 'Neonatology', category_id: 4, article_id: null, system_id: null, + parent_name: null, correct: 1, total: 4, percent: 25 }, + { name: 'Cardiology', category_id: 5, article_id: null, system_id: null, + parent_name: null, correct: 3, total: 4, percent: 75 }, + ], + articles: [ + { name: 'Kawasaki disease', category_id: 5, article_id: 7, system_id: null, + parent_name: 'Cardiology', correct: 0, total: 2, percent: 0 }, + ], + systems: [], +} + +const mock = (payload, recs = RECS) => api.get.mockImplementation((url, config) => { if (url === '/quizzes/sessions') return Promise.resolve({ data: SESSIONS }) + if (url.includes('/recommendations')) { + return Promise.resolve({ data: { rows: recs[config?.params?.group || 'disciplines'] } }) + } if (url.includes('/analysis')) return Promise.resolve({ data: payload }) return Promise.resolve({ data: [] }) }) @@ -220,3 +237,26 @@ describe('a session left part way', () => { expect(screen.queryByRole('link', { name: 'Repeat session' })).not.toBeInTheDocument() }) }) + +it('ranks this session\'s topics, and asks the same answers three ways', async () => { + mock(SAT) + mountAttempt() + const card = (await screen.findByText('Study recommendations')).closest('.an-card') + expect(await within(card).findByText('Neonatology')).toBeInTheDocument() + expect(within(card).getByText('1/4')).toBeInTheDocument() + + await userEvent.click(within(card).getByRole('tab', { name: 'Articles' })) + // An article row is a link, because the point of it is to go and read. + expect(await within(card).findByRole('link', { name: 'Kawasaki disease' })) + .toHaveAttribute('href', '/articles/7') + expect(within(card).queryByText('Neonatology')).not.toBeInTheDocument() +}) + +it('says why a grouping is empty rather than showing nothing', async () => { + mock(SAT) + mountAttempt() + const card = (await screen.findByText('Study recommendations')).closest('.an-card') + await within(card).findByText('Neonatology') + await userEvent.click(within(card).getByRole('tab', { name: 'Systems' })) + expect(await within(card).findByText(/organ-system keyword/)).toBeInTheDocument() +}) diff --git a/frontend/src/pages/DocumentDetailPage.css b/frontend/src/pages/DocumentDetailPage.css index 6748faf..e6fc1f1 100644 --- a/frontend/src/pages/DocumentDetailPage.css +++ b/frontend/src/pages/DocumentDetailPage.css @@ -1,3 +1,8 @@ +.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 fa6b733..cc60a75 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -290,6 +290,9 @@ export default function DocumentDetailPage() { onClose={() => setActiveJob(null)} /> )} + {/* 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