Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions backend/db/migrations/0039_rag_vector_store.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
-- 0039: codify the RAG vector store that only ever existed as dashboard DDL (#481).
--
-- `course_chunks`, the `match_course_chunks` RPC and `CREATE EXTENSION vector`
-- appear in ZERO .sql files in this repo, yet all three are live in staging and
-- production. A database replayed purely from `python -m db.migrate` — local
-- Supabase, the E2E stack, any fresh environment — therefore has RAG silently
-- dead end to end:
--
-- * retrieve_chunks() swallows the RPC failure into [] (rag_service.py)
-- * _get_catalog_chunk() degrades to ""
-- * indexing failures vanish into a fire-and-forget log line
--
-- so the tutor answers ungrounded with no error, no metric and no user-visible
-- signal, and nothing in the suites or oracles asserts the table exists.
--
-- Shape verified against LIVE production and staging on 2026-07-31 by reading a
-- real row and calling the RPC (columns, the 768-dim embedding, and the RPC's
-- exact parameter and return names), rather than from the design doc alone —
-- the doc describes intent, the database is what the code actually talks to.
--
-- IF NOT EXISTS throughout, mirroring 0032's reconcile pattern: this must be a
-- no-op on staging and production, where every object below already exists.

CREATE EXTENSION IF NOT EXISTS vector;

-- Chunk ids are content-addressed (sha256 hex) so a re-upload of the same text
-- merges instead of duplicating — see rag_service.chunk_id.
CREATE TABLE IF NOT EXISTS course_chunks (
id TEXT PRIMARY KEY,
course_id TEXT NOT NULL,
doc_id TEXT,
uploader_id TEXT,
chunk_index INTEGER,
chunk_text TEXT,
chunk_hash TEXT,
embedding VECTOR(768),
category TEXT,
semester TEXT,
section_id TEXT,
school TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);

-- Retrieval always filters by course before ranking.
CREATE INDEX IF NOT EXISTS idx_course_chunks_course_id ON course_chunks (course_id);
CREATE INDEX IF NOT EXISTS idx_course_chunks_doc_id ON course_chunks (doc_id);

-- ANN index. ivfflat needs training data to be worth building, and `lists`
-- wants tuning against real row counts; cosine matches the normalised
-- gemini-embedding-001 output the code stores.
CREATE INDEX IF NOT EXISTS idx_course_chunks_embedding
ON course_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- Retrieval RPC. Parameter names are part of the contract — rag_service.py
-- calls this by keyword (query_embedding / match_count / filter_course_id) and
-- reads back id, course_id, chunk_text, category, similarity.
--
-- CREATE OR REPLACE rather than IF NOT EXISTS: functions have no IF NOT EXISTS
-- form, and replacing with the identical body is the no-op we want on the
-- environments that already have it.
CREATE OR REPLACE FUNCTION match_course_chunks(
query_embedding VECTOR(768),
match_count INTEGER DEFAULT 5,
filter_course_id TEXT DEFAULT NULL
)
RETURNS TABLE (
id TEXT,
course_id TEXT,
chunk_text TEXT,
category TEXT,
similarity FLOAT
)
LANGUAGE sql STABLE
AS $$
SELECT
c.id,
c.course_id,
c.chunk_text,
c.category,
1 - (c.embedding <=> query_embedding) AS similarity
FROM course_chunks c
WHERE c.embedding IS NOT NULL
AND (filter_course_id IS NULL OR c.course_id = filter_course_id)
ORDER BY c.embedding <=> query_embedding
LIMIT match_count;
$$;
3 changes: 2 additions & 1 deletion backend/e2e_oracles/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@
venv/bin/python -m e2e_oracles [--json] [--check NAME]... [--user ID] \\
[--base-url URL] [--log PATH]

Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans` — default
Check names: `graph`, `counts`, `ciphertext`, `logscan`, `orphans`, `ragstore` — default
is all five, in that (sorted) order. `--check` may repeat to select a subset.

Exit codes: 0 clean / 1 findings / 2 infra error — ANY `Finding(oracle=
Expand DownExpand Up@@ -44,6 +44,7 @@
"ciphertext": lambda args: gather.run_ciphertext(args),
"logscan": lambda args: gather.run_logscan(args),
"orphans": lambda args: gather.run_orphans(args),
"ragstore": lambda args: gather.run_ragstore(args),
}


Expand Down
72 changes: 72 additions & 0 deletions backend/e2e_oracles/gather.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -283,3 +283,75 @@ def run_logscan(args: argparse.Namespace) -> tuple[list[Finding], int]:
)
], 0
return scan_file(log_path)


