Skip to content

Course ID schema migration + calendar assignment normalization#53

Merged
Jose-Gael-Cruz-Lopez merged 14 commits into
mainfrom
combined/course-id-migration-and-assignment-normalization
Apr 15, 2026
Merged

Course ID schema migration + calendar assignment normalization#53
Jose-Gael-Cruz-Lopez merged 14 commits into
mainfrom
combined/course-id-migration-and-assignment-normalization

Conversation

@Jose-Gael-Cruz-Lopez

@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez commented Apr 14, 2026

Copy link
Copy Markdown
Member

Summary

Combines the work from PR #51 and PR #52 into a single merge.

From PR #51 (schema migration)

  • Backend: course_id FKs across graph, learn, calendar, course context; Pydantic models updated.
  • Frontend: api.ts uses course_id for courses, sessions, assignments; getCourses returns EnrolledCourse shape.

From PR #52 (calendar robustness)

  • normalizeAssignments() on calendar: default fields, deterministic fallback ids (missing-id-${index}), applied on load / save / Google sync refresh.

Combined follow-ups

  • Normalizer and types carry course_id and course_code from API responses.
  • Dashboard, library, and learn updated for EnrolledCourse / startSession(..., courseId?, useSharedContext).
  • extractSyllabus error path uses response status variable correctly; API tests aligned with course_id.

Supersedes

Verification: npm test -- --watchAll=false and npm run build pass on frontend/.

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Set course nicknames, choose course colors, and upload documents tied to a course.
    • Optionally specify a course when starting or resuming learning sessions.
  • Improvements

    • UI, calendar, and syllabus import use stable course identifiers and surface course codes.
    • Course management (add/update/color/delete) is ID-driven.
    • Course context now provides per-concept mastery metrics and AI-generated course summaries.
  • Bug Fixes

    • More consistent assignment normalization when saving, syncing, and rendering.

Jose Cruz and others added 6 commits April 13, 2026 22:22
This is a major schema refactoring to support canonical courses:

Backend Changes:
- course_context_service.py: Complete rewrite to use new course_concept_stats
  and course_summary tables with Gemini-powered summary generation
- graph_service.py: Updated to join user_courses->courses, use course_id FK
- routes/graph.py: Endpoints now accept course_id instead of course_name
- routes/learn.py: Sessions store course_id, pass through to graph updates
- routes/calendar.py: Assignments use course_id, join with courses for display
- calendar_service.py: Updated to use course_id instead of course_name
- models/__init__.py: Added course_id fields to Pydantic models

Frontend Changes:
- api.ts: Updated all API functions to use course_id instead of course_name
  - getCourses returns enriched data: course_code, course_name, school, dept
  - addCourse, updateCourseColor, deleteCourse all use course_id
  - startSession accepts optional course_id parameter
  - saveAssignments uses course_id

Database Schema Migration (already applied in Supabase):
- courses table is now canonical (no user_id)
- user_courses is the enrollment join table
- All dependent tables use course_id FK: assignments, sessions, graph_nodes,
  documents, study_guides
- New tables: course_concept_stats, course_summary
- Removed: course_context table
…mes"

This reverts commit 1ba4f7549504c64ff6358504fc9fdf38f1620888.
…malization

- Add course_code to Assignment type, normalizeAssignments, syllabus extract mapping
- Dashboard/library: use EnrolledCourse and course_id for color/delete flows
- Learn: pass course_id slot to startSession before shared-context flag
- AssignmentTable: default row includes course_code; optional assignment_type for select

Made-with: Cursor
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 14, 2026

Copy link
Copy Markdown

Deploying web with  Cloudflare Pages  Cloudflare Pages

Latest commit: f23816c
Status: ✅  Deploy successful!
Preview URL: https://38228d2e.web-75h.pages.dev
Branch Preview URL: https://combined-course-id-migration.web-75h.pages.dev

View logs

@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrate canonical course identity from freeform course_name to course_id across backend and frontend: models, routes, services, DB schema, session/graph/calendar flows, and TypeScript types updated so course-scoped data and enrollment use course_id.

Changes

