Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy
, '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(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval by Darkest-Teddy · Pull Request #282 · SaplingLearn/Sapling · GitHub
Skip to content

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval - #282

Merged
Darkest-Teddy merged 3 commits into
mainfrom
rag
Jul 3, 2026
Merged

feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrieval#282
Darkest-Teddy merged 3 commits into
mainfrom
rag

Conversation

@Darkest-Teddy

@Darkest-TeddyDarkest-Teddy commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Scraped the full BU course catalog — 8,720 courses across all 22 BU schools
    using an async httpx + BeautifulSoup scraper (scrape_bu_catalog.py). Captures
    course code, title, description, credits, prerequisites, instructors, and semester offerings.
  • Embedded and indexed all coursesingest_catalog.py embeds each course with
    gemini-embedding-001 (768-dim) and upserts into course_chunks with an HNSW index
    for cosine similarity search.
  • Wired retrieval into the tutorbuild_system_prompt injects the enrolled course's
    catalog entry at session start; both chat paths run per-message semantic search
    (top-5 chunks, similarity ≥ 0.55) to ground the tutor in the student's actual course.
  • Indexes student-uploaded documents — after every upload, _index_document_chunks
    runs in the background via _spawn_post_roll, chunks the text into 800-char windows,
    and embeds + upserts into course_chunks. A relevance gate (similarity ≥ 0.35 against
    the course catalog using the AI-generated summary) blocks off-topic uploads.
  • Added rpc() to db/connection.py — thin wrapper around Supabase /rest/v1/rpc/
    used to call match_course_chunks for vector similarity queries.

Test plan

  • Open a tutor session for a course with a known BU course code — confirm system prompt
    includes COURSE CATALOG INFO block
  • Ask a question related to the course — confirm [RAG] lines appear in backend logs
  • Upload a course-relevant document — confirm [RAG] indexed N chunks in logs
  • Upload an unrelated document — confirm [RAG] doc skipped — relevance < 0.35 in logs
  • After uploading, ask about the document's content — confirm tutor surfaces specific info

Summary by CodeRabbit

  • New Features

    • Course conversations now include Retrieval-Augmented Generation (RAG) context from the BU course catalog and previously uploaded documents.
    • Uploaded documents are automatically chunked, embedded, and added to the course knowledge base after upload.
    • Added tools/scripts to scrape the BU course catalog, generate a scrape run summary, and ingest the catalog into the knowledge base.
  • Bug Fixes

    • Improved reliability for large catalog scraping and data ingestion runs.
  • Chores

    • Updated local ignore rules to avoid committing generated RAG data artifacts.

…etrieval
- Scrapes all 8,720 BU courses across 22 schools into bu_catalog_fall_2026.json
- Ingests catalog into course_chunks table with gemini-embedding-001 (768-dim)
- Adds match_course_chunks RPC wiring via db/connection.py rpc() helper
- Wires per-session catalog lookup and per-message semantic retrieval into
build_system_prompt and both chat paths (agent + legacy)
- Indexes student-uploaded documents into course_chunks after upload,
with relevance gate (cosine similarity >= 0.35 against catalog chunk)
using the AI-generated summary as the document representative
@coderabbitai

coderabbitaiBot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a BU catalog RAG pipeline: catalog data is scraped and ingested into course_chunks, retrieved through a new service and RPC helper, injected into tutor prompts, and used to index uploaded documents. Separately, two frontend labels are updated.

Changes

RAG Pipeline