def run_ragstore(args: argparse.Namespace) -> tuple[list[Finding], int]:
"""`ragstore`: the RAG vector store exists and is queryable (#481).

The failure this exists to catch is SILENT. `course_chunks`, the
`match_course_chunks` RPC and the `vector` extension lived only as
dashboard DDL, in no migration — so a database replayed purely from
`python -m db.migrate` had RAG dead end to end while every suite stayed
green: `retrieve_chunks` swallows the RPC failure into `[]`,
`_get_catalog_chunk` degrades to `""`, and indexing failures vanish into
a fire-and-forget log line. The tutor just answers ungrounded.

So this asserts the store's EXISTENCE, not its contents — an empty
course_chunks is normal on a fresh stack; a missing one is the bug.
"""
conn = _db_conn()
findings: list[Finding] = []

ext = conn.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'").fetchone()
if not ext:
findings.append(
Finding(
oracle="ragstore",
summary="pgvector extension is not installed — RAG cannot store or query embeddings",
evidence={"expected": "CREATE EXTENSION vector (migration 0039)"},
)
)

tbl = conn.execute(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'course_chunks'"
).fetchone()
if not tbl:
findings.append(
Finding(
oracle="ragstore",
summary="course_chunks table is missing — retrieval degrades to [] with no error",
evidence={"expected": "migration 0039 creates it"},
)
)

fn = conn.execute(
"SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE p.proname = 'match_course_chunks'"
).fetchone()
if not fn:
findings.append(
Finding(
oracle="ragstore",
summary="match_course_chunks RPC is missing — every retrieval silently returns []",
evidence={"expected": "migration 0039 creates it"},
)
)

# Rows whose embedding never landed are retrievable by nothing: the RPC
# orders by distance and skips NULLs, so they are dead weight that still
# counts as "indexed" to the caller (#482).
if tbl:
dead = conn.execute(
"SELECT count(*) AS n FROM course_chunks WHERE embedding IS NULL"
).fetchone()
if dead and dead["n"]:
findings.append(
Finding(
oracle="ragstore",
summary=f"{dead['n']} course_chunks row(s) have a NULL embedding — indexed but unretrievable",
evidence={"null_embedding_rows": dead["n"]},
)
)

return findings, 0
20 changes: 18 additions & 2 deletions backend/services/rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,8 +210,24 @@ def index_document_chunks(
except Exception as e:
print(f"[RAG] embed failed for doc {doc_id} batch {i}: {e}")

table("course_chunks").upsert(records, on_conflict="id")
return len(records)
# Never persist a chunk whose embedding didn't land. match_course_chunks
# ranks by vector distance, so a NULL-embedding row can never be returned
# by retrieval — it is dead weight that nonetheless counted toward the
# "indexed N chunks" the caller logs, which is how a partial embedding
# failure used to read as a complete success (#482).
embedded = [r for r in records if r["embedding"] is not None]
dropped = len(records) - len(embedded)
if dropped:
print(
f"[RAG] doc {doc_id}: dropped {dropped}/{len(records)} chunk(s) "
f"with no embedding — they would be unretrievable"
)

if not embedded:
return 0

table("course_chunks").upsert(embedded, on_conflict="id")
return len(embedded)


def format_rag_context(chunks: list[dict]) -> str:
Expand Down
62 changes: 46 additions & 16 deletions backend/tests/test_rag_service.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -191,8 +191,17 @@ def test_duplicate_chunks_within_one_document_are_deduped(mock_client):


@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_handles_embedding_failure(mock_embed):
"""Test that embedding failures are caught and records are still upserted with embedding=None."""
def test_index_document_chunks_drops_chunks_whose_embedding_failed(mock_embed):
"""A total embedding failure must persist NOTHING and report 0 (#482).

This test previously asserted the opposite — that the rows were upserted
with `embedding=None` and the call reported the full chunk count. That was
pinning a bug, not a contract: `match_course_chunks` ranks by vector
distance, so a NULL-embedding row can never be returned by retrieval. Those
rows were unreachable dead weight that still counted toward the "indexed N
chunks" the caller logs, which is exactly how a total embedding outage read
as a complete success.
"""
mock_embed.side_effect = Exception("API error")
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
Expand All@@ -205,16 +214,33 @@ def test_index_document_chunks_handles_embedding_failure(mock_embed):
chunks=["chunk one", "chunk two", "chunk three"],
)

