feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: semester-scoped learning + Courses & Semesters hub - #360

Merged
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning
Jul 29, 2026
Merged

feat: semester-scoped learning + Courses & Semesters hub#360
AndresL230 merged 3 commits into
mainfrom
feat/semester-scoped-learning

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a semester switcher (a redesigned Courses & Semesters hub) that scopes the learning surfaces to a chosen term, and enforces a no-retake rule. Implements the approved design/plan under docs/superpowers/.

Approach: Path A — read-time filtering, no schema change / no migration. The knowledge graph stays keyed on the abstract course_id; a term filter is applied at the read boundary. Retakes are disallowed, so each course lives in exactly one semester.

Backend

  • services/academics.py: term_id_for_label (label → term id, mirrors gradebook) + user_course_ids_for_term (the courses a user is enrolled in for a term).
  • services/graph_service.py: get_graph(semester=) and get_recommendations(semester=) filter nodes/edges/stats (and DB-side course_id in (...) for recs) to the term's courses; add_course now rejects re-adding a course enrolled in any term (no-retake), surfacing the existing term.
  • routes/graph.py: optional ?semester=<term label> on GET /api/graph/{user} and /recommendations. Absent ⇒ all terms (unchanged).

Frontend

  • lib/api.ts: EnrolledCourse.term; optional semester on getGraph/getRecommendations.
  • lib/useActiveSemester.ts: persisted active-semester hook ([value, setter, hydrated]) + distinctTerms/resolveActiveSemester helpers.
  • Dashboard / Tree / Learn / Quiz / Study scope to the active semester; graph fetches are gated on hydration to avoid an unscoped→scoped double-fetch.
  • ManageCoursesModalCourses & Semesters hub: term tabs (set the active semester), courses grouped by term, "Already taken · " disabled state, disabled "Personal learning — Coming soon" placeholder.

Test Plan

  • Backend: python -m pytest tests/ -q → 981 passed, 1 skipped (1 pre-existing unrelated OCR event-loop error).
  • Frontend: npm run typecheck clean, npm run lint 0 errors, npm run test 92 passed.
  • Manual: open Courses & Semesters, switch term tabs, confirm Tree/Learn/Study/Quiz + dashboard graph scope to that term.
  • Manual: search a course taken in a previous term in "Add a course" → shows "Already taken · ", disabled.

Known non-blocking follow-ups

  • Recent-session lists (Dashboard/Tree) are not yet term-filtered (spec §1; needs a sessions backend change).
  • Stale local course selection can persist if the semester is switched mid-screen (cosmetic; shared across Learn/Tree/Quiz/Study).
  • resolveActiveSemester defaults to the most-recently-enrolled term (documented heuristic, not term sort_key).
  • term_id_for_label duplicates gradebook's private _term_id_for_semester — a later cleanup could consolidate.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Semester-scoped learning: graph loading and recommendations (and the related learning screens) now optionally filter by a selected semester.
    • Added a persisted “active semester” selector with automatic fallback to the most recent enrolled term.
    • Updated course management to “Courses & Semesters,” including term-aware enrollment and no-retake enforcement across all terms.
    • Improved tutor retrieval with category-aware document chunking, plus stronger academic integrity guidance.
  • Bug Fixes

    • Ensured semester-filtered graph, stats, and recommendation results stay consistent.
  • Tests

    • Expanded coverage for semester filtering/resolution, term enrollment behavior, API passthrough, and category chunking/indexing.

@coderabbitai

coderabbitaiBot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f229de7-a3dd-498f-9b5b-3f790de13dc4

📥 Commits

Reviewing files that changed from the base of the PR and between 3369301 and b3d80a6.

📒 Files selected for processing (35)
  • backend/db/migrations/0036_offering_null_section_unique.sql
  • backend/prompts/preamble.txt
  • backend/routes/documents.py
  • backend/routes/gradebook.py
  • backend/routes/graph.py
  • backend/scripts/backfill_document_chunks.py
  • backend/services/academics.py
  • backend/services/chunker.py
  • backend/services/graph_service.py
  • backend/tests/test_academics.py
  • backend/tests/test_chunker.py
  • backend/tests/test_document_indexing.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_shared_course_context.py
  • docs/frontend-testids.md
  • docs/superpowers/plans/2026-07-20-per-type-document-chunking.md
  • docs/superpowers/plans/2026-07-20-semester-scoped-learning.md
  • docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md
  • docs/superpowers/specs/2026-07-20-semester-scoped-learning-design.md
  • frontend/e2e/semester-scope.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/src/components/ManageCoursesModal.test.tsx
  • frontend/src/components/ManageCoursesModal.tsx
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/semesters.test.ts
  • frontend/src/lib/semesters.ts
  • frontend/src/lib/useActiveSemester.test.ts
  • frontend/src/lib/useActiveSemester.ts
  • frontend/vitest.setup.ts