Cohort / File(s) Summary
Models
backend/models/__init__.py
Added course_id to StartSessionBody; replaced AssignmentItem.course_name with course_id; added AddCourseBody, UpdateCourseColorBody, UploadDocumentBody.
Calendar Routes
backend/routes/calendar.py
Endpoints now join courses to include course_code/course_name, normalize assignment objects to include course_id, save_assignments sends course_id, and Google event summaries prefer [{course_code}] prefix.
Calendar Service & DB
backend/services/calendar_service.py, backend/db/supabase_schema.sql
insert_new_assignments persists course_id (nullable); DB schema adds assignments.course_id TEXT REFERENCES courses(id) with migration snippet.
Graph Service & Course Management
backend/services/graph_service.py, backend/routes/graph.py
Enrollment queries use user_coursescourses; get_graph builds course-scoped hubs labeled "course_code - course_name" and includes course_id; course CRUD endpoints accept course_id; apply_graph_update gains optional course_id; added nickname update.
Course Context Service
backend/services/course_context_service.py
Rewritten to aggregate enrolled users' graph_nodes by course_id, persist per-course course_concept_stats and course_summary, compute metrics, upsert by (course_id, semester), and regenerate Gemini summary only on hash change. Signatures now use course_id.
Learn Routes & Session Flow
backend/routes/learn.py
Session helpers now resolve/persist course_id; start_session accepts optional course_id and stores it in pending/DB session materialization; apply_graph_update calls include course_id; shared-context and document lookups use course_id.
Quiz Route
backend/routes/quiz.py
Quiz shared-context uses node course_id and get_course_context(course_id); misconception/weak-area extraction now reads concept_stats and deduplicates/prunes before appending addendum.
Documents Route
backend/routes/documents.py
When extracting syllabus assignments, each dict assignment is injected with request course_id before saving.
Frontend API & Types
frontend/src/lib/api.ts, frontend/src/lib/types.ts
Added EnrolledCourse, StartSessionRequest, Session, Assignment, SaveAssignmentItem; API signatures updated to accept/return course_id/courseId; assignment typings extended with optional course_code/course_id.
Calendar Frontend
frontend/src/app/calendar/page.tsx
Added normalizeAssignments, build course name→id map via getCourses, resolve course_id when saving detected syllabus assignments; normalize assignment fields before rendering.
Dashboard / Library / Learn Frontend
frontend/src/app/dashboard/page.tsx, frontend/src/app/library/page.tsx, frontend/src/app/learn/page.tsx
UI state, keys, handlers, and flows switched to course_id (add/update/delete/color flows updated); beginSession/startSession pass explicit course_id from selection/graph nodes/nickname.
Assignment Table Component
frontend/src/components/AssignmentTable.tsx
New-row initialization includes course_code and course_id; assignment-type select falls back to 'other' when missing.
Services: Calendar / Graph / Course Context
backend/services/*.py
Calendar writes/reads use course_id; graph service changed to be enrollment-centric and course-scoped; course-context service major rewrite to store aggregated per-course stats and summary.
Tests & CI
backend/tests/*, frontend/src/__tests__/api.test.ts, .github/workflows/*.yml
Tests updated to new shapes and identifiers (course_id); quiz tests refactored to use a shared context manager; OCR tests skip without GEMINI key; Claude workflow steps set continue-on-error: true.
Main / Middleware
backend/main.py
RecostMiddleware import made optional; middleware registered only when package present and API key set.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client
  participant Frontend
  participant LearnRoute as Backend /learn
  participant GraphSvc as graph_service.apply_graph_update
  participant DB as Supabase
  participant CourseCtx as course_context_service
  participant Gemini as External AI

  Client->>Frontend: beginSession(topic, mode, optional course_id)
  Frontend->>LearnRoute: POST /start-session (topic, mode, course_id?)
  LearnRoute->>DB: insert/select session (include course_id)
  LearnRoute->>GraphSvc: apply_graph_update(user_id, graph_update, course_id)
  GraphSvc->>DB: upsert graph_nodes with course_id
  GraphSvc->>CourseCtx: update_course_context(course_id)
  CourseCtx->>DB: aggregate graph_nodes by course_id -> upsert course_concept_stats/course_summary
  alt summary changed
    CourseCtx->>Gemini: request summary_text
    Gemini-->>CourseCtx: summary_text
    CourseCtx->>DB: store summary_text and summary_hash
  end
  LearnRoute-->>Frontend: 200 OK (session_id, course_id)
  Frontend-->>Client: session started
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Poem

🐇 I hopped through models, types, and state,
I swapped loose names for IDs up-to-date,
Sessions, nodes, and syllabus now all agree,
From UI to DB — a canonical tree,
Thump-thump — neat courses, on every plate!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.07% 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 name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main objectives: migrating from course_name to course_id schema, and normalizing calendar assignments.
Description check ✅ Passed The description is comprehensive and well-structured, covering objectives from both PR #51 and #52, verification steps, and related issue numbers.
Linked Issues check ✅ Passed The pull request successfully implements all coding requirements from both #51 and #52: backend course_id migration across services/routes, Pydantic models updated, frontend api.ts and types refactored, and assignment normalization added.
Out of Scope Changes check ✅ Passed All changes are within scope: core course_id migration, assignment normalization, supporting components updated, optional middleware handling, and workflow robustness improvements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch combined/course-id-migration-and-assignment-normalization

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

@coderabbitai coderabbitai Bot 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: 4

Caution

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

⚠️ Outside diff range comments (4)
frontend/src/components/AssignmentTable.tsx (1)

72-83: ⚠️ Potential issue | 🟠 Major

Rows added here cannot ever acquire a valid course_id.

New rows start with course_id: '', and the table only lets the user edit course_name. After the migration, that means a user-added row can look valid in the UI but still has no canonical FK when the calendar flow saves it. This needs a course selector or a name→id resolution step before persistence.

Also applies to: 239-247

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/AssignmentTable.tsx` around lines 72 - 83, The add
function in AssignmentTable.tsx creates new Assignment rows with course_id set
to '' which means user-edited course_name never maps to the canonical FK; update
the UI/logic so new rows either provide a course selector or perform a name→id
resolution before persistence: modify the add handler (and the equivalent logic
around lines 239-247) to populate course_id from the currently selected course
or open a course-picker when creating the row, and/or add a
resolveCourseNameToId step in the save/submit flow that looks up the canonical
course_id for the edited course_name and sets assignment.course_id before
sending to the backend. Ensure the change references the add function and the
save/submit flow in AssignmentTable.tsx so no persisted row has an empty
course_id.
backend/services/calendar_service.py (1)

40-45: ⚠️ Potential issue | 🟠 Major

Do not persist an empty string as course_id.

course_id is now a canonical FK. Coercing a missing value to "" here will either fail the insert or create assignments that can never join back to a course. Reject unresolved assignments at this boundary, or keep the field nullable until it has been resolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/calendar_service.py` around lines 40 - 45, The code in
rows.append() (in calendar_service.py) must not coerce missing course_id to an
empty string; change the behavior around "course_id": a.get("course_id") or "" —
either validate and reject/skip unresolved assignments before appending (e.g.,
raise ValueError or return an error when a.get("course_id") is falsy) or
preserve a nullable value (use None) and ensure downstream insert allows NULL;
update the function that builds rows (the rows.append block and any callers that
use variable a) to enforce this rule and add a clear failure path for missing
course_id instead of persisting "".
frontend/src/app/learn/page.tsx (1)

90-106: ⚠️ Potential issue | 🟠 Major

Selected courses are no longer propagated to startSession.

This page collapses getCourses() down to course names, and the changed call now hardcodes courseId to undefined. Sessions launched from the course picker therefore lose their canonical course association, so the backend cannot persist them against the migrated course_id model.

Also applies to: 173-180

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/app/learn/page.tsx` around lines 90 - 106, The code reduces
getCourses() to only course_name strings (setApiCourseNames) and builds a color
map by name, which loses course_id so startSession is being called with courseId
undefined; restore and propagate canonical IDs by changing getCourses handling
to keep an array or map of course objects (or a Record course_name->course_id),
update state (e.g., apiCourses or apiCourseMap instead of apiCourseNames) and
adjust the courses useMemo and the course picker to use/display names but pass
the corresponding course_id into startSession (also update the same logic
referenced around lines 173-180) so backend sessions receive the correct
course_id.
frontend/src/app/library/page.tsx (1)

349-365: ⚠️ Potential issue | 🟠 Major

This add-course flow still enrolls by free-text name, not canonical id.

addCourse() now expects a catalog course_id, but this handler still sends the typed label from newCourseName and then tries to recover the created enrollment by matching course_name. Unless the typed value already equals the canonical id, enrollment will miss the catalog or select the wrong course. This UI needs a catalog-backed picker or an explicit name→id resolution step before calling the API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/app/library/page.tsx` around lines 349 - 365, The handler
handleAddCourse is sending the free-text newCourseName to addCourse but
addCourse now requires a canonical catalog course_id; insert a resolution step
before calling addCourse: lookup the catalog id for the typed label (e.g., via a
new helper like fetchCourseIdByLabel(name) or an existing catalog search API),
handle the not-found case by setting course add error, and pass the resolved
course_id to addCourse instead of the raw name; keep the rest of the flow (color
selection, calling getCourses, setting selectedCourseId) unchanged and ensure
you match created by course_id (not course_name) when choosing the selected
course.
🧹 Nitpick comments (3)
backend/routes/learn.py (1)

514-518: Inefficient: Duplicate database query for topic.

The topic is fetched twice with identical queries. Consider storing the result of the first query.

♻️ Refactor to single query
     _ensure_session_ready(body.session_id, body.user_id)
     student_name = get_user_name(body.user_id).split()[0]
-    topic = table("sessions").select(
-        "topic", filters={"id": f"eq.{body.session_id}"}, limit=1
-    )[0]["topic"] if table("sessions").select(
-        "topic", filters={"id": f"eq.{body.session_id}"}, limit=1
-    ) else "this topic"
+    session_rows = table("sessions").select(
+        "topic", filters={"id": f"eq.{body.session_id}"}, limit=1
+    )
+    topic = session_rows[0]["topic"] if session_rows else "this topic"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/learn.py` around lines 514 - 518, The code makes the same
table("sessions").select(...) call twice when computing topic; replace the
duplicate by calling table("sessions").select(...) once (e.g., assign its result
to a local variable like session_rows), then set topic =
session_rows[0]["topic"] if session_rows else "this topic"; update any
references that used the original expression to use the stored result and keep
using body.session_id as the filter parameter.
backend/routes/calendar.py (1)

180-191: Unused variable: course_label is computed but never used.

The course_label variable at line 183 is constructed but not included in the study block dict. Was this intended to be used in the topic field?

🔧 If intended, include course_label in the block
     for a in assignments:
         course = a.get("courses", {}) if isinstance(a.get("courses"), dict) else {}
         course_label = f"[{course.get('course_code', '')}] " if course.get('course_code') else ""
         blocks.append({
-            "topic": a["title"],
+            "topic": f"{course_label}{a['title']}" if course_label else a["title"],
             "suggested_date": a["due_date"],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/calendar.py` around lines 180 - 191, The code computes
course_label but never uses it; update the study block creation in the function
that builds blocks (the loop iterating over assignments) to include course_label
in the "topic" field (e.g., replace "topic": a["title"] with "topic":
f"{course_label}{a['title']}") so the course code is prepended when present, or
alternatively remove the unused course_label if that information is
intentionally omitted; keep the rest of the block keys (suggested_date,
duration_minutes, reason, related_assignment_id) unchanged.
backend/services/course_context_service.py (1)

55-63: Consider logging Gemini failures for observability.

The bare except Exception silently falls back to a static summary. For production debugging, consider logging the exception before falling back.

🔧 Add logging for Gemini failures
+import logging
+
+logger = logging.getLogger(__name__)
+
 def _generate_summary_with_gemini(...) -> str:
     ...
     try:
         return call_gemini(prompt, retries=1)
     except Exception:
+        logger.warning("Gemini summary generation failed, using fallback", exc_info=True)
         # Fallback summary if Gemini fails
         return (...)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/course_context_service.py` around lines 55 - 63, The except
block that swallows errors from call_gemini should log the exception before
returning the fallback summary: change the bare except to "except Exception as
e" and call the module logger (e.g., logger.exception(...) or logger.error(...,
exc_info=True)) with a concise message like "Gemini call failed" and the
exception, then return the existing fallback using avg_class_mastery,
top_struggling, and top_mastered; ensure you reference the existing logger
instance or create one with logging.getLogger(...) if none exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/routes/calendar.py`:
- Around line 118-141: The assignments queries in calendar.py use PostgREST FK
join syntax ("courses!inner(course_code,course_name)") but the assignments table
lacks a course_id FK, so either add a DB migration to add assignments.course_id
referencing courses.id and update schema/migrations accordingly (then keep the
join and ensure insert_new_assignments in calendar_service.py writes course_id),
or change the code to stop using FK join: remove "courses!inner(...)" from
calendar.py, fetch course info by querying the courses table by course_name (or
perform an explicit join via a manual query) and update the transformation logic
to derive course_code/course_name from that lookup, and also update
insert_new_assignments to stop writing a non-existent course_id column (write
course_name only or resolve to a course_id via a lookup before insert). Ensure
these changes are applied consistently for the handlers used by
/upcoming/{user_id}, /all/{user_id}, /suggest-study-blocks and /sync-to-google
and reference functions/calendar.py and insert_new_assignments in
calendar_service.py when making edits.

In `@backend/routes/graph.py`:
- Around line 40-42: The create_course route currently returns the raw result
from add_course which can be {"error": "Course not found in catalog"} and thus
returns HTTP 200; update create_course to inspect the return value from
add_course (or the underlying canonical lookup response) and if it contains an
"error" key raise an HTTPException with an appropriate 4xx status (e.g., 404)
and detail set to the error message; ensure HTTPException is imported from
fastapi and keep successful add_course returns unchanged so clients only receive
200 for truly successful enrollments.

In `@backend/routes/learn.py`:
- Line 331: Replace the undefined bare session_id used in the
PENDING_SESSIONS.pop call with the correct property from the request body (use
body.session_id) so the pending session is removed using the provided session
identifier; update the PENDING_SESSIONS.pop(...) invocation to reference
body.session_id (the same body variable used elsewhere in the surrounding
function) to avoid the NameError.

In `@backend/services/course_context_service.py`:
- Around line 66-72: The caller in quiz.py is passing a subject name to
get_course_context(course_id: str), causing it to return {} because it filters
by course_id; update the caller to pass the actual course identifier (e.g., use
the node's course_id field or resolve the subject->course_id before calling) so
get_course_context receives the course_id it expects, or alternatively add a
short lookup inside get_course_context to resolve a subject name to the
corresponding course_id before querying; reference get_course_context and the
quiz.py call to get_course_context(subject) when making the change.

---

Outside diff comments:
In `@backend/services/calendar_service.py`:
- Around line 40-45: The code in rows.append() (in calendar_service.py) must not
coerce missing course_id to an empty string; change the behavior around
"course_id": a.get("course_id") or "" — either validate and reject/skip
unresolved assignments before appending (e.g., raise ValueError or return an
error when a.get("course_id") is falsy) or preserve a nullable value (use None)
and ensure downstream insert allows NULL; update the function that builds rows
(the rows.append block and any callers that use variable a) to enforce this rule
and add a clear failure path for missing course_id instead of persisting "".

In `@frontend/src/app/learn/page.tsx`:
- Around line 90-106: The code reduces getCourses() to only course_name strings
(setApiCourseNames) and builds a color map by name, which loses course_id so
startSession is being called with courseId undefined; restore and propagate
canonical IDs by changing getCourses handling to keep an array or map of course
objects (or a Record course_name->course_id), update state (e.g., apiCourses or
apiCourseMap instead of apiCourseNames) and adjust the courses useMemo and the
course picker to use/display names but pass the corresponding course_id into
startSession (also update the same logic referenced around lines 173-180) so
backend sessions receive the correct course_id.

In `@frontend/src/app/library/page.tsx`:
- Around line 349-365: The handler handleAddCourse is sending the free-text
newCourseName to addCourse but addCourse now requires a canonical catalog
course_id; insert a resolution step before calling addCourse: lookup the catalog
id for the typed label (e.g., via a new helper like fetchCourseIdByLabel(name)
or an existing catalog search API), handle the not-found case by setting course
add error, and pass the resolved course_id to addCourse instead of the raw name;
keep the rest of the flow (color selection, calling getCourses, setting
selectedCourseId) unchanged and ensure you match created by course_id (not
course_name) when choosing the selected course.

In `@frontend/src/components/AssignmentTable.tsx`:
- Around line 72-83: The add function in AssignmentTable.tsx creates new
Assignment rows with course_id set to '' which means user-edited course_name
never maps to the canonical FK; update the UI/logic so new rows either provide a
course selector or perform a name→id resolution before persistence: modify the
add handler (and the equivalent logic around lines 239-247) to populate
course_id from the currently selected course or open a course-picker when
creating the row, and/or add a resolveCourseNameToId step in the save/submit
flow that looks up the canonical course_id for the edited course_name and sets
assignment.course_id before sending to the backend. Ensure the change references
the add function and the save/submit flow in AssignmentTable.tsx so no persisted
row has an empty course_id.

---

Nitpick comments:
In `@backend/routes/calendar.py`:
- Around line 180-191: The code computes course_label but never uses it; update
the study block creation in the function that builds blocks (the loop iterating
over assignments) to include course_label in the "topic" field (e.g., replace
"topic": a["title"] with "topic": f"{course_label}{a['title']}") so the course
code is prepended when present, or alternatively remove the unused course_label
if that information is intentionally omitted; keep the rest of the block keys
(suggested_date, duration_minutes, reason, related_assignment_id) unchanged.

In `@backend/routes/learn.py`:
- Around line 514-518: The code makes the same table("sessions").select(...)
call twice when computing topic; replace the duplicate by calling
table("sessions").select(...) once (e.g., assign its result to a local variable
like session_rows), then set topic = session_rows[0]["topic"] if session_rows
else "this topic"; update any references that used the original expression to
use the stored result and keep using body.session_id as the filter parameter.

In `@backend/services/course_context_service.py`:
- Around line 55-63: The except block that swallows errors from call_gemini
should log the exception before returning the fallback summary: change the bare
except to "except Exception as e" and call the module logger (e.g.,
logger.exception(...) or logger.error(..., exc_info=True)) with a concise
message like "Gemini call failed" and the exception, then return the existing
fallback using avg_class_mastery, top_struggling, and top_mastered; ensure you
reference the existing logger instance or create one with logging.getLogger(...)
if none exists.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9724a29e-b14d-4462-9bec-16425dd1c515

📥 Commits

Reviewing files that changed from the base of the PR and between c0db38e and 20d59fd.

📒 Files selected for processing (15)
  • backend/models/__init__.py
  • backend/routes/calendar.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/services/calendar_service.py
  • backend/services/course_context_service.py
  • backend/services/graph_service.py
  • frontend/src/__tests__/api.test.ts
  • frontend/src/app/calendar/page.tsx
  • frontend/src/app/dashboard/page.tsx
  • frontend/src/app/learn/page.tsx
  • frontend/src/app/library/page.tsx
  • frontend/src/components/AssignmentTable.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts

Comment thread backend/routes/calendar.py Outdated
Comment thread backend/routes/graph.py Outdated
Comment thread backend/routes/learn.py Outdated
Comment thread backend/services/course_context_service.py
…arg, and missing FK

- learn.py: fix NameError on PENDING_SESSIONS.pop(session_id) -> body.session_id
- graph.py: raise HTTPException(404) when add_course returns {"error": ...}
- quiz.py: pass node course_id to get_course_context instead of subject name
- calendar.py: use left join (courses!left) so assignments without course_id still appear
- calendar_service.py: insert None instead of "" for missing course_id (FK constraint)
- supabase_schema.sql: add course_id FK column to assignments table + migration comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread backend/routes/calendar.py Fixed
Comment thread backend/routes/calendar.py Fixed
Comment thread backend/routes/calendar.py Fixed
Comment thread backend/services/graph_service.py Fixed
Comment thread backend/routes/learn.py Fixed

@coderabbitai coderabbitai Bot 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: 2

Caution

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

⚠️ Outside diff range comments (1)
backend/routes/quiz.py (1)

47-67: ⚠️ Potential issue | 🟠 Major

Use the new get_course_context() payload shape here.

backend/services/course_context_service.py:66-107 now returns {"course_summary": ..., "concept_stats": ...}. Reading common_misconceptions and weak_areas from the top level always yields empty lists, so shared course context never makes it into the quiz prompt.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/quiz.py` around lines 47 - 67, The code reads
common_misconceptions and weak_areas from the top-level course_ctx but the
service now returns {"course_summary": ..., "concept_stats": ...}; update the
block that calls get_course_context() to pull concept_stats =
course_ctx.get("concept_stats", {}) and then set misconceptions =
concept_stats.get("common_misconceptions", []) and weak_areas =
concept_stats.get("weak_areas", []), leaving the rest of the logic that builds
addendum_parts and appends to prompt unchanged (referencing get_course_context,
course_ctx, misconceptions, weak_areas, and prompt).
🧹 Nitpick comments (1)
backend/routes/calendar.py (1)

118-125: Drop the commentary that just repeats the next statement.

The added “join/transform” comments do not explain anything non-obvious and make these handlers noisier to scan.

As per coding guidelines, "**/*.{ts,tsx,py}: No docstrings or comments unless the logic is non-obvious."