# Function completes without raising an exception
assert count == 3
# Still no exception — indexing stays best-effort, as before.
assert count == 0
# …but nothing unretrievable is written.
mock_table.return_value.upsert.assert_not_called()

# Records were still upserted
mock_table.return_value.upsert.assert_called_once()
upsert_records = mock_table.return_value.upsert.call_args[0][0]

# All records have embedding=None since embedding failed
assert all(rec["embedding"] is None for rec in upsert_records)
assert len(upsert_records) == 3
@patch("services.rag_service._embed_documents_batch")
def test_index_document_chunks_keeps_the_chunks_that_did_embed(mock_embed):
"""A PARTIAL failure keeps the good chunks and drops only the bad ones."""
# Batch size is 50, so five chunks arrive as one batch; return a short
# vector list so zip() leaves the tail without an embedding.
mock_embed.return_value = [[0.1] * 768, [0.2] * 768]
with patch("services.rag_service.table") as mock_table:
mock_table.return_value.upsert.return_value = []
from services.rag_service import index_document_chunks

count = index_document_chunks(
course_code="CAS CS 330",
doc_id="doc-partial",
uploader_id="user-xyz",
chunks=["one", "two", "three", "four", "five"],
)

assert count == 2
upserted = mock_table.return_value.upsert.call_args[0][0]
assert len(upserted) == 2
assert all(rec["embedding"] is not None for rec in upserted)


# ── #439: below-seam RAG embed calls must gate on SAPLING_MODEL_MODE ───────
Expand DownExpand Up@@ -290,9 +316,14 @@ def test_retrieve_chunks_never_reaches_transport_in_function_mode(monkeypatch, c

def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypatch, capsys):
"""Same proof as above for the batch/document embed path used by document
upload indexing — the deterministic no-op is count-with-embedding=None,
same shape as test_index_document_chunks_handles_embedding_failure, but
now reached by the mode gate rather than a real API error."""
upload indexing: the mode gate stops it before google-genai's transport.

The deterministic no-op is now "write nothing, report 0". It used to be
"write rows with embedding=None and report the full count" — but in
function mode no embedding can be produced, so those rows were dead on
arrival: unretrievable by construction, and flagged by the `ragstore`
oracle. What this test guards is the absence of egress, which is unchanged.
"""
monkeypatch.setenv("SAPLING_MODEL_MODE", "function")
from services.rag_service import index_document_chunks

Expand All@@ -305,9 +336,8 @@ def test_index_document_chunks_never_reaches_transport_in_function_mode(monkeypa
chunks=["chunk one", "chunk two"],
)

assert count == 2
upsert_records = mock_table.return_value.upsert.call_args[0][0]
assert all(rec["embedding"] is None for rec in upsert_records)
assert count == 0
mock_table.return_value.upsert.assert_not_called()
captured = capsys.readouterr()
assert "unstubbed LLM egress" not in captured.out
assert "SAPLING_MODEL_MODE" in captured.out
Expand Down
Loading