📝 Walkthrough

Walkthrough

Adds semester-scoped graph and learning data across backend and frontend, persists the active semester, updates course management UI, enforces cross-term no-retakes, adds category-aware prose chunking, and strengthens tutor academic-integrity prompts.

Changes

Semester-scoped learning

Layer / File(s)Summary
Semester-scoping contracts
docs/superpowers/...
Documents read-time semester filtering, active-semester behavior, no-retake rules, affected surfaces, and verification requirements.
Term resolution and backend enforcement
backend/routes/graph.py, backend/services/..., backend/tests/*
Adds term-aware routes and services, filters graphs and recommendations, supports requested enrollment terms, and rejects cross-term duplicate enrollments.
Active-semester state and API
frontend/src/lib/api.ts, frontend/src/lib/useActiveSemester.ts, frontend/src/lib/*test*, frontend/eslint-suppressions.json
Adds semester request parameters, course term typing, persisted active-semester state, synchronization, and helper tests.
Dashboard and course hub
frontend/src/components/screens/Dashboard.tsx, frontend/src/components/ManageCoursesModal.tsx, frontend/src/components/screens/Dashboard.test.tsx
Scopes dashboard data and redesigns course management with term selection, per-term enrollment display, and no-retake messaging.
Learning surface filters
frontend/src/components/screens/{Learn,Quiz,Study,Tree}.tsx, frontend/src/components/DocumentUploadModal.test.tsx
Threads the active semester into graph loading and filters course and concept choices across learning screens.

Category-aware document chunking

Layer / File(s)Summary
Chunking contract and implementation
backend/services/chunker.py, backend/tests/test_chunker.py, docs/superpowers/...
Adds deterministic sentence-window prose chunking and category-based dispatch while retaining block chunking for other categories.
Indexing integration
backend/routes/documents.py, backend/scripts/backfill_document_chunks.py, backend/tests/test_document_indexing.py
Passes document categories through live and historical chunk generation and updates indexing tests.

Academic-integrity prompt guidance

Layer / File(s)Summary
Tutor prompt rule
backend/prompts/preamble.txt, backend/tests/test_shared_course_context.py
Adds academic-integrity restrictions and verifies their inclusion in generated prompts.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#53 — Modifies the same course-creation route and request model while this change adds term propagation.

Suggested reviewers:andresl230, jose-gael-cruz-lopez

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change: semester-scoped learning plus the redesigned Courses & Semesters hub.
Description check✅ PassedThe description is detailed and covers the summary, backend/frontend changes, testing, and follow-ups, with only minor template sections omitted.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/semester-scoped-learning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 20, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb3d80a6Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:55 AM

import pytest
from unittest.mock import MagicMock, patch

import services.graph_service as gs
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: per-type document chunking (commits 1a49ebe..c3fcfdf)

Pushed on top of the semester-scoped work. Independent of it — touches only the RAG chunking path.

What & why: the RAG pipeline used one block chunker (\n\n splitting) for every document, which mangles continuous prose (essays, written problem-set work) that lacks blank-line boundaries. This adds a second strategy and routes by the classifier's existing category.

  • services/chunker.py: new chunk_prose (sentence-window, ~200-word target, ~40-word overlap, deterministic, capped at _MAX_WORDS) + chunk_for_category(text, category) dispatch. chunk_document unchanged.
  • Routing: reading/assignment/study_guide → prose; everything else (incl. unknown) → block chunker.
  • Wired into _index_document_chunks (live upload) and scripts/backfill_document_chunks.py. seed_quiz_fixture.py intentionally stays on the block chunker.
  • Determinism preserved so content-addressed chunk IDs stay stable.

Spec/plan:docs/superpowers/specs/2026-07-20-per-type-document-chunking-design.md, docs/superpowers/plans/2026-07-20-per-type-document-chunking.md

Tests:test_chunker.py (16) + test_document_indexing.py (3) + test_rag_service.py (10) pass; per-task + final whole-branch review clean.

Out of scope (deferred, noted in the spec): a tutor system-prompt rule to never hand students assignment answers — that's prompt behavior, not chunking.

🤖 Generated with Claude Code

@Darkest-Teddy
Darkest-Teddy marked this pull request as draft July 21, 2026 04:01
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

Added: tutor academic-integrity rule (commit 168f054)

Follow-up to the chunking work — the deferred behavioral rule.

  • backend/prompts/preamble.txt: a non-negotiable ACADEMIC INTEGRITY section in the shared tutor preamble (applies to all modes — socratic/expository/teachback). The tutor must teach toward the answer (hints, steps, analogous examples) and never hand over the completed graded deliverable or reproduce verbatim solution text from uploads/retrieved context. Concepts, definitions, and general worked examples are still fully explained.
  • Regression test in test_shared_course_context.py asserts the rule is present in build_system_prompt output. 25 passed, ruff clean.
  • Quiz prompt intentionally unchanged — a quiz legitimately contains the marked correct answer (self-test revealed after answering), so the tutor rule doesn't apply there.

🤖 Generated with Claude Code

Darkest-Teddy added a commit that referenced this pull request Jul 27, 2026
main merged a separate semester feature (#140: Dashboard current-vs-archive
split + getSemesters/@lib/semesters). Reconciled under "active-semester wins":
the persisted active-semester selector is the single source of truth for the
Dashboard graph + course panels; #140's current/archive drawer is removed, but
its getSemesters()/sort_key data is reused so the default active term resolves
to the most-recent enrolled term by sort_key (courseTermLabels), fixing #360's
documented enrollment-order heuristic.
Conflict resolutions:
- Dashboard.tsx: fetch graph/recs scoped to activeSemester AND getSemesters;
drop partition/currentProgress/archivedProgress/CoursesArchive; CoursesKey and
the legacy panel render the scoped courseProgress; default term via
courseTermLabels(cs, sems)[0] with resolveActiveSemester fallback.
- ManageCoursesModal.tsx: keep the term-tabs selector (drop groupCoursesByTerm).
- Quiz.tsx: pass scopedConcepts + scopedCourses (scope the picker too).
- api.ts / data.test.ts / DocumentUploadModal.test.tsx: de-duplicate the
EnrolledCourse.term field both branches added.
- Dashboard.test.tsx: rewrite the #140 archive tests as active-semester scoping
tests (default by sort_key, storage-driven scope, label-rank degradation).
Verified locally: frontend typecheck clean, lint 0 errors, 196 tests pass;
backend 1107 pass / 23 skipped (1 pre-existing OCR event-loop error, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Darkest-Teddy
Darkest-Teddy marked this pull request as ready for review July 27, 2026 18:50

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/screens/Dashboard.tsx (1)

1276-1280: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the semester-empty state reachable.

This branch can never render: CoursesKey returns null at Line 1133 whenever courseProgress.length === 0. Remove or adjust that early return so an active semester with no courses shows this message and still exposes course management.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 1276 - 1280,
Update CoursesKey so it does not return null when courseProgress.length is zero
for an active semester; allow rendering to continue to the “Nothing enrolled
this semester.” state while preserving course management controls.
🧹 Nitpick comments (1)
backend/tests/test_shared_course_context.py (1)

471-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover every tutor mode claimed by the test.

The docstring promises the rule is present “regardless of mode,” but this test exercises only expository. Parameterize it over the supported modes so a mode-specific prompt change cannot bypass the integrity rule.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_shared_course_context.py` around lines 471 - 478, Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 265-282: Update the dashboard loading flow around the Promise.all
requests so courses and semesters are fetched first, then derive an
effectiveSemester from the stored activeSemester or the existing
courseTermLabels/resolveActiveSemester fallback before requesting graph and
recommendation data. Pass effectiveSemester to getGraph and getRecommendations,
preserving unscoped requests only when no semester can be resolved; ensure the
default path passes “Spring 2026” rather than undefined and persists that
selected semester.
---
Outside diff comments:
In `@frontend/src/components/screens/Dashboard.tsx`:
- Around line 1276-1280: Update CoursesKey so it does not return null when
courseProgress.length is zero for an active semester; allow rendering to
continue to the “Nothing enrolled this semester.” state while preserving course
management controls.
---
Nitpick comments:
In `@backend/tests/test_shared_course_context.py`:
- Around line 471-478: Update
test_build_system_prompt_carries_academic_integrity_rule to run across every
supported tutor mode instead of only "expository". Parameterize the test using
the existing test framework conventions, while preserving the prompt
construction and both academic-integrity assertions for each mode.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6eedc26e-16af-4a02-b10b-c42176be693d

📥 Commits

Reviewing files that changed from the base of the PR and between c3fcfdf and 3369301.

📒 Files selected for processing (9)
  • backend/prompts/preamble.txt
  • backend/tests/test_shared_course_context.py
  • frontend/eslint-suppressions.json
  • frontend/src/components/screens/Dashboard.test.tsx
  • frontend/src/components/screens/Dashboard.tsx
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/lib/api.ts
💤 Files with no reviewable changes (1)
  • frontend/eslint-suppressions.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/lib/api.ts
  • frontend/src/components/screens/Quiz.tsx
  • frontend/src/components/screens/Study.tsx
  • frontend/src/components/screens/Learn.tsx

Comment on lines +265 to +282
const [graphRes, coursesRes, assignsRes, sessionsRes, recsRes, semestersRes] = await Promise.all([
getGraph(userId),
getGraph(userId, activeSemester || undefined),
getCourses(userId),
getUpcomingAssignments(userId),
getSessions(userId, 10),
getRecommendations(userId).catch(() => ({ recommendations: [] })),
// Term calendar is a nicety: without it the course lists stay flat
// rather than the dashboard failing to load.
getRecommendations(userId, activeSemester || undefined).catch(() => ({ recommendations: [] })),
// Term calendar drives the default active-semester resolution below;
// if it fails we fall back to enrollment order rather than failing the load.
getSemesters().catch(() => ({ semesters: [] })),
]);
const cs = coursesRes.courses || [];
const sems = semestersRes.semesters || [];
setCourses(cs);
setSemesters(semestersRes.semesters || []);
// First run with no stored semester: default to the most-recent enrolled
// term by `sort_key` (courseTermLabels), falling back to enrollment order.
if (!activeSemester) {
const def = courseTermLabels(cs, sems)[0] || resolveActiveSemester("", cs);
if (def) setActiveSemester(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve the default semester before requesting graph data.

With no stored semester, Lines 266 and 270 request unscoped data, then Line 281 selects and persists a term. This briefly renders unscoped graph/stats/recommendations and performs a second fetch. Fetch courses/semesters first, derive an effectiveSemester, then use it for the graph and recommendations requests.

Also assert the default-path getGraph call receives "Spring 2026" rather than undefined.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Dashboard.tsx` around lines 265 - 282, Update
the dashboard loading flow around the Promise.all requests so courses and
semesters are fetched first, then derive an effectiveSemester from the stored
activeSemester or the existing courseTermLabels/resolveActiveSemester fallback
before requesting graph and recommendation data. Pass effectiveSemester to
getGraph and getRecommendations, preserving unscoped requests only when no
semester can be resolved; ensure the default path passes “Spring 2026” rather
than undefined and persists that selected semester.

@AndresL230
AndresL230 marked this pull request as draft July 27, 2026 23:50
AndresL230 added a commit that referenced this pull request Jul 29, 2026
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 marked this pull request as ready for review July 29, 2026 11:13
@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from 3369301 to a53e809CompareJuly 29, 2026 11:13
@AndresL230

Copy link
Copy Markdown
Collaborator

Code review

Found 3 issues:

  1. The new cross-term no-retake guard in add_course is check-then-act with no atomic enforcement: course_offerings' UNIQUE(course_id, term_id, section) does not bind for NULL sections and a race that creates two different offering rows slips past enrollments' uniqueness — a double-click on Add for a never-enrolled course can create duplicate enrollments, violating the PR's own invariant. (bug due to backend/services/graph_service.py — read via user_offering_ids_for_course, then resolve_offering(..., create=True) + insert with no constraint backing the NULL-section case)

# rejected instead of silently creating a second enrollment.)
existing_offerings=user_offering_ids_for_course(user_id, course_id)
ifexisting_offerings:
existing_term=term_for_offering(existing_offerings[0]) or {}
return {
"course_id": course_id,
"already_existed": True,
"term": existing_term.get("label", ""),
}
# Resolve the requested semester label → term id. An unknown label yields
# None, which resolve_offering treats as "current term".
term_id=term_id_for_label(term) iftermelseNone
offering_id=resolve_offering(course_id, term_id=term_id, create=True)
ifnotoffering_id:
return {"course_id": course_id, "error": "No term available to enroll into"}
# Check if already enrolled in this offering
existing=table("enrollments").select(
"id",
filters={"user_id": f"eq.{user_id}", "offering_id": f"eq.{offering_id}"},
)
ifexisting:
return {"course_id": course_id, "already_existed": True}

  1. CoursesKey returns null whenever courseProgress is empty, so the "Nothing enrolled this semester." empty-state later in the same component is unreachable — an empty active semester renders nothing at all in that panel. (bug due to frontend/src/components/screens/Dashboard.tsx — early if (courseProgress.length === 0) return null; above the empty-state block)

if(courseProgress.length===0)returnnull;

  1. Only Dashboard and the Courses & Semesters hub ever resolve/persist a default active semester; Learn/Quiz/Tree/Study read it but never set one — a user who deep-links to /learn first stays permanently unscoped on those screens, silently defeating the semester-scoping feature there. (bug due to frontend/src/lib/useActiveSemester.ts consumers never resolving a default outside Dashboard)

}
/** [activeSemester, setActiveSemester, hydrated] — persisted to localStorage, cross-tab + same-tab
* reactive. `hydrated` is false until the localStorage read completes after mount. */
exportfunctionuseActiveSemester(): [string,(v: string)=>void,boolean]{
const[sem,setSem]=useState<string>("");
// `hydrated` flips true once we've read localStorage after mount. We start at
// "" (not the stored value) to avoid an SSR/CSR hydration mismatch, so callers
// that fetch based on the active semester should wait for `hydrated` to avoid
// an initial unscoped fetch followed by a scoped refetch.
const[hydrated,setHydrated]=useState(false);
useEffect(()=>{
setSem(read());
setHydrated(true);
constonStorage=(e: StorageEvent)=>{
if(e.key===ACTIVE_SEMESTER_STORAGE_KEY)setSem(read());
};
constonCustom=()=>setSem(read());
window.addEventListener("storage",onStorage);
window.addEventListener(CHANGE_EVENT,onCustom);
return()=>{
window.removeEventListener("storage",onStorage);
window.removeEventListener(CHANGE_EVENT,onCustom);
};
},[]);
// Stable identity so consumers can safely list it in effect/callback deps.
constupdate=useCallback((v: string)=>{
if(typeofwindow==="undefined")return;
window.localStorage.setItem(ACTIVE_SEMESTER_STORAGE_KEY,v);
setSem(v);
window.dispatchEvent(newEvent(CHANGE_EVENT));
},[]);
return[sem,update,hydrated];
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 29, 2026 04:37
Rework of PR #360 (feat/semester-scoped-learning) replayed onto current
main as a single commit, resolving the drift accumulated since the
merge-base (~40 commits, incl. the #349 Learn.tsx SSE rewrite, the #355
subject-root dedup, the #352 content-hash chunk ids, and the #140
current/archive dashboard split, which the semester tabs supersede per
the branch's own reconciliation).
Backend semester scoping:
- services/academics.py: term_id_for_label (semester label -> term id,
with term-id fallback) + user_course_ids_for_term (enrollments ->
offerings -> term-filtered course ids).
- services/graph_service.py: get_graph/get_recommendations accept an
optional semester label; nodes/edges/stats and the synthesized subject
roots are restricted to that term's courses (composes with the #355
per-course subject-root dedup — roots are built from the already-
filtered enrollment list).
- add_course: no-retake rule across ALL terms (returns already_existed
with the existing term label) and an optional term label so the hub
enrolls into the tab being viewed, falling back to the current term.
- routes/graph.py: optional ?semester= on GET /api/graph/{user_id} and
/recommendations; AddCourseBody gains term.
- routes/gradebook.py: private _term_id_for_semester duplicate removed;
call sites now use the shared academics.term_id_for_label (follow-up
noted on the original PR).
Frontend semester scoping:
- lib/useActiveSemester.ts: localStorage-backed active-semester hook
(cross-tab sync, hydration flag so first fetches are scoped once).
- Dashboard/Tree/Learn/Quiz/Study fetch the graph scoped to the active
semester and scope course/concept pickers to it; Learn's scoping was
redone by hand against main's SSE-streaming Learn.tsx (pass the active
semester into the bootstrap getGraph, term on concepts, scopedCourses
for the course select, TopicPicker term filter — main's own
suggest/highlight logic kept, not duplicated).
- ManageCoursesModal becomes the Courses & Semesters hub (per-term tabs,
enroll-into-tab, no-retake feedback); the Dashboard archive rail from
#140 is replaced by the active-semester scoping.
- vitest.setup.ts: install an in-memory localStorage when the jsdom
environment exposes none (jsdom 29 under Vitest leaves
window.localStorage undefined), so useActiveSemester/useLayoutPref
are testable in DOM tests.
Per-type document chunking + tutor integrity:
- services/chunker.py: chunk_for_category routes prose-like categories
(essays/assignments) to a sentence-aware prose chunker, others to the
existing chunker; routes/documents.py passes the doc category through;
backfill script follows. Composes with #352's content-addressed chunk
ids (sha256 over chunk text — only boundaries change).
- prompts/preamble.txt: academic-integrity rule for the tutor (guide,
never hand over graded-work answers).
Conflict resolutions: test_document_indexing.py keeps both main's #439
relevance-gate test (repointed at the chunk_for_category seam) and the
branch's category-passthrough test; Dashboard.tsx keeps main's
IS_TEST_MODE/now + #369 learnHrefForNode imports alongside
courseTermLabels; eslint-suppressions.json re-baselined (Dashboard
no-restricted-syntax count drops with the removed archive UI).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gle scoped dashboard fetch
Review follow-ups on the semester-scoped learning rework (#360):
- add_course race (A): new partial unique index (migration 0036) closes the
NULL-section gap in course_offerings_unique; academics.resolve_offering
(create=True) now catches the 409 from a lost create race and re-selects
the winner's offering; the ManageCoursesModal Add button disables while an
add request is in flight. The now-unreachable per-offering already-enrolled
check in add_course is removed and the single return contract documented.
- add toast honesty (B): handleAdd reads the response — already_existed shows
an informational "Already taken in <term>" toast instead of a false success;
success only toasts when a row was created.
- CoursesKey empty state (C): "Nothing enrolled this semester." is reachable —
the key stays rendered when courses exist but the active semester scopes to
none; still null when there are no courses at all.
- dashboard first load (D): with no stored semester, resolve + persist the
default (courses + semesters fetch) BEFORE the scoped fetch and early-return;
the effect re-run performs the single scoped fetch. Zero-courses stays a
single unscoped pass.
- cross-screen default (E): new useActiveSemester.ensureDefaultActiveSemester
(termLabels) persists a default when none is stored, called from
Learn/Quiz/Tree/Study once course term labels are in hand.
- prune (F): drop orphaned partitionCurrentAndArchive, groupCoursesByTerm,
UNKNOWN_TERM_LABEL, CoursePartition (+ TermGroup) and their test blocks.
Tests: resolve_offering conflict-retry + non-409 propagation units; add_course
duplicate-path contract; ManageCoursesModal component tests for the
already-existed/success/in-flight paths; Dashboard single-scoped-fetch,
zero-courses and CoursesKey empty-state renders; ensureDefaultActiveSemester
units.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
Collaborator

All three review findings above are fixed in the latest push: (1) the no-retake race is closed by migration 0036's partial unique index on course_offerings (course_id, term_id) WHERE section IS NULL, a 409-retry re-select in resolve_offering, and an in-flight disable on the hub's Add button; (2) CoursesKey now renders the 'Nothing enrolled this semester.' empty-state when an active semester scopes to zero courses; (3) Learn/Quiz/Tree/Study now resolve a default semester via ensureDefaultActiveSemester as soon as they have term labels. Also: the Add flow honors already_existed with an informational toast, the dead duplicate check was removed, first-load double-fetch eliminated, and the orphaned #140 archive helpers were deleted. Backend 1311 passed; frontend 246 passed.

@AndresL230
AndresL230force-pushed the feat/semester-scoped-learning branch from a53e809 to 9531039CompareJuly 29, 2026 11:38
…e veto)
The e2e lane vetoed the auto-default: the rich seed spans Fall 2025 /
Spring 2026 / Summer 2026 and 7 of 11 journeys failed because the
auto-resolved term silently hid cross-term fixtures (dashboard lost
MATH210; the graph shrank to 1 of 17 nodes). New semantics:
- Default = ALL SEMESTERS (unscoped). An empty stored active-semester
value IS the default and means "All semesters": removed
ensureDefaultActiveSemester + its Learn/Quiz/Tree/Study call sites,
Dashboard's default-resolution early-return pass (deferredToScopedPass)
and its getSemesters/courseTermLabels/resolveActiveSemester usage.
resolveActiveSemester itself is deleted (orphaned — the hub now reads
the stored value raw). Hook, change events, and hydration gating stay.
- Hub UI: ManageCoursesModal grows an explicit "All semesters" tab —
active when the stored value is empty, clicking it clears the value;
term tabs unchanged (both carry aria-pressed). A picked term still
persists and scopes every surface.
- Kept from the review batch: CoursesKey empty-state reachability, the
already_existed info toast, in-flight Add disable, the 0036 partial
unique index + resolve_offering conflict retry, semesters.ts pruning.
- New journey frontend/e2e/semester-scope.spec.ts: default shows
cross-term courses together (MATH210 + BIO110), picking Fall 2025 in
the hub hides Spring-only MATH210, "All semesters" restores it. Hub
opens via new dashboard-courses-manage testid (documented in
docs/frontend-testids.md); tabs selected by role/name.
- Tests reworked: Dashboard default test now asserts one unscoped fetch
with both terms visible; /api/semesters-failure test dropped (no
semesters fetch remains); ensureDefault unit tests replaced by hub tab
component tests (All active by default / term persists / All clears).
- Design doc amendment records the veto and the opt-in scoping decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d0d8837 into mainJul 29, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…ive term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* fix(explore): resolve F1–F8 from the Chapter 2 exploration sweep
Fixes the eight findings from the 2026-07-29 /explore session (.explore/findings.md):
- F6 study_guide: query `assignments` by enrollment_id, not the phantom
user_id/course_id columns (enrollment-keyed table) — get_exams,
_generate_and_insert, and get_courses all 500'd, bricking the whole
study-guide feature. get_courses now delegates to graph_service.get_courses.
- F5a agents/function_handlers_e2e: register note_summary / note_concepts /
note_chat handlers — these are request-path agent tasks that 500'd with
UnregisteredHandlerError in function mode.
- F4 notes: /api/notes/user, the single-note read, and create now return the
abstract course_id + course_code/name resolved from the offering (every
note showed "Unknown course").
- F1/F3 graph_service.get_courses: collapse the per-enrollment fan-out to one
row per course_id (most-recent enrollment as representative; node_count
counted once; additive enrollment_ids/terms lists). Fixes the dashboard
count, /tree chips, and every course picker (#449).
- F2 onboarding.search_courses: dedup catalog results by course code so the
rich/base seed same-code courses don't show as indistinguishable dupes.
- F7 auth: fire an idempotent login-streak achievement check on approved
Google sign-in so "First Steps" is actually granted (test-login left
untouched — it contractually performs no DB writes).
- F5b notetaker: surface toast.error(humanizeError(...)) on failed agent
actions (Summarize/Extract/Generate quiz/Send to tutor were silent no-ops).
- F8 Settings: prefill the profile form from the profile fetch so name/username
aren't blank (data-loss risk on save).
Verification: 1325 backend tests pass (+14 new regression tests), ruff + tsc
clean; live backend re-check of F1/F2/F4/F5a/F6 all pass and the e2e oracles
return 0 findings (down from 6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(courses): scope by term membership, not the collapsed representative term (PR #462 review)
The #449 get_courses collapse keeps one row per abstract course_id with only
the most-recent enrollment's singular `term`. But six screens filtered
`c.term === activeSemester` and the semester tab bar was built from that
singular `term`, so a course enrolled across two terms (CS101 in Fall 2025 +
Spring 2026) dropped off its older-term tab — a regression of #360 semester
scoping (caught by the adversarial review on #462, confirmed live: the Fall
2025 dashboard showed "1 course" while rendering two course hubs).
- api.ts: document `terms`/`enrollment_ids` on EnrolledCourse (backend already
returns them since the collapse).
- useActiveSemester.ts: add `courseInTerm(course, activeSemester)` (term
MEMBERSHIP; "" = all) and flatten `distinctTerms` over the `terms[]` array.
- Dashboard/Study/Tree/Quiz/Learn/ManageCoursesModal: filter via `courseInTerm`;
Quiz/Learn concepts now inherit the course `terms[]` so their (defensive)
semester filter matches too.
- useActiveSemester.test.ts: regression tests for membership + terms[] flatten.
Also closes the F4 follow-up: the POST /api/notes create route now returns the
resolved course_id/labels too (was "Unknown course" on a fresh note until reload).
Verified live (Fall 2025 tab): CS101 + BIO110 both show, "2 courses"; all tabs
resolve correctly (Fall→CS101,BIO110 / Spring→CS101,MATH210 / Summer→ENG150).
tsc clean, affected vitest 16 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Jul 30, 2026
* feat(study): semester-scope the study-tool reads (#141)
The approved reframe: NO Archive toggle. The existing semester selector
(Courses & Semesters hub -> lib/useActiveSemester, "" = All semesters
DEFAULT — untouched, e2e-pinned per #360) now scopes the STUDY-TOOL
reads the same way it already scopes the graph. The study endpoints
used to hardcode current-term resolution (resolve_offering(course_id)),
so under the frozen e2e clock (spring-2026 current) fall-2025 study
material was unreachable regardless of the user's selection.
Backend — optional `semester` (term LABEL via term_id_for_label) on the
course-scoped READ paths, resolved STRICTLY: an unknown label or a term
with no offering of the course degrades to each route's empty/404
behavior, never a silent fall-back to another term. New
`fallback=False` mode on academics.resolve_offering carries that rule
(the default create=False path used to silently resolve ANY offering of
the course on a term miss).
- study_guide: GET /{user}/guide + POST /regenerate (404 on a term
miss, and never generates for an offering that isn't there); GET
/{user}/exams scopes the enrollment set to the selected term.
- flashcards: GET /user/{user} filters cards to the selected term's
offerings (term-LESS cards stay visible under any selection); POST
/generate grounds its docs context in the selected term's offering
(a term miss contributes no docs — not all-docs, not current term).
import/commit stays a CREATE path: current term by design.
- notes: GET /user/{user} course-filtered read takes `semester`
(API completeness — the notetaker UI carries no semester context and
is deliberately NOT wired); the create/re-home paths stay
current-term by design (commented).
- quiz: untouched (no term resolution; scoping is client-side).
Frontend — Study.tsx threads `activeSemester || undefined` into
getStudyGuideExams/getStudyGuide/regenerateStudyGuide/getFlashcards/
generateFlashcards, gated on the useActiveSemester hydrated flag
exactly like Dashboard (call-count pinned: one scoped fetch, never
unscoped-then-scoped).
Tests: backend route + resolver coverage for (a) no semester = existing
current-term behavior, (b) explicit term threads (course, term,
fallback=False), (c) unknown/no-offering term = empty/404 not 500;
vitest Study.semester.test.tsx pins the scoped/unscoped fetch args and
the single-fetch hydration gate. New journey e2e/study-semester.spec.ts
(authored, not run here): All semesters shows the fall AND spring decks
together; picking Fall 2025 in the hub surfaces the fall-2025 CS Basics
deck and hides the spring-only one. No new agent tasks introduced; the
journey triggers no generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(review): recent-guides open as their own term + close the #475 review findings
F1 (major): the recent-guides rail lists guides from ANY term, but opening
one resolved with the ACTIVE semester under #141's strict mode — a
multi-term course + other-term entry cache-missed on (offering, exam) and
silently generated-and-PERSISTED a mismatched row; a course absent from
the active term 404'd for a guide visibly in the sidebar. Invariant now:
a recent entry opens AS ITS OWN TERM.
- backend GET /{user}/cached: each entry carries its own `semester` label
(term_for_offering, lru-cached, offering ids deduped in the existing
enrichment loop); ETag key bumped to guides.v2 so bodies cached under
the old shape revalidate.
- frontend: StudyGuideCacheEntry.semester; openRecent records the entry's
term ("" = term-less entry -> explicitly unscoped) in a ref consumed by
exactly one load; loadGuide takes a per-load term override; the failed
state stores the term so retry replays the exact load. Picker-driven
loads keep following the active selector (unit-tested by driving the
real CustomSelects). The #476 emergent examId-clear behavior is
untouched and its tests stay green.
F3: _generate_and_insert scopes the exam lookup to the RESOLVED
offering's enrollment (the #462 CodeRabbit fix) — a two-term user can no
longer generate a guide keyed on one term's offering from another term's
exam (regression test: 404, nothing persisted).
F2: flashcards _get_course_documents — a course-name miss WITH an
explicit semester now contributes no documents (an explicit term gives
the all-docs fallback nothing to anchor to); without a semester the
pre-existing all-docs fallback is byte-identical (both pinned).
F4: the guide 404 branch no longer hardcodes the exam-deleted copy —
the server's detail renders when it isn't the exam-deleted sentence
(no-offering-in-term case), still guidance, never a toast.
F5: comment truth fixes — notes PATCH re-home relabeled (re-home, not
create; still deliberately current-term); api.ts study-guide block now
documents the pre-existing exams-list asymmetry (omitted semester =
current-term resolution for guide/regenerate but ALL terms for
getStudyGuideExams); Study.semester.test.tsx attributes the exam-clear
to the courseId-keyed effect racing openRecent (#476), not to openRecent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): disambiguate the Linear Algebra pill (course pill + topic pill share the name under All semesters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the feat/semester-scoped-learning branch August 2, 2026 18:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Darkest-Teddy@AndresL230