Also applies to: 147-153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/calendar.py` around lines 118 - 125, Remove the redundant
“Join with courses to get course_code and course_name” and “Transform to include
course info at top level” comments that simply restate the following code; in
the handler around the rows assignment (the call to table("assignments").select
and subsequent transformation of rows) delete those two comments and only add a
brief comment if you need to explain non-obvious logic — otherwise leave the
code self-documenting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/db/supabase_schema.sql`:
- Around line 111-120: The bootstrap schema is out of sync with the migrated
runtime model: update the SQL to match the current runtime tables by removing
the old user-scoped design for courses and adding the new columns/tables
referenced by the routes—specifically modify the courses table to include
course_code (and any non-user-scoped primary key structure used now), add the
user_courses join table, and add course_id columns to sessions and graph_nodes
(ensuring they reference courses(id)); also keep assignments.course_id as-is,
remove or update the stale migration comment, and ensure all FK constraints and
defaults align with the runtime model so a fresh DB init matches the codepaths
that read/write these fields.

In `@backend/routes/calendar.py`:
- Around line 127-141: The code that builds the assignments dict from DB rows
uses the joined courses object (variable course) for course_name/course_code and
loses legacy assignments that have course_name on the assignments row; update
the construction in the loop that creates assignments (the block that sets
"course_id", "course_code", "course_name") to fall back to the legacy column
when the join is missing (e.g., use course.get("course_name",
r.get("course_name", "")) and similarly for course_code), and make the same
fallback change to the other assignment-building loops referenced in this file
(the other blocks that construct assignments lists for /upcoming, /all, and the
Google sync/export code).

