Uh oh!
There was an error while loading. Please reload this page.
feat(documents): file-level dedup — skip OCR, agents, and re-indexing on duplicate uploads - #507
feat(documents): file-level dedup — skip OCR, agents, and re-indexing on duplicate uploads#507Darkest-Teddy wants to merge 15 commits into
Conversation
…cate uploads The RAG corpus is shared per course, so the same lecture deck arrives from many students under many different filenames. `rag_service.chunk_id` already collapses identical passages to one row, but only at the END of the pipeline: OCR and the embedding batch are both paid for first, and the duplicate chunks are then upserted onto rows that already exist. ADR 0019 claims embedding spend as the main win of content-addressed ids; as written the code never delivered it, because nothing checks for an existing chunk before embedding. Catch the duplicate at the door instead, keyed on sha256 of the raw uploaded bytes. The fingerprint covers file contents ONLY, never the filename, so `lec3.pdf` and `Lecture 3 Slides.pdf` are recognised as the same upload. Two scopes, for different reasons: - Text reuse is GLOBAL. Extraction is a pure function of the bytes, so a twin from any course is a valid source. Skips OCR, the slowest step on the path. - The indexing skip is COURSE-SCOPED. Chunk ids hash the course code, so the same file uploaded to a different course genuinely needs its own embeddings; skipping there would leave that course with no retrievable material. Each uploader still gets their own documents row. The dedup targets the shared, expensive layer (OCR + embeddings), not the personal library. Deliberately NOT skipped: the classifier/summary/concepts agents still run. Their output drives per-student side effects that are not stored on the row — `_save_orchestrator_syllabus` writes calendar assignments from `result.syllabus.assignments`, which no column carries. Reusing a twin there would silently drop the second student's calendar population. The column is nullable and non-unique by design: pre-existing rows have no fingerprint and simply do not participate until re-uploaded, and the same file legitimately recurs once per uploader and once per course. `find_duplicate` degrades to "no duplicate" if the column is absent, so the code can ship ahead of the migration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ary dedup Calendar assignments are read off `result.syllabus.assignments`, and no column on the documents row stores them — so unlike category/summary/concept_notes, they cannot be reconstructed from a deduplicated twin. That makes the syllabus path the one place the dedup optimisation must not reach. Without a guard, a future agent-skipping branch would silently leave the second student to upload a given syllabus with an empty calendar: no error, no log line, and nothing in the stored document to show anything was lost. This test pins the separation from the library side: a duplicate syllabus upload still skips OCR (safe for every category) but still runs the agents, so save_assignments_to_db is still called. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the file-level dedup started in 0042. That change skipped OCR and re-indexing on a byte-identical upload; the agents still ran. They are pure functions of the extracted text — classifier, summary, concepts and syllabus all carry static system prompts and no user context — so on a duplicate they re-derive a result that is already known. Rebuilding a result from the columns already on the row is not possible: Summary.headline and Summary.key_points (min_length=3) are not stored, Concept.importance is not stored, and syllabus.assignments — the calendar import's only source — is stored nowhere at all. Reconstructing would mean inventing those fields. So persist the whole DocumentProcessingResult as encrypted JSON (0043) and replay it. One column round-trips losslessly through pydantic, verified including date-typed due_dates, and covers syllabus assignments and grading categories for free. This makes syllabus duplicates safe to short-circuit, which the previous commit deliberately would not do. The calendar write rides on the REPLAYED result and takes the uploader's user_id, so the second student to upload a syllabus gets their own assignments — the per-student side effects (_save_orchestrator_syllabus, _graph_backstop, apply_concepts_to_graph) all still fire, they just no longer need a fresh agent run to feed them. Both upload routes are covered. The streaming route's parallel workers move into _run_document_workers so the replay path can bypass them wholesale; the client-visible SSE event sequence is unchanged, so a replayed upload is indistinguishable apart from latency. decode_result treats a missing payload and one that no longer validates identically: fall back to running the agents. Model drift degrades to the old behaviour rather than failing an upload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main landed 0042_assignments_source_gradescope.sql while this branch was open, so both sides had claimed 0042. Migrations are append-only and applied in filename order, so two files sharing a number is not a cosmetic clash — it makes the ledger ambiguous about what ran. Renumbered file_sha256 to 0043 and agent_result to 0044, and fixed the cross-reference in 0044's header. No SQL changed. Anyone who applied the pre-rebase numbering locally has stale 0042 rows in their migration ledger; the columns themselves are identical, so re-running db.migrate against a fresh database is the clean path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Warning Review limit reached
Next review available in:8 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughThis change adds SHA-256 document deduplication for synchronous and streaming uploads. It persists extracted text and encrypted pipeline results, replays reusable results, preserves per-user effects, and skips duplicate course chunk indexing. ChangesDocument deduplication
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant UploadEndpoint
participant document_dedup
participant DocumentsDB
participant AgentWorkers
participant SSEClient
UploadEndpoint->>document_dedup: compute file_sha256
document_dedup->>DocumentsDB: find_duplicate
DocumentsDB-->>document_dedup: reusable document and stored result
document_dedup-->>UploadEndpoint: extracted text and replay data
UploadEndpoint->>AgentWorkers: invoke agents when replay data is unavailable
AgentWorkers-->>UploadEndpoint: processing outputs
UploadEndpoint-->>SSEClient: preserve streaming event sequence
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Deploying with |
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs | frontend-staging | 79f726b | Commit Preview URL Branch Preview URL | Aug 19 2026, 09:13 PM |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
backend/tests/test_document_dedup.py (1)
41-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a test for multiple twin candidates.
TestFindDuplicateonly ever mocks a single returned row. Add a case whereselectreturns two rows for the samefile_sha256— one withextracted_textpopulated and one without — to verifyfind_duplicatereliably selects the usable row rather than depending on incidental result order. This directly guards the ordering concern raised onservices/document_dedup.pylines 99-124.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/tests/test_document_dedup.py` around lines 41 - 118, Add a TestFindDuplicate case where the mocked select returns two matching rows in both orders: one with extracted_text populated and one without. Assert find_duplicate("cafe1234") returns the usable row in each order, verifying selection does not depend on result ordering while preserving the existing incomplete-twin behavior.backend/tests/test_documents_routes.py (1)
1520-1557: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd coverage for the chunk-indexing skip and its effect on
extracted_text.This test asserts no agent is called and the SSE step sequence is unchanged, but it does not assert whether
_index_document_chunksruns or whether the persisted document row retainsextracted_text. Sinceself._TWIN["offering_id"]is"off-original"and this upload targetscourse_id="c-1", this specific test likely exercises the cross-course path, not the same-offering skip path. Add a variant where the twin'soffering_idmatches the upload's resolved offering, and assert on the persisted row'sextracted_text, to catch the gap raised onbackend/routes/documents.pylines 1057-1083.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/tests/test_documents_routes.py` around lines 1520 - 1557, Extend test_streaming_duplicate_replays_without_calling_any_agent with a same-offering duplicate variant by making the twin offering_id match the upload’s resolved offering. Mock and assert _index_document_chunks is skipped, then inspect the row passed to the mocked table insert and verify extracted_text retains the duplicate’s stored text while preserving the existing SSE sequence and no-agent assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/migrations/0043_documents_file_sha256.sql`:
- Around line 27-32: Update the migration handling for idx_documents_file_sha256
so the partial index is created with CONCURRENTLY and executes outside the
transaction wrapper used by backend/db/migrate.py. Split or otherwise mark the
CREATE INDEX statement for non-transactional execution while preserving its
existing partial condition and IF NOT EXISTS behavior.
In `@backend/routes/documents.py`:
- Around line 1057-1083: Duplicate detection loses future matches because
extracted_text is only saved during chunk indexing. In
backend/routes/documents.py lines 1057-1083, update _persist_document to persist
encrypted extracted_text directly so rows that skip _index_document_chunks
retain it; in backend/services/document_dedup.py lines 99-124, update
find_duplicate’s limit-one query with an explicit ordering that prefers rows
where extracted_text is non-null.
---
Nitpick comments:
In `@backend/tests/test_document_dedup.py`:
- Around line 41-118: Add a TestFindDuplicate case where the mocked select
returns two matching rows in both orders: one with extracted_text populated and
one without. Assert find_duplicate("cafe1234") returns the usable row in each
order, verifying selection does not depend on result ordering while preserving
the existing incomplete-twin behavior.
In `@backend/tests/test_documents_routes.py`:
- Around line 1520-1557: Extend
test_streaming_duplicate_replays_without_calling_any_agent with a same-offering
duplicate variant by making the twin offering_id match the upload’s resolved
offering. Mock and assert _index_document_chunks is skipped, then inspect the
row passed to the mocked table insert and verify extracted_text retains the
duplicate’s stored text while preserving the existing SSE sequence and no-agent
assertions.
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 706f26a5-424e-4600-90d1-355844b0d103
📒 Files selected for processing (6)
backend/db/migrations/0043_documents_file_sha256.sqlbackend/db/migrations/0044_documents_agent_result.sqlbackend/routes/documents.pybackend/services/document_dedup.pybackend/tests/test_document_dedup.pybackend/tests/test_documents_routes.py
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Found by running the real app, not by the suite: file-level dedup never fired
on /upload/sync.
find_duplicate deliberately refuses a twin with no extracted_text — reusing
one would skip OCR and leave the new document empty. But extracted_text was
written only by _index_document_chunks, which runs as a post-roll task on the
STREAMING route. /upload/sync never indexes, so it persisted rows with
file_sha256 set and extracted_text NULL. The lookup found those rows and then
rejected them, so every sync upload re-ran OCR and all four agents.
The route tests missed it because they assert on the insert payload and on
which collaborators were called; nothing tied the column a WRITE produces to
the column the READ requires. The live check caught it in one upload.
_persist_document now writes extracted_text for both routes, which is what
migration 0030 intended ("store raw OCR-extracted text on each document row").
_index_document_chunks still writes it on the streaming path; the value is
identical, so the duplicate write is harmless.
Verified end to end against a local stack, uploading the same PDF twice under
different filenames:
upload 1 36.4s 4 LLM calls
upload 2 7.1s 0 LLM calls, OCR skipped, result replayed
Both uploaders keep their own documents row and the library returns both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Darkest-Teddy
commented
Aug 1, 2026
Second collision on this branch. After main took 0042, these were renumbered to 0043/0044 — but the unpushed feat/gamification-xp-achievements branch already holds 0043_gamification.sql and 0044_achievement_catalog.sql, and it is actively in progress (last commit 13 minutes after this branch's, and it has merged current main). Renumbered this side rather than that one: that branch is live in the shared working tree, so rewriting it would collide with work in flight. Numbering carries no meaning, so the branch that can move safely is the one that moves. 0045/0046 are clear of both main and gamification's current tips. No SQL changed; only the filenames and their header cross-references. Note this leaves the pair adjacent to gamification's, so if that branch adds further migrations before either merges, it will need to skip past 0046. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Darkest-Teddy
commented
Aug 1, 2026
…et (#509) Sequential migration numbers are claimed when a branch is WRITTEN but only validated when it MERGES, so concurrent branches routinely pick the same one. PR #507 hit this twice in a single branch lifetime: first against main's 0042, then against an unpushed branch already holding 0043/0044 — invisible on GitHub, and only found because both had been applied to the same local database. New migrations now use a UTC timestamp prefix (YYYYMMDDHHMMSS_description.sql, `date -u +%Y%m%d%H%M%S`). There is no shared counter, so two branches would have to be created in the same second to collide. THE 45 EXISTING FILES ARE NOT RENAMED, AND MUST NEVER BE. `schema_migrations.filename` is the ledger's primary key and `pending_migrations` treats an unrecorded basename as unapplied, so renaming an applied migration makes the runner apply it AGAIN. 0021_gradebook.sql DROPs and re-CREATEs the assignments table — a bulk rename would destroy the gradebook on every environment that has already run it. The two conventions coexist permanently. Ordering holds, but for a narrower reason than "timestamps are longer": comparison is character-by-character, so length decides nothing — a year-1000 timestamp would sort BEFORE a 9999_ prefix. What actually holds is that every legacy file starts with "0" and every timestamp this millennium starts with "2". A test pins that reason, counter-example included, so the next reader does not re-derive the wrong one. (An initial version of this change asserted the length-based claim; its own boundary test falsified it.) Enforcement is a test, not a note: test_migration_naming.py fails if a new NNNN_ file appears. The existing prefix test in test_migrations.py had to be relaxed to accept both shapes — it would otherwise reject every timestamped migration. Full suite: 1542 passed, 38 skipped. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 2 file(s) based on 1 unresolved review comment. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
…able twins Two follow-ups from running the feature against the real app and from PR review. 1. The streaming route logged "reusing extracted text" for a duplicate but never said whether the AGENTS were skipped. The two savings are independent -- a twin written before the agent_result column reuses the text but still pays for all four agents -- and neither is visible in the event stream, since a replay emits the same nine SSE steps as a fresh upload. The only way to tell the cases apart was counting generateContent calls in the httpx log. Both now say which happened. (/upload/sync already had this line; the streaming route is the one the frontend actually uses.) 2. find_duplicate ran LIMIT 1 with no ORDER BY, so a row with no extracted text could come back while a usable twin sat behind it -- and the post-fetch check would then report "no duplicate" for a file that plainly has one. Filter it in the query instead: whichever single row comes back is usable by construction, in any order. Scanning client-side would not have helped -- LIMIT 1 means the database only ever sends one row. Verified end to end against the live stack, two students uploading byte-identical files under different names to the same course: 33.3s -> 1.5s, 5 generateContent calls -> 0, 12 chunks indexed -> 0, both students still get their own row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts 81037dd, which switched 0045's index to CREATE INDEX CONCURRENTLY and taught db/migrate.py to run such statements outside the transaction. The goal is reasonable; this implementation breaks the migration runner for the whole repo. _split_statements splits on every semicolon, including those inside dollar-quoted bodies. 11 of the 46 migrations contain a DO block or a function body with at least one internal semicolon, and each is cut in half at that point -- verified by running the new splitter over every file in db/migrations: 0001_baseline_schema.sql 2/58 statements with an unbalanced $$ 0009_cosmetics.sql 2/15 0012_gradebook.sql 2/8 0019_conventions_terms... 2/8 0019_gradebook_drops.sql 2/4 0020_gradescope.sql 2/10 0021_gradebook_curve.sql 2/5 0027_gradescope.sql 2/11 0033_realtime_publish... 2/3 0039_rag_vector_store.sql 2/7 0040_room_message_image... 2/4 A migrate from an empty database now fails on 0001, the baseline schema. Nothing caught it: tests/test_migrations.py pins filenames and apply ORDER, never execution, and every existing environment has these migrations already recorded in schema_migrations, so the runner never re-reads them. Atomicity regresses too. Committing before switching to autocommit means a failed CONCURRENTLY leaves the migration's earlier statements applied but the file unrecorded -- and a failed concurrent build leaves an INVALID index that the statement's own IF NOT EXISTS then skips on every retry, so the index stays permanently invalid. Reverting is also the status quo, not a regression: 17 migrations create an index and none use CONCURRENTLY, and `documents` is small enough that the plain lock is not a concern yet. Doing this properly needs a real statement splitter (or a per-file "non-transactional" marker) plus execution coverage, which is its own change rather than a rider on a dedup PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
backend/tests/test_documents_routes.py (1)
1559-1604: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMock all three agents for full isolation in the same-course dedup test.
This test patches only
classifier_agent.run(Line 1577) and assertscls_run.assert_not_called()(Line 1595). It leavessummary_agent.runandconcept_extraction_agent.rununpatched. The sibling testtest_streaming_duplicate_replays_without_calling_any_agent(Lines 1526-1551) and the new_stream_duphelper (Lines 1651-1661) both mock all three agents for the same "stored result replay" scenario.If a regression in the same-course replay path causes the agents to actually run, this test calls real, unmocked
summary_agent.runandconcept_extraction_agent.run. That produces a network call attempt or an unrelated exception, not a clear assertion failure. The test's own assertions also do not verify that the summary and concept agents are skipped, so a regression there would go undetected here.Add the two missing patches and assertions to match the established pattern in this file.
🧪 Proposed fix to mock all agents and verify none run
with ( _mock_validate_user(), patch("routes.documents.extract_text_from_file") as extract, patch("routes.documents.find_duplicate", return_value=twin), patch("routes.documents.resolve_offering", return_value="off-same"), patch("routes.documents.classifier_agent.run", AsyncMock()) as cls_run, + patch("routes.documents.summary_agent.run", AsyncMock()) as sum_run,+ patch("routes.documents.concept_extraction_agent.run", AsyncMock()) as cpt_run, patch("routes.documents.apply_concepts_to_graph", AsyncMock(return_value=0)), patch("routes.documents.table") as t, patch("routes.documents._spawn_post_roll") as post_roll, ):extract.assert_not_called() cls_run.assert_not_called() + sum_run.assert_not_called()+ cpt_run.assert_not_called()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/tests/test_documents_routes.py` around lines 1559 - 1604, Update test_streaming_duplicate_in_the_same_course_skips_the_chunk_index to patch routes.documents.summary_agent.run and routes.documents.concept_extraction_agent.run alongside classifier_agent.run, using AsyncMock instances. Add assertions that both new mocks are not called, matching the established stored-result replay tests while preserving the existing behavior checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/db/migrations/0045_documents_file_sha256.sql`:
- Line 1: Rename backend/db/migrations/0045_documents_file_sha256.sql to a
unique UTC timestamp-prefixed filename while preserving its order before the
agent-result migration; likewise rename
backend/db/migrations/0046_documents_agent_result.sql to a unique UTC
timestamp-prefixed filename ordered after the fingerprint migration, using the
YYYYMMDDHHMMSS_description.sql format.
---
Nitpick comments:
In `@backend/tests/test_documents_routes.py`:
- Around line 1559-1604: Update
test_streaming_duplicate_in_the_same_course_skips_the_chunk_index to patch
routes.documents.summary_agent.run and
routes.documents.concept_extraction_agent.run alongside classifier_agent.run,
using AsyncMock instances. Add assertions that both new mocks are not called,
matching the established stored-result replay tests while preserving the
existing behavior checks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 7dd3b933-a153-4235-b3bb-6103fd372af2
📒 Files selected for processing (6)
backend/db/migrations/0045_documents_file_sha256.sqlbackend/db/migrations/0046_documents_agent_result.sqlbackend/routes/documents.pybackend/services/document_dedup.pybackend/tests/test_document_dedup.pybackend/tests/test_documents_routes.py
🚧 Files skipped from review as they are similar to previous changes (3)
- backend/services/document_dedup.py
- backend/tests/test_document_dedup.py
- backend/routes/documents.py
Uh oh!
There was an error while loading. Please reload this page.
…tion 0045/0046 -> 20260802012500_documents_file_sha256 and 20260802012600_documents_agent_result. Not cleanup — required. #509 froze the legacy NNNN_ set and tests/test_migration_naming.py pins the count, so merging main into this branch put it at 50 against an expected 48: AssertionError: expected 48 legacy NNNN_ migrations, found 50 which is exactly the collision the convention exists to prevent. These two files had already been renumbered twice on this branch (0043/0044, then 0045/0046) as other branches claimed the numbers first. Renaming is safe HERE specifically because these migrations have never been applied outside a local dev database. The ledger keys on basename, so a rename re-runs the file — which is why the 48 legacy names are frozen. Both of these are idempotent (ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS), and re-running them against the local stack under their new names applied cleanly with the dedup data intact. Suite: 1586 passed, 38 skipped. ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jose-Gael-Cruz-Lopez
commented
Aug 19, 2026
Code review — file-level document dedupThis PR fingerprints the raw uploaded bytes with SHA-256 and, on a hit, reuses the twin's Verdict on the two load-bearing questionsTenant scoping — NOT a cross-tenant leak. Reuse of failed/partial artifacts — mostly guarded, one hole. Other checks that came back clean: dedup adds no OOM/DoS surface ( FindingsP0[P0] Replay on # routes/documents.py:566-572def_graph_backstop(*, user_id: str, course_id: str, filename: str,
result: DocumentProcessingResult) ->None:
"""Apply graph update if the orchestrator skipped its tool call."""ifresult.graph_updated:
returnifresult.classification.categorynotin ("syllabus", "assignment"):
returnThe sync route has exactly one graph write path: This is a live path, not a legacy one: No test covers this — P1[P1] # document_dedup.py:79-81ifnottwin:
returnFalsereturnbool(offering_id) andtwin.get("offering_id") ==offering_idThe function never touches
The description names exactly this failure mode as the thing to avoid ("Skipping there would leave that course with no retrievable material"), so this is a gap against the PR's own invariant. A Secondary, same function: chunk ids hash the course code ( P2[P2] rows=table("documents").select(
_TWIN_COLUMNS,
filters={
"file_sha256": f"eq.{file_hash}",
"deleted_at": "is.null",
"extracted_text": "not.is.null",
},
limit=1,
)The inline comment correctly identifies the unordered- [P2] A permanently broken dedup lookup is invisible — exceptException:
logger.debug("file_sha256 duplicate lookup unavailable", exc_info=True)
returnNoneDegrading to "no duplicate" is right, but [P2] The insert-retry now silently discards the dedup columns on any insert error, unlogged — exceptException:
if"request_id"inrowor"file_sha256"inrow:
row.pop("request_id", None)
row.pop("file_sha256", None)
row.pop("agent_result", None)
inserted=table("documents").insert(row)The bare- [P2] New encrypted column
P3[P3] [P3] Untyped signatures — [P3] Description is stale about the migrations — the Changes table and the whole "Note for reviewers" paragraph describe What's good
Verdict: request changes — the P0 graph-merge loss on Review-only pass — no code changed and nothing fixed. Conventions checked against the Canopy |
_persist_document encrypts agent_result because it carries the summary, concept descriptions, and syllabus contents — but the manifest that enforces the encrypted-column set at rest on every lane run listed only summary / concept_notes / extracted_text, so a regression writing the new column in plaintext would have shipped undetected. The new test derives the expected set from the row _persist_document actually inserts (every value that decrypts is ciphertext at rest) rather than from a second hardcoded list, so the next encrypted column cannot be added without the manifest noticing.
…cally chunks_already_exist inferred "the chunks are in the shared corpus" from "a documents row with these bytes exists in this offering" and never touched course_chunks. Four ways that is false, and each one left a row that suppressed indexing for that material PERMANENTLY, because every later upload matched the same row: * /upload/sync persists a row (now with file_sha256 AND extracted_text, so it IS a twin) and never indexes anything; * _index_document_chunks swallows every exception; * it returns early on empty chunking and on the relevance gate, both after the row is written; * it is fire-and-forget, so a duplicate arriving seconds later sees the row before the task has run. So A uploads a syllabus via sync, B uploads the same bytes to the same course via the streaming route, indexing is skipped, and the course holds zero retrievable material forever — the exact failure this feature exists to avoid. It now queries course_chunks for the real chunk ids, scoped by COURSE CODE (what rag_service.chunk_id actually hashes, so two offerings of one course share their rows — the offering test both missed real reuse and claimed reuse the ids do not provide), and checks the first AND last chunk so a batch dropped mid-embed does not read as a complete index. A failed lookup degrades to "index it", the only safe direction. find_duplicate was an unordered LIMIT 1: a row with agent_result NULL could come back while a replayable one sat behind it, re-running all four agents for nothing. It now pulls a small ordered window and prefers a row with a stored result, then a same-offering row. decode_result clears graph_updated. It is run-scoped state — whether the ORIGINAL uploader's graph gained nodes — and serving it out of a content-addressed cache lets one student's merge suppress the next's, since _graph_backstop returns immediately when it is True. A broken lookup was invisible: the catch logged at debug, and None is also the normal answer, so a dropped column or sustained timeouts left dedup never firing while every upload looked healthy. Now a WARNING plus a countable document.dedup_lookup_failed event, matching what rag_service.retrieve_chunks does with the identical ambiguity (#482). Also drops category / summary / concept_notes from the lookup: three columns and a decrypt each (plus a decrypt_json round-trip) per upload for values no caller reads, and types decode_result's return.
…raph /upload/sync had NO graph write path on a duplicate. The route's only merge is process_document -> _step_apply_graph -> apply_concepts_to_graph, and the replay branch skips process_document entirely; _graph_backstop cannot cover it, because graph_updated arrives True from the twin's cached agent_result (the ORIGINAL uploader's run) and, even at False, the backstop is restricted to syllabus/assignment. This is a live path — the Gradebook syllabus flow posts here — so the second student to upload a shared syllabus silently lost their graph seeding. The replay branch now runs the same apply_concepts_to_graph call the streaming route makes, with THIS user_id, and recomputes graph_updated from the count it returns. Both branches share one try, so a graph failure on a replay lands on the same retry-friendly 502 as one on a fresh run. The chunk-reuse decision moves out of the route and into _index_document_chunks, which is the only place that knows the resolved course code and the real chunk list. The index task is now scheduled for every upload; see the dedup-service commit for why the twin's row could never answer that question. _persist_document's insert retry is narrowed to a missing-column failure and logs the fallback. The bare except also caught transient PostgREST errors and unrelated constraint violations, and then wrote a row with no fingerprint and no stored result — permanently invisible to dedup, with nothing to replay and nothing in the log to say so. Types _run_document_workers, and resolves the offering before the sync route's dedup lookup so the twin preference can use it. Tests: the sync replay merges for the new user id; a stale graph_updated cannot suppress it; the indexer skips the embed only when the corpus really holds the chunks; an unrelated insert error propagates instead of silently dropping the dedup columns. The same-offering streaming case now asserts the index IS scheduled and patches all three workers, so a regression fails an assertion instead of attempting a model call.
Jose-Gael-Cruz-Lopez
commented
Aug 19, 2026
Review fixes appliedEvery outstanding finding on this PR (human review + CodeRabbit) has been addressed and pushed. Blocker
Major
Minor
NitsUnread decrypts dropped · signatures typed · the three duplicate-path tests now mock all three agents and cover multi-candidate ordering and the same-offering skip. Verification — Fixes applied and verified locally against this branch head; each figure above is a command I ran, not an estimate. |
Jose-Gael-Cruz-Lopez
commented
Aug 19, 2026
Verify against the real database before mergingI could not reach a live database while working on this — there are no credentials on this machine (only These are the checks that need a real connection. 1. Both migrations before the codeSELECT column_name, is_nullable FROMinformation_schema.columnsWHERE table_name ='documents'AND column_name IN ('file_sha256','agent_result');
SELECT indexname, indexdef FROM pg_indexes
WHERE tablename ='documents'AND indexdef ILIKE '%file_sha256%';Both columns nullable, and the partial index present. 2. Pre-existing rows are deliberately invisible to dedupSELECTcount(*) FILTER (WHERE file_sha256 IS NULL) AS no_fingerprint,
count(*) FILTER (WHERE file_sha256 IS NOT NULL) AS fingerprinted
FROM documents WHERE deleted_at IS NULL;Everything uploaded before this ships has 3. The chunk-existence check is the one that needs real dataThe review found Worth confirming against real rows that the skip can actually fire, i.e. that ids in SELECTcount(*) AS chunk_rows, count(DISTINCT course_id) AS courses FROM course_chunks;Then upload a known-duplicate file into a course that already has chunks and confirm the embedding batch is skipped. If ids ever drifted from the current hash format, the check degrades to "index it" (safe, just no saving) — that is the intended failure direction, but it would mean the feature never pays off. 4. Encryption at rest
Static verification only — no live database was reachable from this environment. Schema model built by replaying |
Why
Sapling's RAG corpus is shared per course, so the same lecture deck arrives from many students under many different filenames.
rag_service.chunk_idalready collapses identical passages to one row — but only at the end of the pipeline. By then OCR, the agent pipeline, and the embedding batch have all been paid for, and the duplicate chunks are simply upserted onto rows that already exist.ADR 0019 names embedding spend as the main win of content-addressed ids. As written the code never delivered it: nothing checks for an existing chunk before embedding.
This catches the duplicate at the door, keyed on
sha256of the raw uploaded bytes. The fingerprint covers file contents only, never the filename, solec3.pdfandLecture 3 Slides.pdfare recognised as the same upload.What a duplicate upload now costs
documentsrowDesign notes
Each uploader still gets their own
documentsrow. The dedup targets the shared, expensive layer — OCR and embeddings — not the personal library. A student whose upload silently vanished would read that as a bug.Two scopes, for different reasons. Text and agent-result reuse are global: extraction and the agents are pure functions of the bytes (static system prompts, no user context), so a twin from any course is valid. The indexing skip is course-scoped: chunk ids hash the course code, so the same file uploaded to a different course genuinely needs its own embeddings. Skipping there would leave that course with no retrievable material.
Why the whole result is persisted rather than a few columns. Rebuilding a
DocumentProcessingResultfrom the row is impossible without inventing data:Summary.headlineisn't stored,Summary.key_pointsisn't stored and requires at least 3 entries,Concept.importanceisn't stored, andsyllabus.assignments— the calendar import's only source — isn't stored anywhere. One encrypted JSON column round-trips losslessly (verified includingdate-typeddue_dates, ~430 bytes) and covers syllabus assignments and grading categories for free.Syllabus uploads are safe to short-circuit. The calendar write rides on the replayed result and takes the uploader's
user_id, so the second student to upload a syllabus gets their own assignments. All per-student side effects still fire —_save_orchestrator_syllabus,_graph_backstop,apply_concepts_to_graph— they just no longer need a fresh agent run to feed them. A test assertssave_assignments_to_dbis called with the new user id.Degrades rather than fails.
decode_resulttreats a missing payload and one that no longer validates identically: run the agents. Model drift falls back to today's behaviour instead of failing an upload. Both new columns are nullable, andfind_duplicatereturns "no duplicate" if the columns are absent, so the code can ship ahead of the migrations.Columns are non-unique by design — the same file legitimately recurs once per uploader and once per course.
Changes
services/document_dedup.pyfile_sha256,find_duplicate,decode_result,chunks_already_existdb/migrations/0043_documents_file_sha256.sqldb/migrations/0044_documents_agent_result.sqlroutes/documents.py_run_document_workerstests/test_document_dedup.pytests/test_documents_routes.pyThe streaming route's parallel workers moved into
_run_document_workersso the replay path can bypass them wholesale. The client-visible SSE event sequence is unchanged — a test pins the exact nine-step sequence — so a replayed upload is indistinguishable to the frontend apart from latency.Verification
ruff check services/ routes/ tests/cleanNote for reviewers
0043/0044were originally0042/0043; main landed0042_assignments_source_gradescope.sqlwhile this branch was open. Renumbered infe2f685— no SQL changed. Anyone who applied the earlier numbering locally has stale0042rows in their ledger; the columns are identical, so re-runningdb.migrateagainst a fresh database is the clean path.Not included
Near-duplicate detection (same material, different bytes — a re-export or re-scan) is out of scope. Neither a file hash nor a chunk hash catches it; that needs MinHash/SimHash or content-defined chunking, and belongs with the chunking overhaul that would change chunk boundaries anyway.
🤖 Generated with Claude Code
Summary by CodeRabbit