fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment
, '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

fix(backend): close the #136 contract-audit tail + #340 + #72 - #464

Merged
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail
Jul 30, 2026
Merged

fix(backend): close the #136 contract-audit tail + #340 + #72#464
AndresL230 merged 2 commits into
mainfrom
fix/b3-backend-contract-tail

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Bundle B3 of the backlog clear — the five remaining #136 audit findings plus the two same-shaped siblings, every claim re-verified against main @ 9b000b5 before touching code (all seven still present; line numbers in the issue bodies had drifted). Implemented as five parallel single-surface changes; full detail in the commit message.

Verification

  • Backend 1344 passed + ruff check . clean; frontend 258 passed, tsc clean, lint 0 errors.
  • Agent-adjacent files (tool rename, flashcard service) re-run green on the lock-pinned pydantic-ai 1.107 scratch venv.
  • 30+ new tests, each verified red against its bug first; journey added to quiz.spec.ts.
  • Full local e2e cycle queued (serialized behind the in-flight B2 cycle on the stack lock); results will be posted below. Migration 0037 will be exercised by the cycle's from-seed boot.

Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Class Intel sharing toggle with account-level persistence and opt-out support.
    • Added stricter profile visibility controls for private and school-limited profiles.
    • Added an admin view for the newsletter allowlist.
  • Bug Fixes

    • Prevented duplicate quiz submissions and incorrect grading of malformed questions.
    • Improved document upload limits and streaming error handling.
    • Ensured feedback and issue reports use the authenticated account.
    • Improved handling of blocked flashcard content generation.
    • Standardized tutor tool behavior for more reliable chat interactions.

@coderabbitai

coderabbitaiBot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fab2f195-7a1c-4209-9ef4-dbfb3588d054

📥 Commits

Reviewing files that changed from the base of the PR and between c04b4d1 and 8f2694a.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx
📝 Walkthrough

Walkthrough

Changes

The PR fixes AI tool naming, Class Intel preference persistence and filtering, profile privacy, document upload failure handling, feedback authentication, admin allowlist listing, quiz idempotency and grading, and flashcard content-filter error propagation. Backend, frontend, unit, integration, and E2E tests are updated accordingly.

Agent tool contracts

Layer / File(s)Summary
Prompt-facing tool registration
backend/agents/chat_tutor.py, backend/agents/note_chat.py
AI agents expose search_course_materials under the prompt-facing name and create fresh tutor tool instances per agent.
Tool-name contract validation
backend/tests/test_chat_stream.py, backend/tests/test_chat_tutor_imports.py, backend/tests/test_model_mode_seam.py
Tool-call and registration tests use the updated prompt-facing name.

Class context and profile privacy

Layer / File(s)Summary
Class Intel settings contract
backend/db/migrations/0037_share_class_context.sql, backend/models/__init__.py, backend/routes/profile.py
User settings persist and accept the optional share_class_context value.
Opt-out aggregation enforcement
backend/services/course_context_service.py, backend/tests/test_shared_course_context.py
Opted-out users are excluded from aggregation; missing settings default to opted in, and empty cohorts purge aggregates.
Viewer-aware profile visibility
backend/routes/profile.py, backend/tests/test_profile_routes.py
Profile visibility now considers owners, school peers, strangers, and anonymous viewers.
Frontend preference synchronization
frontend/src/components/SharedContextToggle.tsx, frontend/src/components/SharedContextToggle.test.tsx
The toggle synchronizes server settings with local state and localStorage while handling failed updates.

Document upload pipeline

Layer / File(s)Summary
Upload persistence and SSE flow
backend/routes/documents.py
Upload limits use MAX_FILE_SIZE, synchronous persistence runs in threads, and post-result failures emit terminal SSE events without fallback reruns.
Upload behavior validation
backend/tests/test_documents_routes.py
Tests cover computed size-limit messages and single-result SSE failure behavior.

Authentication and admin allowlist