---

Outside diff comments:
In `@backend/routes/quiz.py`:
- Around line 47-67: The code reads common_misconceptions and weak_areas from
the top-level course_ctx but the service now returns {"course_summary": ...,
"concept_stats": ...}; update the block that calls get_course_context() to pull
concept_stats = course_ctx.get("concept_stats", {}) and then set misconceptions
= concept_stats.get("common_misconceptions", []) and weak_areas =
concept_stats.get("weak_areas", []), leaving the rest of the logic that builds
addendum_parts and appends to prompt unchanged (referencing get_course_context,
course_ctx, misconceptions, weak_areas, and prompt).

---

Nitpick comments:
In `@backend/routes/calendar.py`:
- Around line 118-125: Remove the redundant “Join with courses to get
course_code and course_name” and “Transform to include course info at top level”
comments that simply restate the following code; in the handler around the rows
assignment (the call to table("assignments").select and subsequent
transformation of rows) delete those two comments and only add a brief comment
if you need to explain non-obvious logic — otherwise leave the code
self-documenting.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1f2047-ace2-4deb-a3b3-806813c28c1a

📥 Commits

Reviewing files that changed from the base of the PR and between 20d59fd and a46692f.

📒 Files selected for processing (6)
  • backend/db/supabase_schema.sql
  • backend/routes/calendar.py
  • backend/routes/graph.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/services/calendar_service.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/services/calendar_service.py
  • backend/routes/graph.py

Comment thread backend/db/supabase_schema.sql Outdated
Comment thread backend/routes/calendar.py
…e resolution

- quiz: aggregate misconceptions and prerequisite_gaps from concept_stats rows
- learn: match enrolled course by graph subject label; dedupe session topic query
- calendar: fallback course_name/course_code from assignment row; study/sync labels
- frontend: pass course_id from graph nodes and course picker; resolve course_id on syllabus save; library/dashboard addCourse errors and post-add selection
- tests: update quiz augmentation mocks for course_id and concept_stats

Made-with: Cursor
Comment thread backend/routes/learn.py Fixed

@coderabbitai coderabbitai Bot 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: 3

♻️ Duplicate comments (1)
backend/routes/learn.py (1)

75-76: ⚠️ Potential issue | 🟠 Major

Don't swallow the DB lookup failures here.

These except Exception: pass blocks make the new course_id migration fail silently, which is especially risky in the two helpers that decide course resolution and prompt context. Please log the exception plus the lookup key and then keep the current fallback behavior.

