Skip to content

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(academics): ingest real per-section Fall 2026 BU offerings (#280) by Darkest-Teddy · Pull Request #512 · SaplingLearn/Sapling · GitHub
Skip to content

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(academics): ingest real per-section Fall 2026 BU offerings (#280) by Darkest-Teddy · Pull Request #512 · SaplingLearn/Sapling · GitHub
Skip to content

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

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

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

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

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(academics): ingest real per-section Fall 2026 BU offerings (#280) by Darkest-Teddy · Pull Request #512 · SaplingLearn/Sapling · GitHub
Skip to content

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(academics): ingest real per-section Fall 2026 BU offerings (#280) by Darkest-Teddy · Pull Request #512 · SaplingLearn/Sapling · GitHub
Skip to content

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

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

feat(academics): ingest real per-section Fall 2026 BU offerings (#280) - #512

Open
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2
Open

feat(academics): ingest real per-section Fall 2026 BU offerings (#280)#512
Darkest-Teddy wants to merge 7 commits into
mainfrom
feat/280-per-section-offerings-v2

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Refs #280 (tasks 1–3). Does not close#280 — task 4 (API + frontend read path, letting a student pick the section they actually registered for) is deliberately deferred, so the issue must stay open after this merges.

Why

course_offerings has been hollow since the 0020 academics split. Staging held 12,318 rows, of which 4 (the demo seed) carried a section and none carried a meeting time or location. Courses could not behave like a registrar.

This ingests the operational layer for fall-2026 from bu.edu: one offering per published section, carrying section / instructor_name / meeting_times / location.

What changed

Scraper (task 1)_extract_schedule flattened each course's schedule tables down to a semester list, discarding exactly the fields #280 needs. Replaced with _extract_sections + _derive_schedule:

  • Columns are looked up by table header name, not position, so a column BU inserts doesn't silently shift the data.
  • Staff/TBA instructors and NO ROOM locations become NULL rather than being stored literally and shown to students.
  • New --rescan mode refetches only the URLs already in the JSON. Sections were never stored, so recovering them required a re-fetch; skipping the listing walk preserves the index-orphan courses that only probe_unscraped_courses.py finds.

Importer (task 2)backend/db/import_offerings.py, dry-run by default.

It adopts rather than re-creates. Staging already had 4,122 hollow fall-2026 offerings with enrollments, sessions, documents and notes pointing at them, and course_offerings is referenced by 8 tables with mixed CASCADE / SET NULL semantics — a delete-and-reinsert import would have destroyed or silently orphaned real rows. So for each course the first published section takes over the existing section-less row in place, id preserved, and only the remaining sections are inserted. Re-running matches every section by code and writes nothing.

Offerings the scrape no longer lists are reported, never deleted. A partial or failed scrape must not be able to wipe a term.

BU publishes multiple meeting patterns per section — CAS MA 123 A1 is an MWF 9:05 lecture and a Thursday 6:30 exam block. A keep-first dedup would have dropped a real meeting from 308 sections, so _merge_meetings joins them ("R 6:30 pm-8:30 pm; MWF 9:05 am-9:55 am"). Verified across the whole scrape that no multi-meeting section has two different instructors.

School linkage (task 3)--link-school creates the Boston University schools row and links courses to it, after a duplicate-course_code precheck that refuses outright rather than half-linking. All 8,510 courses turned out to be unique, so the merge work #280 anticipated wasn't needed.

Determinism fix — with one offering per section, resolve_offering's order="created_at.asc", limit=1 was non-deterministic: sections land in a single batch insert and share a created_at, so the winner was the planner's choice. Two calls could split one user's documents and notes across sections. Now ordered by section.asc first; '' sorts before A1, preserving pre-#280 behaviour wherever a hollow offering remains.

No migration in this PR

The section NOT NULL DEFAULT '' change this depends on is already on main as 0033_offering_section_not_null.sql (recovered by #510 — it reached staging out-of-band during this work), and 0036's now-unreachable NULL-section partial index was dropped by 20260801062439_drop_dead_null_section_index.sql. Reasoning for choosing the non-null default over NULLS NOT DISTINCT is recorded in the ADR.

Separable

backend/.env.example and backend/.env.staging.example still handed out the direct db.<ref>.supabase.co host, which is IPv6-only and is the reason applying migrations failed locally. migrate.py and CLAUDE.md were already fixed upstream; these two templates were missed. Happy to split this into its own PR if preferred.

Out of scope

Task 4 (API + frontend read path, letting a student pick the section they actually registered for) is not here. Today a student enrolling in CAS CS 330 lands in A1 regardless — this PR makes that choice stable, not correct.

Verification

Staging, after applying:

  • 11,079 fall-2026 offerings — 10,958 sectioned, 121 left hollow for courses BU dropped from the schedule
  • coverage: meeting_times 100%, instructor_name 92%, location 59%
  • 0 duplicate (course, term, section); 0 orphaned enrollments / sessions / documents / notes
  • BU school linked to 8,507 courses, demo's 3 untouched
  • re-run reports "Nothing to do — already in sync"
  • spot-checked against bu.edu: CAS CS 330 = 7 sections; CAS MA 123 A1 in STO B50

Tests: 53 targeted tests pass (test_scrape_bu_catalog.py 12, test_import_offerings.py 20 against a hermetic PostgREST fake, plus academics). Full backend suite: 1,585 passed, 41 skipped. Ruff clean on every touched file.

Unaffected by design

  • Knowledge graph keys on the abstract courses.id, and the import resolves each course_code to the existing catalog row rather than creating a parallel course — sections cannot fork a course's graph identity.
  • RAG keys course_chunks on course_code, not offering_id, so the shared-course-corpus property survives sections.
  • Gradebook keys on enrollment_id and study analytics on offering_id; both keep working because adoption preserves offering ids.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Course catalogs now include detailed section schedules, instructors, meeting times, locations, and notes.
    • Added catalog rescan and refresh options to update course information while preserving failed records and backups.
    • Added tools to identify and investigate courses missing from catalog data.
  • Bug Fixes

    • Improved offering selection for consistent results, prioritizing section information.
  • Documentation

    • Added guidance for importing, validating, and verifying catalog data.

`course_offerings` has been hollow since the 0020 academics split: staging held
12,318 rows, of which 4 (the demo seed) carried a section and none carried a
meeting time or location. Courses could not behave like a registrar. This
ingests the operational layer for fall-2026 from bu.edu.
Scraper (tasks 1). `_extract_schedule` flattened each course's schedule tables
down to a semester list, discarding exactly the fields #280 needs. Replaced with
`_extract_sections` + `_derive_schedule`: sections are read by table header
name, not column position, so a column BU inserts doesn't silently shift the
data. Staff/TBA instructors and "NO ROOM" locations become NULL instead of being
stored literally. Added a `--rescan` mode that refetches only the URLs already
in the JSON — sections were never stored, so recovering them needed a re-fetch,
and skipping the listing walk preserves the index-orphan courses that only
`probe_unscraped_courses.py` finds.
Importer (task 2). `db/import_offerings.py` writes one offering per published
section. It *adopts* rather than re-creates: staging already had 4,122 hollow
fall-2026 offerings with enrollments, sessions, documents and notes pointing at
them, and `course_offerings` is referenced by 8 tables with mixed CASCADE/SET
NULL semantics, so delete-and-reinsert would have destroyed or orphaned real
rows. The first published section takes over the existing section-less row in
place, id preserved. Offerings the scrape no longer lists are reported, never
deleted — a partial scrape must not be able to wipe a term.
BU publishes multiple meeting patterns per section (CAS MA 123 A1 is an MWF
lecture *and* a Thursday exam block). A keep-first dedup would have dropped a
real meeting from 308 sections, so `_merge_meetings` joins them.
School linkage (task 3). `--link-school` creates the Boston University `schools`
row and links courses to it, after a duplicate-course_code precheck that refuses
outright rather than half-linking. All 8,510 courses were already unique, so no
merge was needed.
Determinism fix. With one offering per section, `resolve_offering`'s
`order="created_at.asc", limit=1` was non-deterministic — sections land in a
single batch insert and share a created_at, so the winner was the planner's
choice, and two calls could split one user's documents and notes across
sections. Now ordered by `section.asc` first; '' sorts before 'A1', preserving
pre-#280 behaviour wherever a hollow offering remains.
No migration here. The `section NOT NULL DEFAULT ''` change this depends on is
already in the chain as 0033 (recovered by #510), and 0036's now-unreachable
NULL-section index was dropped by 20260801062439.
Also corrects `.env.example` / `.env.staging.example`, which still handed out
the direct `db.<ref>.supabase.co` host — IPv6-only, and the reason applying
migrations failed here. `migrate.py` and CLAUDE.md were already fixed; these two
templates were missed.
Task 4 of #280 (API + frontend read path, letting a student pick their section)
is deliberately out of scope.
Verified on staging: 11,079 fall-2026 offerings, 10,958 sectioned and 121 left
hollow for courses BU dropped from the schedule; meeting_times 100%, instructor
92%, location 59%; 0 duplicate (course, term, section); 0 orphaned
enrollments/sessions/documents/notes; a re-run reports "Nothing to do".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@supabase

supabaseBot commented Aug 2, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jose-Gael-Cruz-Lopez, you've reached your PR review limit, so we couldn't start this review.

Next review available in:8 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 876e120f-6485-4488-94da-5bcc23767bb3

📥 Commits

Reviewing files that changed from the base of the PR and between 65f6c0d and a623d29.

📒 Files selected for processing (13)
  • CLAUDE.md
  • backend/.env.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/conftest.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • backend/tests/test_seed_staging.py
  • docs/decisions/0026-per-section-offering-ingest.md
📝 Walkthrough

Walkthrough

The PR adds section-aware BU catalog scraping, rescan and verification tools, a dry-run offering importer, missing-course and school-linking workflows, deterministic offering selection, connection guidance, tests, and decision documentation.

Changes

BU offering ingestion

Layer / File(s)Summary
Section catalog scraping and rescanning
backend/scripts/scrape_bu_catalog.py, backend/tests/test_scrape_bu_catalog.py
The scraper extracts section schedules and course rollups, handles fetch failures, supports configurable crawling, and adds refresh and rescan modes.
Catalog probing and verification
backend/scripts/probe_unscraped_courses.py, backend/scripts/verify_catalog_scrape.py
New tools probe missing course URLs and validate catalog structure, section integrity, and sampled live results.
Per-section offering synchronization
backend/db/import_offerings.py, backend/tests/test_import_offerings.py, CLAUDE.md, .gitignore
The importer resolves catalog data, creates or updates per-section offerings, adopts hollow rows, preserves stale rows, supports dry runs, and optionally creates courses and links Boston University.
Deterministic offering selection and operational configuration
backend/services/academics.py, backend/tests/test_academics.py, backend/.env.example, backend/.env.staging.example, docs/decisions/0026-per-section-offering-ingest.md
Offering lookup now prioritizes section ordering. Environment examples use the Supabase session pooler. The ADR records the section-ingest behavior and migration assumptions.

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

Possibly related PRs

Suggested reviewers:andresl230

Sequence Diagram(s)

sequenceDiagram
participant BUCatalog
participant scrape_bu_catalog
participant import_offerings
participant PostgREST
BUCatalog->>scrape_bu_catalog: course pages and schedule tables
scrape_bu_catalog-->>import_offerings: section-level catalog JSON
import_offerings->>PostgREST: resolve term and course rows
import_offerings->>PostgREST: insert or update course_offerings
PostgREST-->>import_offerings: synchronization results
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningThe PR addresses scraping, importing, section handling, school linkage, and data integrity, but it does not implement the issue's API and frontend requirements.Add API and frontend support for exposing and displaying section details, or move those requirements to a separate linked issue before closing #280.
Docstring Coverage⚠️ WarningDocstring coverage is 26.92% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Out of Scope Changes check✅ PassedThe supporting scripts, documentation, environment templates, tests, and deterministic ordering change all support the Fall 2026 offering ingestion objectives.
Title check✅ PassedThe title clearly and concisely identifies the main change: ingesting real per-section Fall 2026 BU offerings.
Description check✅ PassedThe description thoroughly explains the purpose, changes, testing, verification results, deferred scope, and reviewer notes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/280-per-section-offerings-v2

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 Aug 2, 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-staginga623d29Commit Preview URL

Branch Preview URL
Aug 19 2026, 09:11 PM

Comment threadbackend/db/import_offerings.py Fixed

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

🧹 Nitpick comments (10)
backend/scripts/probe_unscraped_courses.py (1)

60-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The probe defaults contradict the scraper's stated crawl policy.

scrape_bu_catalog.py Lines 53-56 state that bu.edu/robots.txt asks for Crawl-delay: 15, and it defaults to one worker with a 15-second delay. This script targets the same host and reuses the same env var names, but defaults to 4 workers and 1.5 seconds. An operator who sets BU_CONCURRENCY and BU_PAGE_DELAY for the scraper gets consistent behavior; an operator who sets neither gets a 40x faster crawl here.

Either align the defaults with the scraper, or document why the probe's much smaller URL set justifies the difference.

🤖 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/scripts/probe_unscraped_courses.py` around lines 60 - 61, Align the
probe defaults in CONCURRENCY and DELAY with the crawl policy used by
scrape_bu_catalog.py: default to one worker and a 15-second delay while
preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY environment-variable
overrides.
backend/tests/test_scrape_bu_catalog.py (1)

146-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the listing-truncation branch.

The tests cover the parser well. They do not cover the FETCH_FAILED change, which is the fix for the truncated crawl described in scrape_bu_catalog.py Lines 74-78. That branch is the highest-cost regression in this PR, and a silent return to the old behavior would again cut a school short without an error.

scrape_school accepts a client, so the branch is testable without network access by passing a stub client that raises httpx.TimeoutException. Assert that _errors gains a TRUNCATED entry and that the walk stops.

I can draft the test if you want.

🤖 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_scrape_bu_catalog.py` around lines 146 - 150, Add a test
for the FETCH_FAILED handling in scrape_school using a stub client that raises
httpx.TimeoutException. Assert the result records a TRUNCATED entry in _errors
and that traversal stops without processing subsequent listings, covering the
truncated-crawl regression without network access.
backend/scripts/verify_catalog_scrape.py (2)

53-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the codes.count(c) branch.

Line 53 uses codes.count(c) inside a comprehension, which is O(n²). Lines 55-57 already implement the same result in O(n). The len(codes) < 3000 guard bounds the cost at about 9 million comparisons, so this is not a real slowdown, but the branch adds no value.

♻️ Proposed simplification
 codes = [c.get("course_code", "") for c in catalog]
- dupes = {c for c in codes if codes.count(c) > 1} if len(codes) < 3000 else None- if dupes is None:- seen, dupes = set(), set()- for c in codes:- (dupes if c in seen else seen).add(c)+ seen: set[str] = set()+ dupes: set[str] = set()+ for c in codes:+ (dupes if c in seen else seen).add(c)
🤖 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/scripts/verify_catalog_scrape.py` around lines 53 - 57, Remove the
conditional codes.count(c) comprehension and initialize dupes and seen through
the existing linear-time iteration in the verification flow. Preserve the
current duplicate-detection result and behavior for all code-list sizes, using
the loop around seen and dupes as the sole implementation.

189-196: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The two early continue paths skip SPOT_DELAY.

time.sleep(SPOT_DELAY) runs at Line 227, after the comparison. Line 193 and Line 196 return to the top of the loop before that point. If bu.edu rate-limits or returns errors, the loop issues requests with no delay, which makes the rate limiting worse.

Move the delay into a finally block, or call it before each continue.

🤖 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/scripts/verify_catalog_scrape.py` around lines 189 - 196, Ensure
every iteration of the scrape loop applies SPOT_DELAY, including the exception
and non-200 response paths around client.get and status_code handling. Move the
existing delay from its current post-comparison location into a finally block
covering the request and response processing, or invoke it before each early
continue, while preserving the existing success-path behavior.
backend/scripts/scrape_bu_catalog.py (2)

74-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the fetch return annotation for the new sentinel.

fetch is annotated -> Optional[str] on Line 82, but Line 100 now returns FETCH_FAILED, an object(). Runtime behavior is correct because callers use is FETCH_FAILED. The annotation no longer describes the contract, and a type checker cannot flag a caller that forgets the sentinel branch.

♻️ Proposed typed sentinel
-FETCH_FAILED = object()+class _FetchFailed:+ """Sentinel type so `fetch` can be annotated precisely."""++FETCH_FAILED = _FetchFailed()

Then update the signature:

asyncdeffetch(client: httpx.AsyncClient, url: str, retries: int=2) ->Union[str, None, _FetchFailed]:
🤖 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/scripts/scrape_bu_catalog.py` around lines 74 - 79, Update the fetch
function’s return annotation to include the FETCH_FAILED sentinel type, defining
or reusing a typed _FetchFailed symbol for the sentinel as needed. Preserve the
existing str/None contract and FETCH_FAILED identity checks so callers can
type-check all return branches.

222-226: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Build the column map from one header row, not from every <th> in the table.

Line 223 collects every <th> in the table. Line 226 then maps each header name to its index in that flat list. The mapping is only valid if the table contains exactly one header row.

If a table repeats its header row, or nests a table, headers grows past the width of a data row. col then keeps the last index for each name, and cell() returns "" for every field because of the i < len(_cells) guard. The section code becomes "", and Line 243 drops every row of that table. The loss is silent, which is the failure mode this parser is meant to prevent.

Scope the header lookup to the first row that contains <th>.

♻️ Proposed fix
 for tbl in soup.find_all("table"):
- headers = [th.get_text(" ", strip=True).lower() for th in tbl.find_all("th")]+ header_row = tbl.find("tr", recursive=True)+ while header_row is not None and not header_row.find_all("th", recursive=False):+ header_row = header_row.find_next_sibling("tr")+ if header_row is None:+ continue+ headers = [th.get_text(" ", strip=True).lower() for th in header_row.find_all("th")]
if "section" not in headers:
continue
col = {name: i for i, name in enumerate(headers)}

Add a regression test in backend/tests/test_scrape_bu_catalog.py for a table that repeats its header row.

🤖 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/scripts/scrape_bu_catalog.py` around lines 222 - 226, Update the
table parsing around the headers/col construction to select only the first table
row containing <th> elements, then build the header list and column map from
that row so repeated or nested headers cannot shift data indices. Add a
regression test in test_scrape_bu_catalog.py using a table with a repeated
header row and verify its data rows are still parsed.
docs/decisions/0026-per-section-offering-ingest.md (1)

19-22: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix two markdownlint findings.

Static analysis flags two formatting issues in this file:

  • Line 19: the fenced code block has no language tag (MD040).
  • Line 47: the line starts with #280, which markdownlint reads as a heading missing a space after # (MD018).
📝 Proposed formatting fixes
-```+```text
Section | Instructor | Location | Schedule | Notes
A1 | Erdos | LSE B01 | TR 2:00 pm-3:15 pm | ...

```diff
**Status: already applied.** This shipped as
`0033_offering_section_not_null.sql`. It reached staging out-of-band during the
-#280 work and was recovered into the repo by `#510` (one of the three migrations
+\`#280` work and was recovered into the repo by `#510` (one of the three migrations

Also applies to: 45-48

🤖 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 `@docs/decisions/0026-per-section-offering-ingest.md` around lines 19 - 22,
Update the fenced code block containing the section offering table to include
the text language tag. Adjust the line beginning with “#280” in the same
document so markdownlint no longer interprets it as a malformed heading, while
preserving the displayed content.

Source: Linters/SAST tools

backend/db/import_offerings.py (2)

146-149: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider guarding the "one instructor per section" assumption.

_merge_meetings silently takes the first non-null instructor_name across a section's rows, backed by the comment that this was "verified across the whole scrape that no multi-meeting section lists two different instructors." If a future scrape (or --rescan) violates that assumption, a second instructor is silently dropped with no diagnostic. Consider logging a warning when more than one distinct non-null instructor name is seen for a section, so a scraper regression surfaces instead of silently losing data.

🤖 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/db/import_offerings.py` around lines 146 - 149, Update
_merge_meetings to collect distinct non-null instructor_name values from all
rows and emit a warning when more than one unique instructor is present, while
retaining the existing first-instructor selection behavior for the merged
result.

89-100: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fetch course_offerings scoped to the target term.

The existing-offerings loader pages through every course_offerings row, then filters by term_id in Python. select_with_count accepts filters, so pass {"term_id": f"eq.{term_id}"} from _all_rows and remove the client-side discard to avoid transferring unrelated term rows.

🤖 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/db/import_offerings.py` around lines 89 - 100, Update _all_rows to
accept a term_id parameter and pass {"term_id": f"eq.{term_id}"} via
select_with_count’s filters argument when loading course_offerings; remove the
subsequent client-side term_id filtering so only target-term rows are fetched
and retained.
backend/tests/test_import_offerings.py (1)

23-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the fake Supabase table fixture into backend/tests/conftest.py.

backend/tests/conftest.py only installs global hermetic guard fixtures; it does not provide a reusable fake table/store. This module and backend/tests/test_seed_staging.py both define independent Supabase fakes with different PostgREST semantics. Centralize the shared fake fixture there and have both callers reuse/extend it so the mock DB contract cannot drift.

🤖 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_import_offerings.py` around lines 23 - 101, Move the
shared _Store, _FakeTable, and store fixture definitions from
test_import_offerings.py into backend/tests/conft.py, preserving their current
behavior and patching of imp.table. Update test_import_offerings.py and
test_seed_staging.py to reuse the centralized store fixture, extending or
adapting it only where their Supabase/PostgREST behavior differs, and remove the
duplicate fake implementations.

Source: Coding guidelines

🤖 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 `@backend/scripts/probe_unscraped_courses.py`:
- Around line 99-121: Track candidate URL transport failures separately from
genuinely retired courses in the probing flow around candidate_urls and the
httpx.HTTPError handler. Add and populate an unreachable bucket when all
attempts fail due to network errors, return it alongside the existing buckets,
and print it with the other result summaries; reserve retired for candidates
that complete probing without finding a valid course.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 391-411: Update _refetch to reject redirected responses before
returning fresh: compare fresh["source_url"] with the original url, and when
they differ, append an appropriate rescan error and return None. Preserve the
existing empty-response and parse-failure handling so the in-place
rec.clear()/rec.update(fresh) path only processes records for the original URL.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 212-217: Update the mismatch reporting in the section comparison
branch to sort section values safely when missing keys produce None. Preserve
the existing set comparison and diff output, but use a consistent comparable
ordering for values from stored and fresh sections so missing section codes are
reported instead of raising TypeError.
---
Nitpick comments:
In `@backend/db/import_offerings.py`:
- Around line 146-149: Update _merge_meetings to collect distinct non-null
instructor_name values from all rows and emit a warning when more than one
unique instructor is present, while retaining the existing first-instructor
selection behavior for the merged result.
- Around line 89-100: Update _all_rows to accept a term_id parameter and pass
{"term_id": f"eq.{term_id}"} via select_with_count’s filters argument when
loading course_offerings; remove the subsequent client-side term_id filtering so
only target-term rows are fetched and retained.
In `@backend/scripts/probe_unscraped_courses.py`:
- Around line 60-61: Align the probe defaults in CONCURRENCY and DELAY with the
crawl policy used by scrape_bu_catalog.py: default to one worker and a 15-second
delay while preserving the existing BU_CONCURRENCY and BU_PAGE_DELAY
environment-variable overrides.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 74-79: Update the fetch function’s return annotation to include
the FETCH_FAILED sentinel type, defining or reusing a typed _FetchFailed symbol
for the sentinel as needed. Preserve the existing str/None contract and
FETCH_FAILED identity checks so callers can type-check all return branches.
- Around line 222-226: Update the table parsing around the headers/col
construction to select only the first table row containing <th> elements, then
build the header list and column map from that row so repeated or nested headers
cannot shift data indices. Add a regression test in test_scrape_bu_catalog.py
using a table with a repeated header row and verify its data rows are still
parsed.
In `@backend/scripts/verify_catalog_scrape.py`:
- Around line 53-57: Remove the conditional codes.count(c) comprehension and
initialize dupes and seen through the existing linear-time iteration in the
verification flow. Preserve the current duplicate-detection result and behavior
for all code-list sizes, using the loop around seen and dupes as the sole
implementation.
- Around line 189-196: Ensure every iteration of the scrape loop applies
SPOT_DELAY, including the exception and non-200 response paths around client.get
and status_code handling. Move the existing delay from its current
post-comparison location into a finally block covering the request and response
processing, or invoke it before each early continue, while preserving the
existing success-path behavior.
In `@backend/tests/test_import_offerings.py`:
- Around line 23-101: Move the shared _Store, _FakeTable, and store fixture
definitions from test_import_offerings.py into backend/tests/conft.py,
preserving their current behavior and patching of imp.table. Update
test_import_offerings.py and test_seed_staging.py to reuse the centralized store
fixture, extending or adapting it only where their Supabase/PostgREST behavior
differs, and remove the duplicate fake implementations.
In `@backend/tests/test_scrape_bu_catalog.py`:
- Around line 146-150: Add a test for the FETCH_FAILED handling in scrape_school
using a stub client that raises httpx.TimeoutException. Assert the result
records a TRUNCATED entry in _errors and that traversal stops without processing
subsequent listings, covering the truncated-crawl regression without network
access.
In `@docs/decisions/0026-per-section-offering-ingest.md`:
- Around line 19-22: Update the fenced code block containing the section
offering table to include the text language tag. Adjust the line beginning with
“#280” in the same document so markdownlint no longer interprets it as a
malformed heading, while preserving the displayed content.
🪄 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: bbfa0fe8-0136-47e2-bfe0-10a690649915

📥 Commits

Reviewing files that changed from the base of the PR and between 7534071 and 65f6c0d.

📒 Files selected for processing (13)
  • .gitignore
  • CLAUDE.md
  • backend/.env.example
  • backend/.env.staging.example
  • backend/db/import_offerings.py
  • backend/scripts/probe_unscraped_courses.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/scripts/verify_catalog_scrape.py
  • backend/services/academics.py
  • backend/tests/test_academics.py
  • backend/tests/test_import_offerings.py
  • backend/tests/test_scrape_bu_catalog.py
  • docs/decisions/0026-per-section-offering-ingest.md

Comment threadbackend/scripts/probe_unscraped_courses.py Outdated
Comment threadbackend/scripts/scrape_bu_catalog.py
Comment threadbackend/scripts/verify_catalog_scrape.py Outdated
Correctness:
- probe_unscraped_courses: a code whose every candidate URL died in transport
was filed as "retired (404)". Nothing was learned about those courses, and the
retired list exists to drive a cleanup decision — so a flaky network could
have argued for deleting live courses. Transport failures now land in a
separate `unreachable` bucket; `retired` means "answered, and no valid page".
- scrape_bu_catalog: `_extract_sections` read headers from every <th> in the
table. On a page that repeats its header row mid-table the dict comprehension
kept the LAST index per name, shifting instructor into location for every row
below the repeat. Headers now come from the first <th> row only. Regression
test added.
- scrape_bu_catalog: `fetch` follows redirects, so a retired slug can serve an
unrelated course's page, and `rescan` would then overwrite one course's record
with another's. `_refetch` now rejects a response whose course_code isn't the
one being refreshed. (Reviewer suggested comparing source_url, but
parse_course stamps that from the requested URL, so it can never differ — the
code on the page is the only tell.)
- verify_catalog_scrape: the section-set mismatch branch sorted values that can
be None — exactly the missing-section-code case the check exists to report —
raising TypeError instead. Now sorted through a None-tolerant key.
Politeness / cost:
- verify_catalog_scrape: SPOT_DELAY was skipped on all three error paths, so a
struggling bu.edu got hammered hardest. Moved into a finally.
- probe_unscraped_courses: defaults were 4 workers / 1.5s against the same host
and same robots.txt the crawler honors at 1 worker / 15s (Crawl-delay: 15).
Aligned; BU_CONCURRENCY / BU_PAGE_DELAY still override.
- import_offerings: `_all_rows("course_offerings", ...)` pulled every term's
rows and filtered client-side. Filtered server-side instead.
Clarity:
- import_offerings: `_merge_meetings` silently kept the first instructor. That
no multi-meeting section disagrees was a point-in-time check, not a BU
guarantee, so it now warns when it does.
- scrape_bu_catalog: FETCH_FAILED was a bare object() while `fetch` was
annotated `Optional[str]`. Gave the sentinel a type and a FetchResult alias.
- verify_catalog_scrape: dropped the O(n^2) `codes.count(c)` branch; the linear
path it fell back to above 3,000 records handles every size.
- ADR 0026: fenced block tagged `text`; rewrapped a line starting with "#280"
that markdownlint read as a malformed heading.
Test added for the truncated-crawl regression the FETCH_FAILED sentinel exists
to prevent: a listing timeout must record TRUNCATED, not read as the end of
pagination (the bug that cut the previous crawl to 416 of 2,253 CAS courses).
Not done: moving the _Store/_FakeTable fakes into conftest.py and sharing them
with test_seed_staging.py. That pulls an unrelated test file into this PR, and
the two fakes model different PostgREST surfaces; keeping them local keeps each
suite's failure modes independent.
Verified: 1,592 passed, 38 skipped. Ruff clean. Verifier re-run offline over the
real 8,857-record scrape reports identical numbers to before the dedup rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadbackend/tests/test_scrape_bu_catalog.py Fixed
@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Darkest-Teddy

Copy link
Copy Markdown
CollaboratorAuthor

@coderabbitai full review

@coderabbitai

coderabbitaiBot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

- import_offerings: `sys.exit(main())` called sys.exit on a procedure that
returns None, so it was always sys.exit(None). Failures already raise
SystemExit from run()/_resolve_term and carry their own status, so the
wrapper only obscured that. Call main() directly; `sys` is no longer needed.
- test_scrape_bu_catalog: the module was pulled in with both `import
scrape_bu_catalog as scrape` and `from scrape_bu_catalog import ...`. Dropped
the module import; the truncated-crawl test patches through monkeypatch's
string targets and asserts on a list it owns.
Behaviour unchanged. 1,592 passed, 38 skipped; ruff clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Code review — per-section Fall 2026 BU offerings

This lands the operational layer for course_offerings: a header-driven section parser in scrape_bu_catalog.py, a --rescan mode to re-derive sections from already-crawled URLs, a dry-run-by-default importer (db/import_offerings.py) that adopts hollow section-less rows in place instead of re-creating them, a structural + live verifier, an index-orphan prober, and a determinism fix in resolve_offering. The shape is right and the risky decisions are the correct ones — adoption over delete-and-reinsert, report-never-delete for stale offerings, and no migration of its own (it rides 0033_offering_section_not_null.sql + 20260801062439_drop_dead_null_section_index.sql, both confirmed already on main). I verified the two things that would hurt most and both hold:

  • Idempotency: correct. The conflict key the importer keys on is (course_id, term_id, section) — the same tuple as course_offerings_unique from 0020_academics_split.sql — resolved client-side via by_section = {(r.get("section") or ""): r for r in rows} (import_offerings.py:352). A second run matches every section by code, patch comes out empty, and nothing is written. None and '' both normalise to '', so pre- and post-0033 rows behave identically, and hollow can only ever hold one row per (course, term) once 0033 is applied. A crash mid-write is retryable: adoption is matched by section code, not by position.
  • Migration back-compat: not applicable, and correctly so. No new migration; no NOT NULL add, no unique index over live data, no destructive drop. The dependencies are already on main and both carry guards.

Findings below are things I could not talk myself out of. Nothing rises to a data-loss or security issue.

Findings

[P1] --link-school is silently dropped when offerings are already in syncbackend/db/import_offerings.py:408-410

ifnotto_insertandnotto_update:
print("\nNothing to do — already in sync.")
return

This return fires before link_school() is ever reached (import_offerings.py:428-429 for apply, 413-414 for dry run). The documented runbook command in this PR — python -m db.import_offerings --apply --create-missing --link-school (CLAUDE.md:54) — therefore does nothing for #280 task 3 on any re-run, and prints "Nothing to do" without mentioning the flag it skipped. This is exactly the retry path that matters: link_school deliberately bails out with [FAIL] on duplicate course_codes (import_offerings.py:260-268), so the intended recovery is "merge the dupes, re-run" — and after the offering sync has landed, that re-run silently links nothing. There is no --link-school-only entry point either. The early return should be gated on not do_link_school, or the linking step hoisted above it.

[P2] Registrar placeholder schedules are stored verbatim in meeting_timesbackend/db/import_offerings.py:130-132,157-163

_NO_LOCATION= {"no room", "tba", "tbd", "arr", ""}
...
t= (s.get("meeting_times") or"").strip()
iftandtnotintimes:
times.append(t)
loc= (s.get("location") or"").strip()
iflocandloc.lower() notin_NO_LOCATIONandlocnotinlocations:
locations.append(loc)

instructor_name is normalised at scrape time (_NO_INSTRUCTOR, scrape_bu_catalog.py:202-204) and location at import time (_NO_LOCATION), but meeting_times gets no placeholder handling on either side — scrape_bu_catalog.py:264 is a bare "meeting_times": cell("schedule") or None. This PR's own fixtures show the consequence: test_scrape_bu_catalog.py:116-127 models a real arranged section as <td>ARR 12:00 am-12:00 am</td> in the Schedule column, and test_import_offerings.py::test_placeholder_locations_become_null feeds ("A1", "Erdos", "ARR", "NO ROOM") and asserts only location is None — the resulting row keeps meeting_times == "ARR". So an ARR section lands with location = NULL (honest) and meeting_times = "ARR 12:00 am-12:00 am" (a placeholder shown to students), which is precisely the Staff/NO ROOM failure mode the rest of the PR is careful to avoid. It also inflates the PR's headline metric: verify_catalog_scrape.py:163-165 counts any truthy meeting_times as coverage and its [FAIL] placeholder check at :168-174 covers instructors only, so "meeting_times 100%" cannot distinguish a real schedule from ARR. Applying the same placeholder set (or an ARR-aware one) to meeting_times in _merge_meetings, plus a matching [FAIL] in _sections, closes both halves.

[P2] Updates and adoptions are one HTTP PATCH per row while inserts are batchedbackend/db/import_offerings.py:417-424

foriinrange(0, len(to_insert), BATCH):
table("course_offerings").insert(to_insert[i : i+BATCH])
print(f" inserted {min(i+BATCH, len(to_insert)):,}/{len(to_insert):,}", flush=True)
forn, (offering_id, patch) inenumerate(to_update, 1):
table("course_offerings").update(patch, filters={"id": f"eq.{offering_id}"})

BATCH = 500 is honoured for to_insert and for link_school's in.(...) windows, but the update path is a per-row round trip. The PR reports 4,122 hollow fall-2026 offerings on staging, every one of which goes through this loop as an adoption, plus one PATCH per section whose instructor/room/time the registrar changed on any later re-import — so a routine refresh after BU republishes is O(sections) sequential requests, each its own transaction and each firing trg_course_offerings_updated_at. SupabaseTable.upsert(..., on_conflict="id") already exists in db/connection.py and merges duplicates, which would let these be written in the same 500-row batches as the inserts.

[P2] resolve_offering's cross-term fallback is still unordered, and this PR is what makes that bitebackend/services/academics.py:169-174

# No offering in the target term and not creating — fall back to any offering# of this course so reads still resolve to something sensible.any_off=table("course_offerings").select(
"id", filters={"course_id": f"eq.{course_id}"}, limit=1
)
returnany_off[0]["id"] ifany_offelseNone

ADR 0026 Decision 3 and the fix at :133-138 are right, but they only cover the term-filtered query. This limit=1 with no order picks an arbitrary row, and the PR is what turns "arbitrary among 1" into "arbitrary among 7" for every scraped course. It is not reachable today for those courses because current_term() resolves to fall-2026, but it becomes live the moment a later term row is seeded (as 0032_retire_summer_2026.sql shows terms do get maintained): every resolve_offering(course_id) fallback caller — routes/study_guide.py:52, routes/notes.py:156, routes/flashcards.py:144,408 — can then hand the same user a different section on consecutive calls, which is the exact documents/notes split the ADR argues against. Same order="section.asc,created_at.asc" applies.

[P3] The 409 race re-select still orders by created_at.asc alonebackend/services/academics.py:156-161

rows=table("course_offerings").select(
"id",
filters={"course_id": f"eq.{course_id}", "term_id": f"eq.{term_id}"},
order="created_at.asc",
limit=1,
)

Sibling of the query fixed 20 lines above, same function, same tuple, left on the ordering the comment at :126-132 explains is planner-dependent. Benign in the common race (only the '' row exists at that point), but it is the one path that can return a row the steady-state reader would not, and it now contradicts the new test's assertion that section is the primary sort.

[P3] _all_rows silently returns one page if the row count can't be readbackend/db/import_offerings.py:92-99

whileTrue:
rows, total=table(name).select_with_count(
columns, filters=filters, order="id.asc", limit=PAGE, offset=offset
)
out.extend(rows)
offset+=len(rows)
ifnotrowsoroffset>=total:
returnout

SupabaseTable.select_with_count returns total = 0 whenever Content-Range is missing or unparseable (db/connection.py), so the guard becomes 1000 >= 0 and the helper returns a truncated view of courses / course_offerings without a word. Truncating existing is the dangerous direction: the importer would queue inserts for sections that already exist and take a duplicate-key 409 mid-batch. Terminating on a short page (len(rows) < PAGE) and treating total as advisory removes the dependency. backend/scripts/probe_unscraped_courses.py:70-80 carries the same copy.

What's good

  • Adoption-in-place with the id preserved, and "stale offerings are reported, never deleted", are the two calls that decide whether this is safe to run against a database with live enrollments — both are argued in ADR 0026 and pinned by tests (test_adopts_hollow_offering_in_place, test_stale_offerings_are_left_alone, test_other_terms_are_untouched).
  • Header-driven column lookup with the first-<th>-row rule, and the FETCH_FAILED sentinel that stops a transient listing timeout from reading as end-of-pagination, both come with regression tests that name the exact bug they prevent.
  • Trust boundary is clean: import_offerings / probe_unscraped_courses / verify_catalog_scrape are CLI-only (git grep at head finds no import outside CLAUDE.md), all DB access goes through db/connection.py::table() per the Engineering Style Guide, and the .env templates now match the session-pooler guidance in the Infrastructure doc (postgres.<ref>@...pooler...:5432).

Verdict: request changes — the --link-school early return (P1) makes the documented command silently incomplete on any re-run, and the meeting_times placeholder gap (P2) puts registrar filler in a student-facing column. Everything else is small. Idempotency and migration back-compatibility both check out.


Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy live docs (Engineering Style Guide, Architecture, Infrastructure, Backend & AI Agents). Every finding cites a snippet re-read at this PR's head SHA; severity: P0 blocker · P1 major · P2 minor · P3 nit.

…query
The cross-term fallback and the post-409 re-select were still on
created_at.asc (or no order at all). Since #280 a course has one offering
per published section, all written by one batch insert, so created_at ties
and the winner is the planner's — two calls could hand the same user
different sections and split their documents/notes. The fallback is the
worse of the two: it is unfiltered by term, so it picks among every section
in every term, latent only while current_term() is the newest seeded term.
Extends ADR 0026 Decision 3 to name all three reads.
test_import_offerings.py and test_seed_staging.py each carried their own
Supabase fake with quietly different insert/upsert semantics: a script could
satisfy one module's model of PostgREST and break against the other's, and a
fix to one fake never reached the second. One shared store/table pair now
serves both — rows keyed by primary key like the real database, eq./in.()
filters, paged select_with_count, insert, update, upsert(on_conflict) — with
the per-table primary key (user_profiles keyed on user_id) passed in rather
than forked. It also records writes, so a test can assert HOW a script wrote.
No semantic divergence turned out to be genuine; the only real difference was
the primary-key column, now a constructor argument.
…nest paging
--link-school was dropped whenever the offering sync had nothing to do: the
"already in sync" early return fired before it, so the documented runbook
command linked nothing on any re-run — and the re-run is the path the flag
exists for, since link_school [FAIL]s on duplicate course_codes and asks the
operator to merge them and run again. The branch now still runs the linking
step (respecting --apply) and says so.
Placeholder schedules were the one student-facing column with no placeholder
handling: instructor is nulled at scrape time and location at import time, but
an arranged section's "ARR 12:00 am-12:00 am" was stored verbatim and shown as
a real class time. Nulled on both sides now (the importer also runs against
older catalog files), with an ARR-aware check rather than an exact-match set,
and the verifier fails a scrape that stored one instead of counting it as
meeting_times coverage.
Updates and adoptions went out one PATCH per row while inserts were batched —
O(sections) sequential round trips, 4,122 of them for staging's hollow
fall-2026 offerings alone, each its own transaction and each firing
trg_course_offerings_updated_at. They now use upsert(on_conflict=id) in the
same 500-row batches; rows are sent whole because ON CONFLICT checks NOT NULL
before it detects the conflict.
_all_rows returned a truncated table without a word whenever Content-Range was
missing (select_with_count reports total=0, so offset >= total read 1000 >= 0).
It now stops on a short page and treats the count as advisory — truncating
'existing' is the direction that queues inserts for sections that already exist
and takes a duplicate-key 409 mid-batch. Same copy fixed in
probe_unscraped_courses.py.
@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Review fixes applied

Every outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed.

Major

  • --link-school was silently dropped on every re-run. The Nothing to do — already in sync early return fired before link_school(), so the runbook command documented in CLAUDE.md did nothing for task 3 once offerings were synced — and that is precisely the retry path that matters, since link_school bails on duplicate course codes expecting a merge-and-re-run. Now gated on the flag, with an honest message. Three tests, including that a dry run still does not create the school row.

Minor

  • Registrar placeholder schedules were stored verbatim in a student-facing column while instructor and location were both normalised — an ARR section landed with an honest NULL location and "ARR 12:00 am-12:00 am" as its meeting time. Nulled on both sides with a predicate that keeps a real pattern following ARR, and the verifier now excludes placeholders from coverage instead of counting them (which had inflated the "100%" figure) and flags them.
  • Updates were one PATCH per row while inserts batched at 500 — 4,122 sequential requests plus trigger fires for the staging adoption pass. Now upsert(on_conflict="id") in the same 500-row windows; rows are sent whole because Postgres checks NOT NULL before detecting the conflict.
  • resolve_offering's cross-term fallback is ordered (this PR is what turned "arbitrary among 1" into "arbitrary among 7").

Nits

409-race re-select ordered consistently · _all_rows terminates on a short page instead of silently truncating when Content-Range is unreadable (both copies) · multi-instructor warning · term-scoped fetch · shared PostgREST fake centralised in conftest.py · markdownlint fixes · main() return value and dual-import style.

Also

The PR body said Closes #280; task 4 is deferred, so it now reads Refs #280 and will no longer auto-close the issue.

Verificationruff check . clean · 1569 passed, 38 skipped

Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate.

@Jose-Gael-Cruz-Lopez

Copy link
Copy Markdown
Member

Verify against the real database before merging

I could not reach a live database while working on this — there are no credentials on this machine (only .env.example files) and the local Supabase stack needs a container runtime that wouldn't start. So everything below was verified statically, by replaying every migration in ledger order to build a schema model and checking this PR's DB access against it. That model found 0 schema mismatches here, and it is trustworthy enough to have independently reproduced the documents.course_id absence that caused #562/#534 — but it cannot see data, and it cannot see whether staging's ledger actually matches the repo.

These are the checks that need a real connection.

This is the PR where the database check matters most

It writes ~11k rows and adopts 4,122 existing hollow offerings in place, keeping their ids so enrollments, sessions, documents and notes stay attached. Nothing here can be judged from the repo alone.

1. Always dry-run first — it is the default

cd backend && python -m db.import_offerings

Compare against the numbers in the PR body: 11,079 fall-2026 offerings, 10,958 sectioned, 121 left hollow. A materially different count means the scrape or the term changed underneath it.

2. The placeholder change alters rows that are ALREADY stored

The fix nulls registrar filler (ARR, TBA, TBD) in meeting_times, matching how instructor_name and location were already handled. Staging rows imported before this change still hold values like 'ARR 12:00 am-12:00 am'.

SELECTcount(*) FROM course_offerings
WHERE term_id ='fall-2026'AND meeting_times ~*'^(ARR|TBA|TBD)';

Confirm a re-run actually queues these as updates rather than reporting "already in sync". If the count is non-zero before the run and unchanged after, the reconciliation is comparing post-normalisation values on both sides and silently skipping them — please check before trusting the coverage figure, since verify_catalog_scrape now excludes placeholders from meeting_times coverage and the previously reported "100%" included them.

3. --link-school on a re-run — the P1 that was fixed

The early return used to skip linking entirely once offerings were in sync, so the documented command was a no-op on every retry.

SELECTcount(*) FROM courses WHERE school_id IS NOT NULL; -- expect ~8,510SELECTcount(*) FROM schools WHERE slug ='boston-university';

4. Batched upserts must not have duplicated anything

Per-row PATCHes became upsert(on_conflict="id") in 500-row windows, sending whole rows.

SELECT course_id, term_id, section, count(*)
FROM course_offerings GROUP BY1,2,3HAVINGcount(*) >1; -- expect 0 rowsSELECTcount(*) FROM enrollments e
WHERE NOT EXISTS (SELECT1FROM course_offerings o WHEREo.id=e.offering_id); -- expect 0

5. Re-run idempotency

A second --apply must report nothing to do and write nothing. That is the property that makes this safe to retry.

Static verification only — no live database was reachable from this environment. Schema model built by replaying backend/db/migrations/ in ledger order.

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.

Ingest real Fall 2026 BU course offerings (sections, instructors, meeting times, locations)

2 participants

@Darkest-Teddy@Jose-Gael-Cruz-Lopez