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.'} +

) : (