Minimal fix
-    except Exception:
-        pass
+    except Exception as e:
+        print(f"Failed to resolve course_id for topic={topic_trim!r} user_id={user_id!r}: {e}")
@@
-    except Exception:
-        pass
+    except Exception as e:
+        print(f"Failed to load course info for course_id={course_id!r}: {e}")

Also applies to: 145-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/learn.py` around lines 75 - 76, Replace the silent except
blocks that read "except Exception: pass" inside the helpers that resolve
course_id and build prompt context with error-logging handlers: catch the
exception as e, log the exception and the lookup key (e.g., the course lookup
value or course_id being used) via the existing logger (or processLogger)
including context like "course lookup failed for key=...", and then continue to
return the current fallback value so behavior is unchanged; apply the same
change to both occurrences mentioned (the course resolution helper and the
prompt-context helper).
🧹 Nitpick comments (1)
backend/routes/learn.py (1)

232-233: Trim the restated inline comments.

These comments only narrate the next line, so they add noise without clarifying anything non-obvious.

As per coding guidelines, "No docstrings or comments unless the logic is non-obvious".

Also applies to: 258-260, 307-309, 492-494

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/learn.py` around lines 232 - 233, Remove the redundant inline
comments that simply restate the next line of code (e.g., the comment
immediately above the session_data dict in backend/routes/learn.py) and the
other similar narrating comments around the session creation, progress update,
and response handling blocks; leave only comments that explain non-obvious logic
or intent and ensure no docstring-style or line-by-line narration remains so the
code is concise and follows the "no comments unless non-obvious" guideline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/routes/learn.py`:
- Around line 56-58: The queries in learn.py (e.g., the enrolled selection using
table("user_courses") and later queries/inserts referencing
graph_nodes.course_id and sessions.course_id) will fail because the DB schema
lacks course_code and course_id columns and silent except blocks mask these
errors; add the three schema changes (ALTER TABLE courses ADD COLUMN course_code
TEXT; ALTER TABLE graph_nodes ADD COLUMN course_id TEXT REFERENCES courses(id);
ALTER TABLE sessions ADD COLUMN course_id TEXT REFERENCES courses(id)) in your
migration, and replace the broad silent excepts around the Supabase calls (the
try/except blocks that currently do "except Exception: pass") with error
logging/raising so API errors are not swallowed and course resolution can
surface failures.

In `@backend/routes/quiz.py`:
- Around line 47-50: The graph_nodes schema is missing course_id so inserts in
backend/services/graph_service.py (the code that sets "course_id":
node_course_id) and reads in backend/routes/quiz.py (node.get("course_id", ""))
are ineffective; fix by adding course_id TEXT REFERENCES courses(id) to the
graph_nodes CREATE TABLE in backend/db/supabase_schema.sql and add a migration
comment/SQL to backfill or add the column for existing deployments, then verify
the insert path in the graph insert function and the read in routes/quiz.py
continue to use node_course_id/get("course_id") correctly; alternatively, if you
don't want schema changes, remove the course_id insert from the graph service
and the read/usage in routes/quiz.py (and any get_course_context usage) so the
code and schema are consistent.

In `@backend/tests/test_shared_course_context.py`:
- Around line 426-437: The test fixtures currently include both subject and
course_id so they won't detect a regression reverting to
get_course_context(node["subject"]); update the quiz-augmentation tests that set
mock_table/mock_ctx (the blocks around mock_table.return_value.select and
mock_ctx.return_value) to assert the new lookup by calling
mock_ctx.assert_called_once_with("c1") after generate_quiz() (or alternatively
remove the legacy "subject" field from the mocked row), ensuring the code uses
course_id rather than subject; apply the same change to the other two test
locations mentioned.

---

Duplicate comments:
In `@backend/routes/learn.py`:
- Around line 75-76: Replace the silent except blocks that read "except
Exception: pass" inside the helpers that resolve course_id and build prompt
context with error-logging handlers: catch the exception as e, log the exception
and the lookup key (e.g., the course lookup value or course_id being used) via
the existing logger (or processLogger) including context like "course lookup
failed for key=...", and then continue to return the current fallback value so
behavior is unchanged; apply the same change to both occurrences mentioned (the
course resolution helper and the prompt-context helper).

---

Nitpick comments:
In `@backend/routes/learn.py`:
- Around line 232-233: Remove the redundant inline comments that simply restate
the next line of code (e.g., the comment immediately above the session_data dict
in backend/routes/learn.py) and the other similar narrating comments around the
session creation, progress update, and response handling blocks; leave only
comments that explain non-obvious logic or intent and ensure no docstring-style
or line-by-line narration remains so the code is concise and follows the "no
comments unless non-obvious" guideline.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ea9b5b0-f656-4543-8b62-59a02431ca2d

📥 Commits

Reviewing files that changed from the base of the PR and between a46692f and 5434504.

📒 Files selected for processing (9)
  • backend/routes/calendar.py
  • backend/routes/learn.py
  • backend/routes/quiz.py
  • backend/tests/test_shared_course_context.py
  • frontend/src/app/calendar/page.tsx
  • frontend/src/app/dashboard/page.tsx
  • frontend/src/app/learn/page.tsx
  • frontend/src/app/library/page.tsx
  • frontend/src/lib/types.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/src/app/learn/page.tsx
  • frontend/src/app/calendar/page.tsx
  • backend/routes/calendar.py

Comment thread backend/routes/learn.py
Comment thread backend/routes/quiz.py
Comment thread backend/tests/test_shared_course_context.py
…se_id model

- GitHub Actions: continue-on-error on Claude Code steps so API limits do not block PRs
- main.py: optional recost import for environments without the package
- test_shared_course_context: course_summary/concept_stats mocks; learn build_system_prompt uses course_id
- test_learn_routes: _get_course_id_for_topic coverage; graph apply_graph_update uses course_id
- test_quiz_routes: patch routes.quiz.update_streak (import binding) to avoid real Supabase
- test_graph_service: user_courses enrollment mocks; add_course/delete_course catalog semantics
- test_calendar_routes: upcoming assignment mocks include user_id
- test_ocr_pipeline: skip when GEMINI_API_KEY unset

Made-with: Cursor

@coderabbitai coderabbitai Bot 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: 2

🧹 Nitpick comments (3)
backend/tests/test_calendar_routes.py (1)

122-150: Strengthen this test to validate the expanded response contract.

The fixture now includes additional fields, but the test still only checks title. Add assertions for the newly mocked fields (and defaulted course_code/course_name) so mapping regressions are caught.

Proposed test assertion update
         assert r.status_code == 200
-        assert len(r.json()["assignments"]) == 2
-        assert r.json()["assignments"][0]["title"] == "HW1"
+        assignments = r.json()["assignments"]
+        assert len(assignments) == 2
+        assert assignments[0]["title"] == "HW1"
+        assert assignments[0]["user_id"] == "user_andres"
+        assert assignments[0]["assignment_type"] == "homework"
+        assert assignments[0]["notes"] is None
+        assert assignments[0]["google_event_id"] is None
+        assert assignments[0]["course_id"] is None
+        assert assignments[0]["course_code"] == ""
+        assert assignments[0]["course_name"] == ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_calendar_routes.py` around lines 122 - 150, Update the
test that patches routes.calendar.table and calls
client.get("/api/calendar/upcoming/user_andres") to assert the full expanded
response contract: after confirming r.status_code == 200 and
len(r.json()["assignments"]) == 2, add assertions on r.json()["assignments"][0]
and [1] for the mocked fields ("id", "user_id", "due_date", "assignment_type",
"notes", "google_event_id", "course_id") and also assert the default-mapped
fields "course_code" and "course_name" are present and have expected default
values; locate the mock_rows in the test and add the new assertions immediately
after the existing title check so mapping regressions in the calendar route are
caught.
backend/tests/test_quiz_routes.py (1)