Layer / File(s)Summary
BU catalog scraper
backend/scripts/scrape_bu_catalog.py, backend/data/scrape_summary.json, .gitignore
Crawls BU course listings and detail pages, resumes from prior output, writes scrape totals/errors metadata, and ignores generated JSON data files.
Catalog ingestion into course_chunks
backend/scripts/ingest_catalog.py
Builds deterministic course chunk records from catalog JSON, embeds in batches with retry handling, and upserts into course_chunks by stable ID.
RAG retrieval service and RPC helper
backend/db/connection.py, backend/services/rag_service.py
Adds a Supabase RPC POST helper, query embedding, match_course_chunks retrieval, similarity filtering, and RAG context formatting.
Tutor prompt enrichment
backend/routes/learn.py
Loads catalog chunk text for a course and injects retrieved RAG context into both agent and legacy chat paths.
Upload-time document indexing
backend/routes/documents.py
Schedules post-upload indexing, chunks extracted document text, embeds and relevance-filters chunks, and upserts them into course_chunks.

UI Text Updates

Layer / File(s)Summary
Knowledge graph labels
frontend/src/components/KnowledgeGraph2D.tsx
Removes the node-radius gate so non-root labels render based on subject-root state alone.
Dashboard legend labels
frontend/src/components/screens/Dashboard.tsx
Capitalizes the four mastery-state legend labels without changing their colors.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 33.33% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check⚠️ WarningThe PR description is informative but does not follow the required template and omits several required sections like Changes Made and Related Issues.Restructure the description to match the template and add the missing sections, especially Changes Made, Related Issues, and Notes for Reviewers.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Title check✅ PassedThe title clearly summarizes the main change: a Layer 0 RAG pipeline for BU catalog ingestion, embedding, and per-message retrieval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rag

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.

@Darkest-TeddyDarkest-Teddy changed the title feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalDON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJun 28, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jun 28, 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-staging496582bCommit Preview URL

Branch Preview URL
Jul 03 2026, 03:41 AM

import asyncio
import json
import re
import sys
for school in SCHOOLS:
print(f"\n>> {school}", flush=True)
before = len(all_courses)
school_courses = await scrape_school(client, school, seen_urls, on_batch=save_checkpoint)

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