Layer / File(s)Summary
Admin allowlist listing
backend/routes/admin.py, backend/tests/test_admin_routes.py
Admins can list newsletter allowlist records in newest-first order.
Authenticated feedback attribution
backend/routes/feedback.py, backend/tests/test_feedback_routes.py
Feedback and issue reports require authentication and use the session-derived user id.

Quiz submission correctness

Layer / File(s)Summary
Submission idempotency and grading
backend/routes/quiz.py, backend/tests/test_quiz_routes.py
Completed submissions return 409, and malformed questions without correct options cannot earn points.
Replay side-effect validation
frontend/e2e/quiz.spec.ts
The E2E test verifies replayed submissions do not add mastery events or change mastery scores.

Flashcard content-filter errors

Layer / File(s)Summary
Content-filter exception handling
backend/services/flashcard_import_service.py, backend/tests/test_flashcard_import_service.py, backend/tests/test_flashcard_import_routes.py
Content-filter failures propagate through service methods and produce a 502 response from the route.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant UploadRoute
participant DocumentPipeline
participant Persistence
Client->>UploadRoute: upload document
UploadRoute->>DocumentPipeline: stream processing result
DocumentPipeline->>Persistence: persist document and graph updates
Persistence-->>DocumentPipeline: success or failure
DocumentPipeline-->>Client: done or error and done
Loading

Possibly related PRs

Suggested reviewers:jose-gael-cruz-lopez, darkest-teddy

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check nameStatusExplanationResolution
Linked Issues check⚠️ WarningMost linked fixes are covered, but the summary doesn't show the empty-OCR fallback for #132 or the remaining #135 subissues.Add the missing #132 empty-OCR fallback and the remaining #135 fixes (study guide, notes, createNote, graph floaters), or mark them out of scope.
Docstring Coverage⚠️ WarningDocstring coverage is 32.89% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and points to the main backend audit fixes, though it uses umbrella issue phrasing.
Description check✅ PassedThe description covers the PR summary, issue mapping, and verification, but it uses custom headings instead of the template.
Out of Scope Changes check✅ PassedThe changes stay focused on the listed backend/frontend fixes and their tests; no unrelated scope creep stands out.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/b3-backend-contract-tail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

"""Integration tests for /api/flashcards/import/* routes."""
import base64
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging8f2694aCommit Preview URL

Branch Preview URL
Jul 30 2026, 05:39 AM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
backend/tests/test_flashcard_import_routes.py (1)

205-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize flashcard agent content-filter mocks through the shared gemini fixture.

backend/tests/conftest.py only has the hard BaseApiClient transport guard; the ContentFilterError behavior still needs an explicit shared fixture/parameterized helper so route::test_content_filter_block_returns_502, service::test_content_filter_propagates generation, and cleanup content-filter smoke cases don’t each define a separate flashcard_agent.run monkeypatch.

🤖 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_flashcard_import_routes.py` around lines 205 - 207,
Centralize the ContentFilterError behavior in a shared gemini fixture or
parameterized helper in backend/tests/conftest.py, then update the flashcard
agent run mocks in backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.

Source: Path instructions

🤖 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/services/flashcard_import_service.py`:
- Around line 271-276: Update the ContentFilterError handler in the flashcard
import flow to log only a sanitized event/category, replacing logger.exception
so UnexpectedModelBehavior.body and other raw provider details are not emitted.
Preserve the existing re-raise behavior so the route’s 502 handling remains
unchanged.
In `@frontend/src/components/SharedContextToggle.tsx`:
- Around line 18-70: Reset or re-scope dirtyRef in useSharedContext whenever
userId changes, so a toggle by one user does not suppress server hydration for
another user. Ensure the new user’s fetchSettings result can update enabled and
localStorage unless that same user has toggled locally.
---
Nitpick comments:
In `@backend/tests/test_flashcard_import_routes.py`:
- Around line 205-207: Centralize the ContentFilterError behavior in a shared
gemini fixture or parameterized helper in backend/tests/conftest.py, then update
the flashcard agent run mocks in
backend/tests/test_flashcard_import_routes.py:205-207 and
backend/tests/test_flashcard_import_service.py:320 and :397 to use it instead of
defining separate flashcard_agent.run monkeypatches; preserve the existing
route, service-generation, and cleanup smoke-test expectations.
🪄 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: 732941d8-6d0b-401e-a8af-976530a519e3