207-244: Consider refactoring to reduce nesting depth.

This test still uses 5 levels of nested with patch(...) while other tests use _submit_quiz_mocks(). Since this test needs a custom table factory for the JSON-string scenario, consider parameterizing the context manager:

♻️ Suggested refactor
 `@contextmanager`
-def _submit_quiz_mocks():
+def _submit_quiz_mocks(table_factory=None):
+    factory = table_factory or _make_table()
     with (
-        patch("routes.quiz.table", side_effect=_make_table()),
+        patch("routes.quiz.table", side_effect=factory),
         patch("routes.quiz.update_streak"),
         patch("routes.quiz.get_graph", return_value={"nodes": [], "edges": [], "stats": {}}),
         patch("routes.quiz.get_quiz_context", return_value={}),
         patch("routes.quiz.call_gemini_json", return_value={}),
     ):
         yield

Then simplify the test:

     def test_questions_json_as_string_is_parsed(self):
         """quiz_attempts.questions_json can arrive as a JSON string from the DB."""
         import json as _json
-
-        def factory(name):
-            ...
-
-        with patch("routes.quiz.table", side_effect=factory):
-            with patch("routes.quiz.update_streak"):
-                with patch("routes.quiz.get_graph", return_value={"nodes": [], "edges": [], "stats": {}}):
-                    with patch("routes.quiz.get_quiz_context", return_value={}):
-                        with patch("routes.quiz.call_gemini_json", return_value={}):
-                            r = client.post(...)
+        # define custom factory...
+        with _submit_quiz_mocks(table_factory=factory):
+            r = client.post(...)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_quiz_routes.py` around lines 207 - 244, The test
test_questions_json_as_string_is_parsed currently nests five with-patch blocks
to inject a custom factory for routes.quiz.table; refactor by extending the
existing _submit_quiz_mocks helper to accept an optional table_factory (or a
flag) so you can reuse _submit_quiz_mocks() and provide the custom factory that
returns serialized questions_json, then replace the nested with
patch("routes.quiz.table", side_effect=factory) plus the other with patches with
a single call to _submit_quiz_mocks(table_factory=factory) (or similar) inside
the test; ensure the helper still patches update_streak, get_graph,
get_quiz_context, and call_gemini_json so behavior and assertions in
test_questions_json_as_string_is_parsed remain unchanged.
backend/tests/test_graph_service.py (1)

162-170: Reuse _enrollment_row() for this fixture.

This reintroduces the enrollment shape inline even though the file now has a shared helper for it. Using _enrollment_row("c1", "", "Math") here will keep the user_courses mock schema from drifting across tests.

♻️ Small cleanup
             if name == "user_courses":
-                mock.select.return_value = [{
-                    "id": "e1",
-                    "course_id": "c1",
-                    "color": "#fff",
-                    "nickname": None,
-                    "enrolled_at": "2026-01-01",
-                    "courses": {"course_code": "", "course_name": "Math", "school": "", "department": ""},
-                }]
+                row = _enrollment_row("c1", "", "Math")
+                row["color"] = "#fff"
+                mock.select.return_value = [row]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_graph_service.py` around lines 162 - 170, Replace the
inline enrollment dict in the user_courses branch with the shared helper to
avoid schema drift: when name == "user_courses" set mock.select.return_value to
a list containing _enrollment_row("c1", "", "Math") instead of the hard-coded
dict; ensure you import/visible _enrollment_row in this test module and keep the
surrounding keys (id, course_id, color, nickname, enrolled_at, courses)
determined by that helper so the mocked shape stays consistent across tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/tests/test_graph_service.py`:
- Around line 232-245: The test currently patches services.graph_service.table
to always return the same MagicMock so delete_course may be deleting from the
wrong table; change the test_unenroll_with_no_prior_nodes to patch
services.graph_service.table with a side_effect that returns distinct mocks for
the 'user_courses' and 'courses' table names (or otherwise return different
mocks based on the table argument), call delete_course("u1", "empty-course-id"),
and assert that the specific user_courses mock had its delete method called (and
optionally asserted with the expected key), while the courses mock was not used
— reference the delete_course function and services.graph_service.table to
locate where to apply the side_effect and assertions.
- Around line 196-227: Update the tests to assert the actual DB interactions
rather than only the return dict: when using the factory/patch for
services.graph_service.table in test_inserts_new_course, verify that the mock
for "courses" had select called and that the mock for "user_courses" had insert
called with the expected enrollment payload (and that no other table's insert
was called); in test_skips_insert_for_existing_course, assert that
"user_courses".insert was not called and that the "courses" lookup was still
exercised as expected. Reference the existing factory, the patched
services.graph_service.table, and the add_course call to locate where to add
these assertions.

---

Nitpick comments:
In `@backend/tests/test_calendar_routes.py`:
- Around line 122-150: Update the test that patches routes.calendar.table and
calls client.get("/api/calendar/upcoming/user_andres") to assert the full
expanded response contract: after confirming r.status_code == 200 and
len(r.json()["assignments"]) == 2, add assertions on r.json()["assignments"][0]
and [1] for the mocked fields ("id", "user_id", "due_date", "assignment_type",
"notes", "google_event_id", "course_id") and also assert the default-mapped
fields "course_code" and "course_name" are present and have expected default
values; locate the mock_rows in the test and add the new assertions immediately
after the existing title check so mapping regressions in the calendar route are
caught.

In `@backend/tests/test_graph_service.py`:
- Around line 162-170: Replace the inline enrollment dict in the user_courses
branch with the shared helper to avoid schema drift: when name == "user_courses"
set mock.select.return_value to a list containing _enrollment_row("c1", "",
"Math") instead of the hard-coded dict; ensure you import/visible
_enrollment_row in this test module and keep the surrounding keys (id,
course_id, color, nickname, enrolled_at, courses) determined by that helper so
the mocked shape stays consistent across tests.

In `@backend/tests/test_quiz_routes.py`:
- Around line 207-244: The test test_questions_json_as_string_is_parsed
currently nests five with-patch blocks to inject a custom factory for
routes.quiz.table; refactor by extending the existing _submit_quiz_mocks helper
to accept an optional table_factory (or a flag) so you can reuse
_submit_quiz_mocks() and provide the custom factory that returns serialized
questions_json, then replace the nested with patch("routes.quiz.table",
side_effect=factory) plus the other with patches with a single call to
_submit_quiz_mocks(table_factory=factory) (or similar) inside the test; ensure
the helper still patches update_streak, get_graph, get_quiz_context, and
call_gemini_json so behavior and assertions in
test_questions_json_as_string_is_parsed remain unchanged.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5ef4e8e8-e012-4fa0-a49c-4086e0b84a7b

📥 Commits

Reviewing files that changed from the base of the PR and between 5434504 and 60dab9d.

📒 Files selected for processing (9)
  • .github/workflows/claude-code-review.yml
  • .github/workflows/claude.yml
  • backend/main.py
  • backend/tests/test_calendar_routes.py
  • backend/tests/test_graph_service.py
  • backend/tests/test_learn_routes.py
  • backend/tests/test_ocr_pipeline.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py

Comment thread backend/tests/test_graph_service.py
Comment on lines +232 to +245
def test_unenrolls_user_from_course(self):
mock = MagicMock()
mock.delete.return_value = []
with patch("services.graph_service.table", return_value=mock):
result = delete_course("u1", "course-id-1")

assert result == {"deleted": True}
mock.delete.assert_called_once()

def test_deletes_course_with_no_nodes(self):
mock = _simple_mock(select_returns=[])
def test_unenroll_with_no_prior_nodes(self):
mock = MagicMock()
mock.delete.return_value = []
with patch("services.graph_service.table", return_value=mock):
result = delete_course("u1", "EmptyCourse")
result = delete_course("u1", "empty-course-id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Pin the unenroll target, not just “some delete happened.”

Because table() always returns the same mock here, this still passes if delete_course() deletes from courses instead of user_courses. The migration coverage is much stronger if the test asserts the table name and the delete call on that specific mock.

🧪 Tighter assertion for the unenroll path
     def test_unenrolls_user_from_course(self):
-        mock = MagicMock()
-        mock.delete.return_value = []
-        with patch("services.graph_service.table", return_value=mock):
+        tables = {"user_courses": MagicMock()}
+        tables["user_courses"].delete.return_value = []
+        with patch("services.graph_service.table", side_effect=tables.__getitem__) as table_mock:
             result = delete_course("u1", "course-id-1")

         assert result == {"deleted": True}
-        mock.delete.assert_called_once()
+        table_mock.assert_called_with("user_courses")
+        tables["user_courses"].delete.assert_called_once()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_graph_service.py` around lines 232 - 245, The test
currently patches services.graph_service.table to always return the same
MagicMock so delete_course may be deleting from the wrong table; change the
test_unenroll_with_no_prior_nodes to patch services.graph_service.table with a
side_effect that returns distinct mocks for the 'user_courses' and 'courses'
table names (or otherwise return different mocks based on the table argument),
call delete_course("u1", "empty-course-id"), and assert that the specific
user_courses mock had its delete method called (and optionally asserted with the
expected key), while the courses mock was not used — reference the delete_course
function and services.graph_service.table to locate where to apply the
side_effect and assertions.