🤖 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/data/scrape_summary.json`:
- Line 209: The committed scrape_summary artifact currently hardcodes a
machine-local absolute path in the output_file field, which leaks
workstation-specific details and breaks portability. Update the value in
scrape_summary.json to use a repo-relative path or remove the field entirely,
and ensure any code that writes this summary uses a stable path source so future
runs do not reintroduce local identifiers; check the summary generation logic
and the output_file serialization path.
- Around line 2-205: The scrape summary currently includes unresolved failures,
so do not keep this snapshot as-is. Update the ingestion flow that reads
scrape_summary.json to either rerun scraping until the errors array is empty or
make the ingest step fail fast when total_errors is nonzero, using the summary
fields total_errors and errors as the check. Locate the validation in the
scrape-summary ingestion path and ensure it blocks indexing when any failed
course URLs remain.
In `@backend/routes/documents.py`:
- Around line 994-1014: The batch embedding flow in the document ingest path
still upserts every record at the end, including chunks whose embedding stayed
None after _embed_texts failed. Update the course_chunks ingest logic to mirror
the catalog ingest pattern by upserting only the successfully embedded records
inside each batch loop, and skip failed batch records instead of carrying them
forward. Keep the batching behavior in place around the existing record
processing in the documents route so large uploads do not get sent as one final
upsert.
- Around line 960-975: The relevance check currently uses only the first
returned catalog chunk from table("course_chunks"), which is arbitrary because
the query has limit=1 without ordering. Update the logic around the catalog_rows
lookup to evaluate all catalog embeddings for the course and compare the
document sample against the best cosine match, then apply the
MIN_COURSE_RELEVANCE gate to that maximum score. Keep the change localized near
the existing sample_text, doc_sample_vec, and dot calculation so the
representative catalog selection is no longer dependent on Supabase row order.
In `@backend/routes/learn.py`:
- Around line 504-505: The course-scoped retrieval in learn.py is broadening to
global RAG when _get_course_info() does not return a BU code, because bu_code or
None passes None into retrieve_chunks() and drops the course filter. Update the
logic around _get_course_info, bu_code, and retrieve_chunks so that when
course_id is present but no valid course_code is found, retrieval does not fall
back to all course_chunks; instead, skip or short-circuit the course-scoped RAG
path and keep the query scoped to the requested course only. Apply the same fix
in both places that build rag_chunks from retrieve_chunks.
- Around line 300-304: The retrieved catalog/chunk content is being appended
directly into the active prompt, so malicious uploads can inject instructions
into both the legacy system prompt path and the agent user-turn path. Update
build_system_prompt and the code paths that add catalog_text/chunk_text to treat
retrieved text as untrusted data: wrap it in clear delimiters or a context
block, add an explicit rule to ignore any instructions inside retrieved content,
and avoid merging raw retrieved text into the prompt where possible.
In `@backend/scripts/ingest_catalog.py`:
- Line 41: The Gemini client setup and ingest retry path are allowing missing or
invalid credentials to continue until rows are written with empty embeddings.
Update the `_gemini` initialization and the ingest flow in `ingest_catalog.py`
so a missing `GEMINI_API_KEY` or a failed embedding request stops processing
immediately instead of upserting `embedding=None` into `course_chunks`. Make the
failure explicit in the `genai.Client`/embedding generation path and propagate
it through the affected ingest logic around the referenced retry/upsert section.
In `@backend/scripts/scrape_bu_catalog.py`:
- Around line 26-47: The SCHOOLS list in scrape_bu_catalog.py is missing two BU
school slugs, so the crawl only covers 20 schools instead of the full 22-school
contract. Update the SCHOOLS constant in scrape_bu_catalog.py to include the
missing school slugs, or replace the hardcoded list with a source that derives
all schools dynamically so the crawler and downstream index always stay
complete. Use the existing SCHOOLS configuration in scrape_bu_catalog.py as the
place to fix this.
- Around line 210-232: The second _extract_schedule() implementation overwrites
the earlier heading-based semester parser, so pages with only an h4 term heading
lose semester detection. Update _extract_schedule() to preserve the existing
<h4>FALL 2025Schedule</h4> parsing logic while still collecting semesters and
instructors from table headers, and ensure semester_offered remains populated
when the term appears only in the heading.
In `@backend/services/rag_service.py`:
- Around line 21-48: `retrieve_chunks()` in `rag_service.py` is doing blocking
Gemini and PostgREST I/O on the async chat path, which can stall the event loop.
Update the RAG flow so `retrieve_chunks` and its helper `_embed` are async
end-to-end, or run the sync work in a threadpool, and then adjust the async chat
handlers that call `retrieve_chunks()` to await/use the non-blocking version.
Keep the existing `retrieve_chunks`, `_embed`, and chat handler call sites as
the main places to update.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 42df138f-228e-4ce0-a192-993b5752c4b0

📥 Commits

Reviewing files that changed from the base of the PR and between 1c2f6f0 and 1f3f7c2.

📒 Files selected for processing (8)
  • .gitignore
  • backend/data/scrape_summary.json
  • backend/db/connection.py
  • backend/routes/documents.py
  • backend/routes/learn.py
  • backend/scripts/ingest_catalog.py
  • backend/scripts/scrape_bu_catalog.py
  • backend/services/rag_service.py

Comment on lines +2 to +205
"total_courses": 8720,
"total_errors": 50,
"errors": [
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-650a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-or-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-529a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-512a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-641a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-525a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pd-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-511a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-522a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-541a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-532a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-660a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-523a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-od-644a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-581a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pa-530a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-534a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-md-531a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-546a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-542a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-en-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-519a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-pe-520a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-527a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-521a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-642a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-os-640a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-544a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-gd-540a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-ph-524a/",
"error": "empty response"
},
{
"url": "https://www.bu.edu/academics/sdm/courses/sdm-rs-532a/",
"error": "empty response"
}
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't commit a scrape summary that already records 50 failed course pages.

Ingestion uses this scrape output as the source corpus, so these unrecovered SDM URLs mean the initial RAG index is knowingly incomplete. Please either rerun until this is clean or make ingestion fail fast when the scrape summary reports unresolved errors.

🤖 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/data/scrape_summary.json` around lines 2 - 205, The scrape summary
currently includes unresolved failures, so do not keep this snapshot as-is.
Update the ingestion flow that reads scrape_summary.json to either rerun
scraping until the errors array is empty or make the ingest step fail fast when
total_errors is nonzero, using the summary fields total_errors and errors as the
check. Locate the validation in the scrape-summary ingestion path and ensure it
blocks indexing when any failed course URLs remain.