📥 Commits

Reviewing files that changed from the base of the PR and between 9b000b5 and c04b4d1.

📒 Files selected for processing (25)
  • backend/agents/chat_tutor.py
  • backend/agents/note_chat.py
  • backend/db/migrations/0037_share_class_context.sql
  • backend/models/__init__.py
  • backend/routes/admin.py
  • backend/routes/documents.py
  • backend/routes/feedback.py
  • backend/routes/profile.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/flashcard_import_service.py
  • backend/tests/test_admin_routes.py
  • backend/tests/test_chat_stream.py
  • backend/tests/test_chat_tutor_imports.py
  • backend/tests/test_documents_routes.py
  • backend/tests/test_feedback_routes.py
  • backend/tests/test_flashcard_import_routes.py
  • backend/tests/test_flashcard_import_service.py
  • backend/tests/test_model_mode_seam.py
  • backend/tests/test_profile_routes.py
  • backend/tests/test_quiz_routes.py
  • backend/tests/test_shared_course_context.py
  • frontend/e2e/quiz.spec.ts
  • frontend/src/components/SharedContextToggle.test.tsx
  • frontend/src/components/SharedContextToggle.tsx

Comment threadbackend/services/flashcard_import_service.py
Comment on lines 18 to 70
export function useSharedContext(): [boolean, (v: boolean) => void] {
const { userId, userReady } = useUser();
const [enabled, setEnabled] = useState(true);
// Once the user toggles locally, a late-arriving server hydration must not
// clobber their fresh choice.
const dirtyRef = useRef(false);

useEffect(() => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "false") setEnabled(false);
}, []);

// Best-effort server hydration (#72): the preference persists on
// user_settings so it follows the user across devices and gates the WRITE
// path server-side. On any failure — offline, signed out, or a server that
// does not serve the column yet — the localStorage value above stands.
useEffect(() => {
if (!userReady || !userId) return;
let cancelled = false;
fetchSettings(userId)
.then((settings) => {
if (cancelled || dirtyRef.current) return;
const server = (settings as ShareClassContextSettings).share_class_context;
if (typeof server === "boolean") {
setEnabled(server);
localStorage.setItem(STORAGE_KEY, String(server));
}
})
.catch(() => {
/* keep the localStorage value */
});
return () => {
cancelled = true;
};
}, [userReady, userId]);