…s course_id

- course_context_service: aggregate quiz_context per concept (not one global list)
- graph_service: scope apply_graph_update node lookups by course_id when provided
- documents: set course_id on syllabus-extracted assignments from upload context
- api.test: saveAssignments fixture uses course_id

Made-with: Cursor

@coderabbitai coderabbitai Bot 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: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/routes/documents.py`:
- Around line 144-147: The loop in backend/routes/documents.py mutates every
item but still passes the original assignments list to save_assignments_to_db,
so non-dict items can cause calendar_service.py to crash (it expects dicts and
calls a.get). Filter the assignments list to only include dict items, set each
dict's "course_id" (e.g., inside the for a in assignments: block or via a new
list comprehension), and then call save_assignments_to_db(user_id,
filtered_assignments) so calendar_service.save_assignments_to_db only receives
well-formed dicts.

In `@backend/services/course_context_service.py`:
- Around line 267-279: The current selection always picks five concepts even
when pct_struggling or pct_mastered are 0.0; update the logic around
concept_metrics/sorted_by_struggling/top_struggling_concepts and
sorted_by_mastered/top_mastered_concepts to first filter out entries with
pct_struggling == 0.0 (and pct_mastered == 0.0 respectively) before sorting and
slicing, so only concepts with non-zero rates become “top” and an empty list
remains when none qualify (allowing the “None identified” fallback).
- Around line 76-89: The queries against the partitioned tables
table("course_summary") and table("course_concept_stats") in get_course_context
currently filter only by course_id and then take summary_rows[0], which can
return/merge data from different semesters; update both selects to also filter
by the semester (e.g. add a "semester": f"eq.{semester}" filter) and ensure you
pick the summary row that matches that semester rather than blindly using the
first element (i.e., scope summary_rows and stats_rows by semester and
validate/handle when no row exists for that semester).
- Around line 122-143: The aggregation currently returns early when
enrollment_rows or node_rows is empty and only upserts remaining concepts,
leaving stale course_summary and course_concept_stats records; update the logic
in the function that builds the summary (references: enrollment_rows, user_ids,
node_rows, course_info) to proactively purge stale rows: when enrollment_rows is
empty or node_rows is empty, delete any existing course_summary and all
course_concept_stats for the given course_id; when you compute the set of
current concept identifiers from node_rows before upserting, delete any
course_concept_stats rows for course_id whose concept identifier (concept_name
or id depending on your schema) is not in that current set so only present
concepts remain; ensure deletes run via the same table(...) helper that you use
elsewhere (targeting course_summary and course_concept_stats) before returning
or after upsert so get_course_context() no longer serves stale data.
- Line 108: The function update_course_context currently hard-codes
semester="Spring 2026"; remove this default so the semester must be explicit or
else compute it dynamically. Change the signature of
update_course_context(course_id: str, semester: str) (no hard-coded default) and
update all callers that call update_course_context(course_id) to pass the
correct semester, or alternatively implement a single-source helper (e.g.,
get_current_semester()) inside update_course_context to derive the current
semester when semester is None and use that value for writes; ensure the unique
symbol update_course_context and any callers that invoke it are updated
accordingly so writes go to the correct semester partition.

In `@backend/services/graph_service.py`:
- Around line 228-253: add_course (and likewise delete_course) mutates
user_courses but doesn't refresh the shared course context computed in
course_context_service, so shared summaries become stale; after successfully
inserting or removing a user_courses row in add_course and delete_course, import
and invoke the course_context_service function that recomputes/invalidates
shared context for that course (e.g. call
course_context_service.refresh_shared_context_for_course(course_id) or the
equivalent function used at Lines 118-121 in course_context_service), ensure the
call runs after the DB change and errors are handled/logged so the refresh won't
fail silently.
- Around line 396-403: The synchronous loop calling update_course_context(cid)
inside apply_graph_update is blocking the request; change it to run updates
asynchronously (e.g., dispatch background tasks or schedule jobs) so
apply_graph_update returns immediately. Specifically, replace the direct calls
to services.course_context_service.update_course_context in the touched_courses
loop with non-blocking dispatch (enqueue to your existing task queue, spawn a
background worker/future, or use an async fire-and-forget helper), ensuring
errors are logged but do not block; keep the retry/timeout behavior of
_generate_summary_with_gemini/gemini_service intact by running that logic in the
background worker rather than inline. Ensure the new code references the same
function name update_course_context so the background worker invokes it for each
cid.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ace1f249-10a4-4d58-a915-31e9f863a321

📥 Commits

Reviewing files that changed from the base of the PR and between 60dab9d and 48e0a1e.

📒 Files selected for processing (4)
  • backend/routes/documents.py
  • backend/services/course_context_service.py
  • backend/services/graph_service.py
  • frontend/src/__tests__/api.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/tests/api.test.ts

Comment thread backend/routes/documents.py Outdated
Comment thread backend/services/course_context_service.py
Comment thread backend/services/course_context_service.py Outdated
Comment on lines +122 to +143
if not enrollment_rows:
return # No students enrolled, nothing to aggregate

user_ids = [r["user_id"] for r in enrollment_rows]
student_count = len(user_ids)

# ── 2. Get course info for the summary ────────────────────────────────────
course_rows = table("courses").select(
"course_code,course_name",
filters={"id": f"eq.{course_id}"},
)
course_info = course_rows[0] if course_rows else {"course_code": "", "course_name": ""}

# ── 3. All graph nodes for this course across every enrolled student ──────
# Build user_id filter for PostgREST
user_filter = ",".join(user_ids)
node_rows = table("graph_nodes").select(
"id,concept_name,mastery_score,mastery_tier,user_id",
filters={"subject": f"eq.{course_name}"},
filters={"course_id": f"eq.{course_id}", "user_id": f"in.({user_filter})"},
)
if not node_rows:
return
return # No graph data yet for this course

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Purge stale rows when the cohort or concept set shrinks.

This function returns early when there are no enrolled students or no graph_nodes, and later it only upserts the concepts that still exist. Nothing removes the old course_summary row or course_concept_stats rows that dropped out of the current aggregate, so get_course_context() can keep serving stale shared context after unenrollment or when a concept disappears from the course.

Also applies to: 239-261

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/course_context_service.py` around lines 122 - 143, The
aggregation currently returns early when enrollment_rows or node_rows is empty
and only upserts remaining concepts, leaving stale course_summary and
course_concept_stats records; update the logic in the function that builds the
summary (references: enrollment_rows, user_ids, node_rows, course_info) to
proactively purge stale rows: when enrollment_rows is empty or node_rows is
empty, delete any existing course_summary and all course_concept_stats for the
given course_id; when you compute the set of current concept identifiers from
node_rows before upserting, delete any course_concept_stats rows for course_id
whose concept identifier (concept_name or id depending on your schema) is not in
that current set so only present concepts remain; ensure deletes run via the
same table(...) helper that you use elsewhere (targeting course_summary and
course_concept_stats) before returning or after upsert so get_course_context()
no longer serves stale data.

Comment thread backend/services/course_context_service.py
Comment thread backend/services/graph_service.py
Comment on lines +396 to 403
# Refresh shared course context for every course touched in this update
if touched_courses:
from services.course_context_service import update_course_context
for subj in touched_subjects:
for cid in touched_courses:
try:
update_course_context(subj)
update_course_context(cid)
except Exception:
pass # never block the main response for a context refresh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the graph_service.py file around lines 396-403
find . -type f -name "graph_service.py" | head -5

Repository: SaplingLearn/Sapling

Length of output: 99


🏁 Script executed:

# Get file size to determine if we can read it completely
wc -l backend/services/graph_service.py

Repository: SaplingLearn/Sapling

Length of output: 101


🏁 Script executed:

# Read the specific lines mentioned in the review
sed -n '390,410p' backend/services/graph_service.py

Repository: SaplingLearn/Sapling

Length of output: 821


🏁 Script executed:

# Check gemini_service.py for the timeout configuration
find . -type f -name "gemini_service.py" | head -5

Repository: SaplingLearn/Sapling

Length of output: 100


🏁 Script executed:

# Read lines 13-18 of gemini_service.py
sed -n '1,30p' backend/services/gemini_service.py

Repository: SaplingLearn/Sapling

Length of output: 777


🏁 Script executed:

# Find and examine the update_course_context function
rg "def update_course_context" -A 20

Repository: SaplingLearn/Sapling

Length of output: 1767


🏁 Script executed:

# Get the full update_course_context function to see if it calls Gemini
wc -l backend/services/course_context_service.py

Repository: SaplingLearn/Sapling

Length of output: 110


🏁 Script executed:

# Search for Gemini calls within course_context_service
rg "gemini" backend/services/course_context_service.py -i -C 5

Repository: SaplingLearn/Sapling

Length of output: 2308


🏁 Script executed:

# Search for summary generation which might use Gemini
rg "summary" backend/services/course_context_service.py -i -C 3

Repository: SaplingLearn/Sapling

Length of output: 5015


🏁 Script executed:

# Check what apply_graph_update does and where it returns
rg "def apply_graph_update" -A 50 backend/services/graph_service.py

Repository: SaplingLearn/Sapling

Length of output: 2093


Move course context refresh to async/background task to prevent request blocking.

The inline update_course_context(cid) calls at lines 398-401 execute synchronously before apply_graph_update() returns. When triggered, _generate_summary_with_gemini() in course_context_service.py calls Gemini with retries=1. Given the 180-second timeout per attempt (gemini_service.py, line 13), a failed or slow summary refresh can block the learning request for multiple minutes, especially when touched_courses contains multiple course IDs.

The try/except suppresses errors but does not defer execution—the blocking still occurs. Consider deferring this refresh to a background queue or task scheduler.

🧰 Tools
🪛 Ruff (0.15.10)

[error] 402-403: try-except-pass detected, consider logging the exception

(S110)


[warning] 402-402: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/graph_service.py` around lines 396 - 403, The synchronous
loop calling update_course_context(cid) inside apply_graph_update is blocking
the request; change it to run updates asynchronously (e.g., dispatch background
tasks or schedule jobs) so apply_graph_update returns immediately. Specifically,
replace the direct calls to
services.course_context_service.update_course_context in the touched_courses
loop with non-blocking dispatch (enqueue to your existing task queue, spawn a
background worker/future, or use an async fire-and-forget helper), ensuring
errors are logged but do not block; keep the retry/timeout behavior of
_generate_summary_with_gemini/gemini_service intact by running that logic in the
background worker rather than inline. Ensure the new code references the same
function name update_course_context so the background worker invokes it for each
cid.

Jose-Gael-Cruz-Lopez and others added 3 commits April 14, 2026 20:41
…rors, test alignment

Production fixes:
- quiz.py: read misconceptions/weak_areas from concept_stats list (new payload shape)
- learn.py: log exceptions instead of silently swallowing in course_id lookup helpers
- calendar.py: remove unused course_label variable in suggest_study_blocks
- course_context_service.py: filter zero-rate concepts from top struggling/mastered;
  scope concept_stats query by semester
- documents.py: filter non-dict assignments and attach course_id before saving
- main.py: make recost import optional so tests can import app without the package

Test alignment (all 177 tests now pass):
- test_shared_course_context.py: update TestGetCourseContext, TestUpdateCourseContext,
  TestApplyGraphUpdateTriggersContext, TestLearnHelpers, TestQuizPromptAugmentation
  to match new schema (user_courses join, course_concept_stats/course_summary tables,
  course_id instead of subject string, renamed helper functions)
- test_graph_service.py: update TestGetGraph, TestGetCourses, TestAddCourse to use
  user_courses enrollment model instead of legacy courses table
- test_learn_routes.py: remove _resolve_course import, replace TestResolveCourse
  with TestGetCourseIdForTopic matching renamed function
- test_calendar_routes.py: add full response contract fields to upcoming test fixture
- test_quiz_routes.py: add update_streak mock to prevent real DB calls in submit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ale purge, schema sync

- Remove unused course_id_map from get_graph
- Derive semester from courses table instead of hardcoding Spring 2026
- Purge stale course_concept_stats/course_summary rows when enrollment hits zero
- Trigger update_course_context after add_course and delete_course
- Rewrite supabase_schema.sql to match new canonical schema (user_courses, course_id FKs, course_concept_stats, course_summary; drop old course_context blob table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
try:
from services.course_context_service import update_course_context
update_course_context(course_id)
except Exception:
try:
from services.course_context_service import update_course_context
update_course_context(course_id)
except Exception:
@Jose-Gael-Cruz-Lopez Jose-Gael-Cruz-Lopez merged commit eaae213 into main Apr 15, 2026
8 checks passed
@AndresL230 AndresL230 deleted the combined/course-id-migration-and-assignment-normalization branch April 15, 2026 01:25
Sign up for free to 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.

1 participant