"elapsed_seconds": 1890,
"completed_at": "2026-06-27T01:39:55.726124+00:00",
"semester_tag": "fall_2026",
"output_file": "C:\\Users\\Jack\\Desktop\\VS Code\\sapling\\backend\\data\\bu_catalog_fall_2026.json"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Remove the machine-local absolute path from this committed artifact.

C:\Users\Jack\... leaks workstation details and makes the summary non-portable. A repo-relative path or no path field at all would avoid noisy diffs and the local identifier exposure.

🤖 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/data/scrape_summary.json` at line 209, The committed scrape_summary
artifact currently hardcodes a machine-local absolute path in the output_file
field, which leaks workstation-specific details and breaks portability. Update
the value in scrape_summary.json to use a repo-relative path or remove the field
entirely, and ensure any code that writes this summary uses a stable path source
so future runs do not reintroduce local identifiers; check the summary
generation logic and the output_file serialization path.

Comment on lines +960 to +975
catalog_rows = table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
if catalog_rows and catalog_rows[0].get("embedding"):
catalog_vec = catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))
if dot < MIN_COURSE_RELEVANCE:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Compare against the best catalog match, not an arbitrary catalog row.

Line 960 fetches limit=1 with no ordering, so the relevance gate can reject an on-topic upload if Supabase returns a non-representative catalog chunk. Fetch the course’s catalog embeddings and gate on the max cosine score instead.

Suggested adjustment
- catalog_rows = table("course_chunks").select(- "embedding",- filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},- limit=1,- )- if catalog_rows and catalog_rows[0].get("embedding"):- catalog_vec = catalog_rows[0]["embedding"]+ catalog_rows = table("course_chunks").select(+ "embedding",+ filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},+ )+ catalog_vecs = [r["embedding"] for r in catalog_rows if r.get("embedding")]+ if catalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text = doc_summary or chunks[0]
doc_sample_vec = _embed_texts([sample_text])[0]
time.sleep(1.5)
- # cosine similarity (vectors are unit-norm from the model)- dot = sum(a * b for a, b in zip(doc_sample_vec, catalog_vec))+ dot = max(sum(a * b for a, b in zip(doc_sample_vec, catalog_vec)) for catalog_vec in catalog_vecs)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
limit=1,
)
ifcatalog_rowsandcatalog_rows[0].get("embedding"):
catalog_vec=catalog_rows[0]["embedding"]
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
# cosine similarity (vectors are unit-norm from the model)
dot=sum(a*bfora, binzip(doc_sample_vec, catalog_vec))
ifdot<MIN_COURSE_RELEVANCE:
catalog_rows=table("course_chunks").select(
"embedding",
filters={"course_id": f"eq.{bu_course_id}", "category": "eq.catalog"},
)
catalog_vecs= [r["embedding"] forrincatalog_rowsifr.get("embedding")]
ifcatalog_vecs:
# Use the AI-generated summary as the document representative —
# it's more reliable than raw first-chunk text (avoids cover pages,
# tables of contents, and boilerplate skewing the score).
sample_text=doc_summaryorchunks[0]
doc_sample_vec=_embed_texts([sample_text])[0]
time.sleep(1.5)
dot=max(sum(a*bfora, binzip(doc_sample_vec, catalog_vec)) forcatalog_vecincatalog_vecs)
ifdot<MIN_COURSE_RELEVANCE:
🤖 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/routes/documents.py` around lines 960 - 975, The relevance check
currently uses only the first returned catalog chunk from
table("course_chunks"), which is arbitrary because the query has limit=1 without
ordering. Update the logic around the catalog_rows lookup to evaluate all
catalog embeddings for the course and compare the document sample against the
best cosine match, then apply the MIN_COURSE_RELEVANCE gate to that maximum
score. Keep the change localized near the existing sample_text, doc_sample_vec,
and dot calculation so the representative catalog selection is no longer
dependent on Supabase row order.

Comment on lines +994 to +1014
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})

# Embed in batches of 50
BATCH = 50
for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota

table("course_chunks").upsert(records, on_conflict="id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only upsert successfully embedded chunks, and keep upserts batched.

If _embed_texts fails for a batch, those records keep embedding=None but are still sent to course_chunks; additionally, all chunks are posted in one large request after embedding. That can either pollute the retrieval index or make large uploads fail at the final Supabase call. Upsert only records with embeddings per batch, matching the catalog ingest pattern.

Suggested adjustment
 for i in range(0, len(records), BATCH):
batch = records[i : i + BATCH]
texts = [r["chunk_text"] for r in batch]
try:
vecs = _embed_texts(texts)
+ if len(vecs) != len(batch):+ raise ValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
for rec, vec in zip(batch, vecs):
rec["embedding"] = vec
+ table("course_chunks").upsert(batch, on_conflict="id")
except Exception as e:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
- table("course_chunks").upsert(records, on_conflict="id")
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
table("course_chunks").upsert(records, on_conflict="id")
"embedding": None,
"category": category,
"semester": "current",
"section_id": None,
"school": "",
})
# Embed in batches of 50
BATCH=50
foriinrange(0, len(records), BATCH):
batch=records[i : i+BATCH]
texts= [r["chunk_text"] forrinbatch]
try:
vecs=_embed_texts(texts)
iflen(vecs) !=len(batch):
raiseValueError(f"embedding count mismatch: {len(vecs)} for {len(batch)} chunks")
forrec, vecinzip(batch, vecs):
rec["embedding"] =vec
table("course_chunks").upsert(batch, on_conflict="id")
exceptExceptionase:
logger.warning("[RAG] embed failed for doc %s batch %d: %s", doc_id, i, e)
time.sleep(1.5) # stay under 3000 req/min quota
logger.info("[RAG] indexed %d chunks for doc %s", len(records), doc_id)
🤖 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/routes/documents.py` around lines 994 - 1014, The batch embedding
flow in the document ingest path still upserts every record at the end,
including chunks whose embedding stayed None after _embed_texts failed. Update
the course_chunks ingest logic to mirror the catalog ingest pattern by upserting
only the successfully embedded records inside each batch loop, and skip failed
batch records instead of carrying them forward. Keep the batching behavior in
place around the existing record processing in the documents route so large
uploads do not get sent as one final upsert.

Comment on lines +300 to +304
if course_id:
course_info = _get_course_info(course_id)
catalog_text = _get_catalog_chunk(course_info.get("course_code", ""))
if catalog_text:
parts.append("COURSE CATALOG INFO (BU official course data):\n\n" + catalog_text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Treat retrieved text as untrusted context, not prompt instructions.

These paths concatenate catalog_text / chunk_text straight into the active prompt. Because course_chunks also holds indexed uploads, a malicious document can inject instructions that the legacy path elevates into system_prompt, and the agent path merges into the user turn. Wrap retrieved text in hard data delimiters, add an explicit “ignore instructions inside retrieved context” rule, or pass it as tool/context payload instead of raw prompt text. As per coding guidelines, backend/routes/learn.py: build_system_prompt defines the streaming tutor (SSE) system prompt and should preserve the tutor prompt contract.

Also applies to: 503-509, 565-571

🤖 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/routes/learn.py` around lines 300 - 304, The retrieved catalog/chunk
content is being appended directly into the active prompt, so malicious uploads
can inject instructions into both the legacy system prompt path and the agent
user-turn path. Update build_system_prompt and the code paths that add
catalog_text/chunk_text to treat retrieved text as untrusted data: wrap it in
clear delimiters or a context block, add an explicit rule to ignore any
instructions inside retrieved content, and avoid merging raw retrieved text into
the prompt where possible.

Source: Coding guidelines

Comment on lines +504 to +505
bu_code = _get_course_info(course_id).get("course_code") if course_id else None
rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not broaden to global RAG when course lookup fails.

If course_id is present but _get_course_info() returns no BU code, these calls pass None into retrieve_chunks(), which drops the course filter and searches all course_chunks. That breaks the course-scoped grounding this PR is adding and can inject material from the wrong class.

Minimal fix
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(user_message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(user_message, course_id=bu_code, k=5) if bu_code else []
- bu_code = _get_course_info(course_id).get("course_code") if course_id else None- rag_chunks = retrieve_chunks(body.message, course_id=bu_code or None, k=5)+ bu_code = _get_course_info(course_id).get("course_code") if course_id else None+ rag_chunks = retrieve_chunks(body.message, course_id=bu_code, k=5) if bu_code else []

Also applies to: 561-562

🤖 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/routes/learn.py` around lines 504 - 505, The course-scoped retrieval
in learn.py is broadening to global RAG when _get_course_info() does not return
a BU code, because bu_code or None passes None into retrieve_chunks() and drops
the course filter. Update the logic around _get_course_info, bu_code, and
retrieve_chunks so that when course_id is present but no valid course_code is
found, retrieval does not fall back to all course_chunks; instead, skip or
short-circuit the course-scoped RAG path and keep the query scoped to the
requested course only. Apply the same fix in both places that build rag_chunks
from retrieve_chunks.

EMBED_BATCH = 100 # texts per Gemini embed_content call
RATE_DELAY = 3.0 # 100 texts / 3s = ~2,000 texts/min (limit is 3,000/min)

_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast instead of upserting rows with embedding=None.

Because the client is created with an empty default API key, a missing or invalid GEMINI_API_KEY falls into the retry path and then writes null embeddings to course_chunks anyway. That leaves the ingest looking successful while retrieval can never match those rows.

Suggested fix
-_gemini = genai.Client(api_key=os.getenv("GEMINI_API_KEY", ""))+_api_key = os.getenv("GEMINI_API_KEY")+if not _api_key:+ raise RuntimeError("GEMINI_API_KEY is required for catalog ingestion")+_gemini = genai.Client(api_key=_api_key)
@@
- except Exception as exc2:- print(f" FAILED [{i}:{i+EMBED_BATCH}]: {exc2} — inserting without embedding")- embeddings.extend([None] * len(batch))+ except Exception as exc2:+ raise RuntimeError(+ f"Embedding failed for batch [{i}:{i + EMBED_BATCH}]"+ ) from exc2

Also applies to: 117-156

🤖 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/ingest_catalog.py` at line 41, The Gemini client setup and
ingest retry path are allowing missing or invalid credentials to continue until
rows are written with empty embeddings. Update the `_gemini` initialization and
the ingest flow in `ingest_catalog.py` so a missing `GEMINI_API_KEY` or a failed
embedding request stops processing immediately instead of upserting
`embedding=None` into `course_chunks`. Make the failure explicit in the
`genai.Client`/embedding generation path and propagate it through the affected
ingest logic around the referenced retry/upsert section.

Comment on lines +26 to +47
SCHOOLS = [
"cas",
"com",
"eng",
"cfa",
"cgs",
"cds",
"khc",
"gms",
"grs",
"sdm",
"met",
"questrom",
"sar",
"sha",
"law",
"sph",
"ssw",
"sth",
"wheelock",
"frederick-s-pardee-school-of-global-studies",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Only 20 school slugs are configured.

This list has 20 entries, so two BU schools never get crawled and the catalog/index is incomplete before ingestion even starts. Please add the missing slugs or derive the school list dynamically so it matches the 22-school scrape contract in this PR.

🤖 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 26 - 47, The SCHOOLS list
in scrape_bu_catalog.py is missing two BU school slugs, so the crawl only covers
20 schools instead of the full 22-school contract. Update the SCHOOLS constant
in scrape_bu_catalog.py to include the missing school slugs, or replace the
hardcoded list with a source that derives all schools dynamically so the crawler
and downstream index always stay complete. Use the existing SCHOOLS
configuration in scrape_bu_catalog.py as the place to fix this.

Comment on lines +210 to +232
def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
for table in soup.find_all("table"):
headers = [th.get_text(strip=True) for th in table.find_all("th")]
for h in headers:
if sem_pattern.match(h.strip()) and h not in semesters:
semesters.append(h.strip().title())

# Instructor column
lower_headers = [h.lower() for h in headers]
if "instructor" in lower_headers:
idx = lower_headers.index("instructor")
for row in table.find_all("tr")[1:]:
cells = row.find_all("td")
if len(cells) > idx:
name = cells[idx].get_text(strip=True)
if name and name not in ("TBA", "Staff", "") and name not in instructors:
instructors.append(name)
return semesters, instructors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

This second _extract_schedule() overrides the <h4> semester parser.

The earlier implementation already handled the documented <h4>FALL 2025Schedule</h4> structure. Redefining the function here drops that path and only looks at table headers, so semester_offered will be empty on pages where the term appears only in the heading.

Suggested fix
 def _extract_schedule(soup: BeautifulSoup) -> tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
+ for h4 in soup.find_all("h4"):+ m = _SEM_RE.search(h4.get_text(strip=True))+ if m:+ label = m.group(0).strip().title()+ if label not in semesters:+ semesters.append(label)
sem_pattern = re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
def_extract_schedule(soup: BeautifulSoup) ->tuple[list[str], list[str]]:
semesters: list[str] = []
instructors: list[str] = []
forh4insoup.find_all("h4"):
m=_SEM_RE.search(h4.get_text(strip=True))
ifm:
label=m.group(0).strip().title()
iflabelnotinsemesters:
semesters.append(label)
sem_pattern=re.compile(
r'^(FALL|SPRING|SPRG|SUMMER|SUMM|SUM|WINTER|WINT)\s+\d{4}$', re.I
)
fortableinsoup.find_all("table"):
headers= [th.get_text(strip=True) forthintable.find_all("th")]
forhinheaders:
ifsem_pattern.match(h.strip()) andhnotinsemesters:
semesters.append(h.strip().title())
# Instructor column
lower_headers= [h.lower() forhinheaders]
if"instructor"inlower_headers:
idx=lower_headers.index("instructor")
forrowintable.find_all("tr")[1:]:
cells=row.find_all("td")
iflen(cells) >idx:
name=cells[idx].get_text(strip=True)
ifnameandnamenotin ("TBA", "Staff", "") andnamenotininstructors:
instructors.append(name)
returnsemesters, instructors
🤖 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 210 - 232, The second
_extract_schedule() implementation overwrites the earlier heading-based semester
parser, so pages with only an h4 term heading lose semester detection. Update
_extract_schedule() to preserve the existing <h4>FALL 2025Schedule</h4> parsing
logic while still collecting semesters and instructors from table headers, and
ensure semester_offered remains populated when the term appears only in the
heading.

Comment on lines +21 to +48
def _embed(text: str) -> list[float]:
resp = _client.models.embed_content(
model=_EMBED_MODEL,
contents=[text],
config=genai_types.EmbedContentConfig(output_dimensionality=_OUTPUT_DIM),
)
return list(resp.embeddings[0].values)


def retrieve_chunks(
query: str,
course_id: str | None = None,
k: int = 5,
min_similarity: float = 0.55,
) -> list[dict]:
"""Return up to k chunks similar to query, optionally filtered by course_id.

Each result: {"course_id": str, "chunk_text": str, "similarity": float}
"""
try:
embedding = _embed(query)
params: dict = {
"query_embedding": embedding,
"match_count": k,
"filter_course_id": course_id,
}
rows = rpc("match_course_chunks", params)
return [r for r in rows if r.get("similarity", 0) >= min_similarity]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"Async chat handlers calling retrieve_chunks:"
rg -n -C2 'async def (_chat_via_agent|_legacy_chat)|retrieve_chunks\(' backend/routes/learn.py
echoecho"Blocking calls inside the RAG path:"
rg -n -C2 '_client\.models\.embed_content|rpc\("match_course_chunks"|_client\.post\(' \
backend/services/rag_service.py backend/db/connection.py

Repository: SaplingLearn/Sapling

Length of output: 2952


Move RAG I/O off the async request path.

retrieve_chunks() makes synchronous Gemini and PostgREST calls, and both async chat handlers invoke it directly. That blocks the event loop on every chat request and will hurt concurrency under load; make this path async end-to-end or wrap it in a threadpool.

🤖 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/services/rag_service.py` around lines 21 - 48, `retrieve_chunks()` in
`rag_service.py` is doing blocking Gemini and PostgREST I/O on the async chat
path, which can stall the event loop. Update the RAG flow so `retrieve_chunks`
and its helper `_embed` are async end-to-end, or run the sync work in a
threadpool, and then adjust the async chat handlers that call
`retrieve_chunks()` to await/use the non-blocking version. Keep the existing
`retrieve_chunks`, `_embed`, and chat handler call sites as the main places to
update.

…legend
- Remove r > 10 gate in KnowledgeGraph2D so every concept node shows its
name regardless of mastery score (unexplored nodes have r=8, below old threshold)
- Capitalize legend labels: mastered/learning/struggling/unexplored → title case
- Add react-force-graph-3d (missing from node_modules, caused build error)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@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.

🧹 Nitpick comments (1)
frontend/src/components/KnowledgeGraph2D.tsx (1)

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

Labels now render for all non-root nodes regardless of size.

Dropping r > 10 means even the smallest (e.g., unexplored/low-mastery) nodes get text labels. In dense graphs this can cause overlapping/cluttered labels since node radii can be as small as 8px and collision radius as low as 18px (Line 202, 233). If this is the intended UX, LGTM; otherwise consider re-adding a minimum-size or zoom-level gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KnowledgeGraph2D.tsx` at line 518, The label
rendering in KnowledgeGraph2D now applies to every non-root node, which can make
tiny nodes clutter the graph. In the node label rendering block near the
n.is_subject_root check, add back a guard based on node size or zoom level so
labels only appear when the node radius is large enough or the graph is
sufficiently zoomed in. Use the existing node radius/collision logic in
KnowledgeGraph2D as the reference point for choosing the threshold.
🤖 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.
Nitpick comments:
In `@frontend/src/components/KnowledgeGraph2D.tsx`:
- Line 518: The label rendering in KnowledgeGraph2D now applies to every
non-root node, which can make tiny nodes clutter the graph. In the node label
rendering block near the n.is_subject_root check, add back a guard based on node
size or zoom level so labels only appear when the node radius is large enough or
the graph is sufficiently zoomed in. Use the existing node radius/collision
logic in KnowledgeGraph2D as the reference point for choosing the threshold.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5568e158-ca74-488f-bf4b-67843028f7b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1f3f7c2 and 496582b.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/src/components/KnowledgeGraph2D.tsx
  • frontend/src/components/screens/Dashboard.tsx
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/components/screens/Dashboard.tsx

@Darkest-TeddyDarkest-Teddy changed the title DON'T MERGE! feat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalfeat(rag): Layer 0 RAG pipeline - BU catalog scrape, embedding, and per-message retrievalJul 3, 2026
@Darkest-Teddy
Darkest-Teddy merged commit ba90801 into mainJul 3, 2026
4 checks passed
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.

1 participant

@Darkest-Teddy