const update = (v: boolean) => {
dirtyRef.current = true;
setEnabled(v);
localStorage.setItem(STORAGE_KEY, String(v));
// Best-effort write-through (#72). Swallow failures: the local toggle
// still gates this client's read path when offline or when the server
// does not accept the field yet.
if (userId) {
const patch: ShareClassContextSettings = { share_class_context: v };
updateSettings(userId, patch).catch((err) => {
console.warn(
"Failed to persist share_class_context; toggle applied locally only",
err,
);
});
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate file =="
fd -a 'SharedContextToggle\.tsx$'.||trueecho"== git diff stat =="
git diff --stat ||trueecho"== outline =="
ast-grep outline frontend/src/components/SharedContextToggle.tsx --view expanded ||trueecho"== source lines =="
cat -n frontend/src/components/SharedContextToggle.tsx | sed -n '1,140p'echo"== related files/usages =="
rg -n "useSharedContext|SharedContextToggle|share_class_context|fetchSettings|updateSettings|useUser" -S frontend/src ||true

Repository: SaplingLearn/Sapling

Length of output: 19062


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== locate login/user/account switch references =="
rg -n "logout|sign(out|out)|sign in|login|account|userId|userReady|App|Router|routes|route|useRoute|useLocation|useNavigate|navigate\\(" -S frontend/src ||trueecho"== files =="
git ls-files frontend/src | sed -n '1,240p'

Repository: SaplingLearn/Sapling

Length of output: 50376


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== UserContext outline/sections =="
wc -l frontend/src/context/UserContext.tsx
cat -n frontend/src/context/UserContext.tsx | sed -n '1,260p'echo"== Learn screen hook sections =="
wc -l frontend/src/components/screens/Learn.tsx
ast-grep outline frontend/src/components/screens/Learn.tsx --view expanded ||true
cat -n frontend/src/components/screens/Learn.tsx | sed -n '320,350p'
cat -n frontend/src/components/screens/Learn.tsx | sed -n '1205,1232p'
cat -n/frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'2>/dev/null || cat -n frontend/src/components/screens/Learn.tsx | sed -n '1270,1285p'echo"== route files around learn auth check =="
fd -a 'page\.tsx$|layout\.tsx$' frontend/src/app | rg '/app/.*/|app/'||true
rg -n "useSharedContext|SharedContextToggle|userReady|isAuthenticated|signOut|router.replace|router.push|onboarding|dashboard" frontend/src/app frontend/src/components/screens/Learn.tsx frontend/src/context/UserContext.tsx -S
echo"== app shell/public auth-related snippets =="forfin frontend/src/app/\(public\)/page.tsx frontend/src/app/\(shell\)/page.tsx frontend/src/app/\(shell\)/notetaker/page.tsx frontend/src/components/screens/Learn.tsx;do
[ -f"$f" ] && { echo"--- $f"; cat -n "$f"| sed -n '1,140p'; }
done

Repository: SaplingLearn/Sapling

Length of output: 42699


Reset the dirty flag for each user.

update() makes dirtyRef.current permanent for the component instance, so after user A toggles it once, user B’s later server hydration can be skipped silently and B may see A’s stored preference instead of their own share_class_context. Reset the dirty flag when userId changes, or scope it to the current user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/SharedContextToggle.tsx` around lines 18 - 70, Reset
or re-scope dirtyRef in useSharedContext whenever userId changes, so a toggle by
one user does not suppress server hydration for another user. Ensure the new
user’s fetchSettings result can update enabled and localStorage unless that same
user has toggled locally.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Review pass complete: 5-agent review + confidence scoring produced two findings, both at 75 (below the 80 posting bar) and both fixed anyway in the commit above — (1) the #129 guard was check-then-act under concurrency; the gate is now an atomic conditional update (completed_at is.null), loser 409s before any mastery write; (2) flipping share_class_context now schedules update_course_context for every enrolled offering, so opted-out data leaves the aggregates immediately rather than at the next classmate-triggered refresh. Backend 1348 passed + ruff clean. Full e2e cycle queued behind the in-flight B2 cycle.

AndresL230 added a commit that referenced this pull request Jul 29, 2026
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from typing import Optional

from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, UploadFile, File, Query
AndresL230and others added 2 commits July 29, 2026 22:36
#129#132#135)
Seven verified-still-present findings, each fixed TDD red-first:
- #130: GET /api/admin/allowlist existed only in the frontend client —
added the admin-gated listing over newsletter_emails (id,email,
created_at,approved_at desc), matching the AllowlistEmail shape.
- #134: submit_feedback/submit_issue_report now derive user_id from the
session (401 unauthenticated; body.user_id accepted but never trusted).
get_public_profile now resolves the viewer: private profiles return a
minimal stub to non-owners (name/majors/minors/year/school no longer
leak), and the 0031 'school' tier — previously identical to public —
reveals extended fields only to same-school viewers (academics
school-peer resolver, fail-closed). Owners always see everything.
- #129: resubmitting a completed quiz now 409s before any scoring — no
re-applied mastery, no duplicate node_mastery_events, no achievement
re-fire (409 over replay: quiz_attempts stores no mastery_before/after).
Malformed items (no correct option) can no longer match a missing answer
('' == '') for a free point. Lane journey pins the 409 + single-event
contract by replaying the captured wire body via page.request.
- #132 remainder: the streaming upload's post-result persistence block is
now its own try — a failure after the result event yields one terminal
error, never a second result from a second (billed) legacy run; the
streaming size-limit 400 said '15 MB' with a 100 MB cap — both routes
now derive the message from MAX_FILE_SIZE; the remaining synchronous
PostgREST calls in BOTH async upload paths moved to asyncio.to_thread.
- #135: note_chat + chat_tutor register the retrieval tool under the
prompt-facing name search_course_materials (explicit Tool(name=…), fresh
instances per agent); seam/stream/import tests updated with the wire
rename; evaluator semantics preserved (verified, no eval changes).
- #340 (P1): ContentFilterError (a UnexpectedModelBehavior subclass) no
longer degrades to [] — content-filter blocks propagate to the routes'
502 instead of a 200 {"cards": []} the UI toasts as success.
- #72: Class Intel opt-out is now a persisted preference —
user_settings.share_class_context (migration 0037, default true),
PATCHable via the settings whitelist, honored at the single write
chokepoint (update_course_context filters aggregation to opted-in users;
all-opted-out purges the aggregates). SharedContextToggle keeps its
hook/localStorage API and adds best-effort server persistence +
hydration.
Suites: backend 1344 passed + ruff clean; frontend 258 passed + tsc +
lint clean; agent-adjacent files re-verified green under the lock-pinned
pydantic-ai 1.107.
Closes#130. Closes#134. Closes#129. Closes#132. Closes#135. Closes#340. Closes#72.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…red aggregate refresh
- #129 hardening: the completed_at pre-read was check-then-act — two
concurrent submits (double-click) both passed it and double-applied
mastery. The gate is now an atomic conditional update (completed_at
is.null) whose loser 409s before any mastery write; the final update no
longer re-stamps completed_at. Quiz test fixtures updated to model
PostgREST's return=representation (a matched update returns rows); two
new tests pin the race loser and the is.null claim idiom.
- #72 completeness: PATCHing share_class_context now schedules
update_course_context for each of the user's enrolled offerings
(deduped, BackgroundTasks) — opted-out data drops out of the aggregates
immediately instead of lingering until a classmate's activity fires the
next refresh. Tests pin per-offering scheduling and that other settings
don't trigger it.
Backend 1348 passed, ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230force-pushed the fix/b3-backend-contract-tail branch from 37ecc92 to 8f2694aCompareJuly 30, 2026 05:37
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Pre-merge e2e gate: full lane 20/20 passed (including the new #129 resubmit journey; migration 0037 replayed in the stack's from-seed boot) + oracles clean (0 findings, 1 allowlisted). Merging.

@AndresL230
AndresL230 merged commit fe22c7f into mainJul 30, 2026
7 checks passed
@AndresL230
AndresL230 deleted the fix/b3-backend-contract-tail branch July 30, 2026 05:41
AndresL230 added a commit that referenced this pull request Jul 30, 2026
…f, validated procedure (#154) (#474)
* feat(durability): productionize DBOS — entrypoint wiring, resume proof, validated procedure (#154)
ADR 0011 shipped the durable shim off + unvalidated: main.py never
constructed/launched DBOS (the shim 'trusted' an init that didn't exist),
the DBOS_DATABASE_URL precondition was docstring-only, and nothing tested
either mode.
- services/durable.py: enforce the DATABASE_URL precondition at activation;
new init_dbos()/shutdown_dbos() — construct + DBOS.launch() from the
lifespan, fail-loud when the operator explicitly opted in (#174 posture).
Decorate-before-construct-before-launch order verified against
dbos==2.28.0's registry internals.
- main.py: init_dbos() after validate_config(), shutdown_dbos() on teardown.
- requirements-durable.txt: the opt-in extra ADR 0011 promised (dbos>=2.28,<3),
never in requirements.txt/lock.
- tests/test_durable_shim.py: 11 hermetic tests over both modes and every
precondition combination (fake dbos module + reload, pristine restore).
- tests/test_dbos_resume.py: opt-in (RUN_DBOS_RESUME=1) subprocess crash/
resume proof — step1 runs EXACTLY once across an os._exit crash and the
workflow completes on relaunch recovery — plus process_document parity
under real DBOS. Exercised against dbos 2.28 + a real Postgres.
- test_documents_routes.py: streaming replay now also pins zero re-inserts
(the #132 crash-after-result scenario, with the #464 exactly-one-result
guard).
- ADR 0011 → accepted (shipped + validated, default off): corrected
activation procedure (launch() migrates + auto-recovers; 'dbos migrate'
was stale), resume monitoring (startup INFO line -> Logfire per #119,
workflow_status SQL), streaming-asymmetry reaffirmed.
Closes#154
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: make durability real for the product + fix fail-loud gap and doc claims
- /upload/sync now pins the DBOS workflow id to doc:{user_id}:{request_id}
(user-scoped — X-Request-ID is client-supplied), so a client retry
attaches to the SAME workflow: completed -> recorded result, crashed ->
resume at last completed step. Graph merge wrapped as _step_apply_graph
so resume never re-runs the one real side effect.
- init_dbos now RAISES on flag-on-but-preconditions-unmet (missing URL /
failed import) instead of silently degrading an explicit opt-in; shim
tests pin both raise paths.
- Doc corrections: pre-#154 flag-on raised DBOSException per call (502s),
not 'silently nothing'; step-outside-workflow runs the plain function on
dbos 2.28 (not undefined); only database_url is deprecated.
- New opt-in proof test_pipeline_crash_resume_via_workflow_id: real
process_document crash mid-_run_workers -> same-id retry -> classify
step runs EXACTLY once across both phases. All 3 resume tests exercised
green against dbos 2.28 + a real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…_responses, answer-key deprecation (#541) (#549)
* feat(quiz): server-authoritative grading — per-question answers, quiz_responses, answer-key deprecation (#541)
Workstream C of the pre-revamp quiz repair batch (epic #537):
C1 — POST /api/quiz/attempts/{attempt_id}/answer grades one question
server-side: owner check, 409 after completion, 400 QUIZ_QUESTION_INVALID
on out-of-range indexes, malformed items never grade correct (#129 rule).
Idempotent on (attempt_id, question_index): re-answering returns the
FIRST recorded response with recorded:false — no revision, decided and
documented for the #537 flow. Returns is_correct/correct_index/
explanation plus the next question stripped of the answer key.
C2 — quiz_responses table (migration 20260812214402): plaintext
analytics scalars only (indexes, boolean, time_ms, confidence), UNIQUE
(attempt_id, question_index) as the idempotency contract, FK cascade
with the attempt. Real-DB integration tests pin the UNIQUE arbitration
and the cascade.
C3 — include_answer_key on generate, default true so the current
QuizPanel keeps working; every keyed response logs a deprecation
breadcrumb; false strips per-option correct booleans from the response
while storage keeps them for grading. Removal tracked in #546; deleting
the key is a hard requirement of #537.
C4 — submit prefers recorded quiz_responses per question (a
contradicting payload answer is ignored — answer-time grades are the
source of truth) and falls back to the payload for questions never
answered through C1. The atomic completed_at claim (PR #464) and 409
behaviour are untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #549 review — persist the graded answers, guard the index/id mismatch, drop the redundant index
Review findings (xhigh, 4 confirmed; 5 reports collapsed to one root cause):
- submit now persists the RECONCILED answer set (recorded responses
winning over payload) instead of the raw request body. A recorded-only
submit previously stored a full score beside answers_json=[], and a
contradicted payload answer was stored despite losing to the recorded
response — the attempt record disagreed with its own score.
- The answer endpoint accepts an optional question_id and rejects a
mismatch with question_index, plus echoes both in the response: passing
the 1-based wire id as the 0-based index used to silently grade the
neighbouring question, which idempotency then locked in.
- quiz_responses drops the standalone attempt_id index — the UNIQUE's
btree already leads with attempt_id, so it only added a write to the
per-answer hot path.
- correct_index is resolved once per request instead of re-scanning the
options on every _graded call (it never depended on the answer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…ion, concurrency tests (#543) (#551)
* feat(quiz): mastery-model seam, honest delivered counts, wire validation, concurrency tests (#543)
Workstream E of the pre-revamp quiz repair batch (epic #537):
E1 — the mastery model is a named seam: services/quiz_config.py holds
MASTERY_DELTA_PER_CORRECT/_PER_WRONG plus mastery_after(), with the
pedagogy written down. THE NUMBERS DO NOT CHANGE — the #393 journey's
+0.09 is byte-identical and pinned by a new test. The options the revamp
gets to choose from (length normalization, difficulty weighting,
diminishing returns) are written up in docs/quiz-mastery-model.md,
including the constraint that any change updates the journey in the same
commit.
E2 — generation stops silently short-changing quizzes: the response
reports requested_count and delivered_count, and losing more than a
third of the requested questions to drift triggers ONE bounded top-up
run (a retry loop against a drifting model burns tokens without
converging). A failed top-up serves what we have; all-dropped still
502s.
E3 — wire-format validation at the route boundary: at least two options,
no duplicate option text (a student can otherwise pick "the same" answer
and be wrong), exactly one correct option, and no duplicate question
stems within one attempt.
E4 — concurrency tests: double-answer on one index (the UNIQUE
arbitrates; the loser re-reads instead of 500ing) and
generate-while-generating for one concept (distinct attempt rows). The
double-submit claim was already pinned by #464's tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #551 review — rekey the top-up on actual drops, stop over-rejecting, surface short quizzes
The review found the E2 top-up miskeyed at its core, verified by
execution. All ten findings addressed:
- The trigger keyed on requested-minus-delivered, conflating "we rejected
some" with "the agent returned fewer". The Quiz schema lets a run
return any count and the E2E seam always returns 3 against a UI default
of 5, so the top-up fired a second full generation on EVERY quiz
journey — double tokens and ~5s latency for zero extra questions. It
now counts questions actually DROPPED and gates on that.
- The top-up prompt said "different from the ones already asked" without
saying what they were, so a deterministic model re-emitted the same
stems and the dedupe discarded the whole retry. It now lists them.
- `wire_questions and ...` made total drift the ONE case that never
retried — backwards, since that's the case a retry most obviously
clears. Total drift now retries once, then 502s (the old
assert_called_once in test_quiz_routes pinned the wrong behaviour and
is updated with the reasoning).
- The retry reused ORCHESTRATOR_LIMITS, handing it a fresh full budget
and doubling the per-request cost backstop. It gets its own smaller
TOPUP_LIMITS.
- The recovery path logged a traceback on a request that deliberately
succeeds — the exact pattern that reds the logscan oracle. Now a
warning with the exception type/message.
- The duplicate-option check casefolded while grading matches
case-SENSITIVELY, so questions whose distractors differ only by case
(`list` vs `List` — a real question) were dropped. It now compares the
way grading does.
- requested_count/delivered_count had no consumer: QuizPanel now warns
"We could only build N of M questions" instead of quietly serving a
short quiz.
- The double-answer test never reached the race path (its fake
short-circuited on the pre-read); it now models the real interleaving
and asserts the loser actually attempted an insert.
Left as-is with reasoning: two of _validate_wire_question's checks are
unreachable from today's only caller (the agent schema pins 4 options
and the caller builds exactly one correct flag) — they are cheap
defence-in-depth for the #537 revamp's new call sites, and the unit
tests exercise them directly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment