Skip to content

Commit 73e68ca

Browse files
Merge remote-tracking branch 'origin/main' into refactor/2-quiz-agent
2 parents 156ec1d + 614067a commit 73e68ca

38 files changed

Lines changed: 6393 additions & 258 deletions

README.md

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Sapling is a study tool that adapts to how you learn. Chat with an AI tutor acro
2626
* **Study Guide** — Generate a Gemini-powered exam study guide from your uploaded course materials. Guides are cached per exam and can be regenerated at any time.
2727
* **Class Intelligence** — Aggregates anonymized class-wide patterns to surface common misconceptions and weak areas, personalizing your sessions.
2828
* **Calendar & Syllabus Tracking** — Paste your syllabus and Sapling extracts assignments, deadlines, and topics automatically.
29-
* **Document Library** — Upload PDFs and notes; Sapling extracts summaries, key concept notes, and flashcard topics to enrich your knowledge graph and study guides. Concept notes can be re-scanned manually per doc or per course.
29+
* **Document Library** — Upload PDFs and notes (up to 100 MB each); Sapling extracts summaries, key concept notes, and flashcard topics to enrich your knowledge graph and study guides. Uploads use a streaming SSE pipeline so the UI shows live per-phase progress (*"Classifying..." → "Extracting summary, concepts, and syllabus..." → "Saved."*). Concept notes can be re-scanned manually per doc or per course.
3030
* **Study Rooms** — Invite classmates, compare knowledge graphs, and track relative mastery across your group.
3131
* **Room Chat** — Real-time text chat with avatars inside each study room.
3232
* **User Profiles** — Public profiles with academic info, bio, featured achievements, and equipped cosmetics.
@@ -38,10 +38,12 @@ Sapling is a study tool that adapts to how you learn. Chat with an AI tutor acro
3838

3939
## Tech Stack
4040

41-
* **Frontend** — Next.js (TypeScript) with D3.js for interactive graph visualization
42-
* **Backend** — FastAPI (Python) serving a REST API with structured Gemini prompts
43-
* **AI** — Google Gemini for tutoring, quiz generation, graph updates, and syllabus extraction. `gemini-2.5-flash` for chat / document understanding; `gemini-2.5-flash-lite` for short structured tasks (quiz generation, concept suggestions)
44-
* **OCR** — Docling (layout-aware PDF → Markdown) with GOT-OCR 2.0 fallback for math/handwriting; Tesseract retained as a legacy fallback
41+
* **Frontend** — Next.js 16 (TypeScript, App Router) with D3.js for interactive graph visualization. Vitest with jsdom + React Testing Library for unit + component tests.
42+
* **Backend** — FastAPI (Python) serving a REST API. Document ingestion runs through a Pydantic AI agentic pipeline (4 typed worker agents fanned out in parallel via `asyncio.gather`); other LLM-driven routes still use the structured-prompt helper in `services/gemini_service.py` until they're migrated.
43+
* **AI** — Google Gemini, with per-task model routing configurable via env vars. Defaults: `gemini-2.5-flash-lite` for classifier + summary; `gemini-2.5-flash` for concept extraction + syllabus parsing; `gemini-2.5-flash-lite` for quiz generation and concept suggestions. Override per task via `SAPLING_MODEL_<TASK>`.
44+
* **Streaming**`sse-starlette` Server-Sent Events on `POST /api/documents/upload` for live per-phase progress. The frontend SSE consumer (`frontend/src/lib/sse.ts`) parses the wire format from a fetch ReadableStream so it works with multipart POSTs (which `EventSource` can't do).
45+
* **Observability**[Logfire](https://logfire.pydantic.dev) auto-instruments Pydantic AI agent runs, tool calls, and FastAPI requests. A custom span scrubber (`backend/services/logfire_scrubber.py`) truncates and SHA-256-fingerprints risky attribute paths (prompt text, model output, message content) before egress so user-uploaded document text never ships verbatim. `genai-prices` provides per-call cost telemetry. Per-request structured logging includes a correlation ID, status, and duration.
46+
* **OCR** — Docling (layout-aware PDF → Markdown) with GOT-OCR 2.0 fallback for math/handwriting; Tesseract retained as a legacy fallback.
4547
* **Database** — Supabase (PostgreSQL) for all persistent data
4648
* **Encryption** — AES-256-GCM column-level encryption (via the `cryptography` library) for user PII, document summaries/concept notes, OAuth tokens, chat messages, and gradebook notes
4749
* **Deploy** — Frontend on Cloudflare Workers via `@opennextjs/cloudflare`
@@ -120,9 +122,12 @@ npm run dev # → http://localhost:3000
120122
- `POST` `/api/calendar/save` — Save extracted assignments
121123

122124
**Documents**
123-
- `POST` `/api/documents/upload` — Upload and process a document
125+
- `POST` `/api/documents/upload`**Streaming SSE upload.** Runs the agentic pipeline (classifier → parallel summary/concepts/syllabus → graph merge) and emits typed SSE events the client renders as live progress: `status:start`, `progress:classify`, `progress:classified`, `progress:extract`, `progress:extracted`, `progress:graph_update`, `progress:graph_updated`, `result:finalize`, `status:done`. Errors emit `error:fallback` (degraded to legacy single-call pipeline) or `error:failed` (terminal). Idempotent on `X-Request-ID` — a retry with the same ID returns the previously persisted document without re-running the pipeline.
126+
- `POST` `/api/documents/upload/sync` — Non-streaming JSON upload. Same orchestrator under the hood, returns the persisted document as a single JSON response. Used by callers that don't need progress events.
124127
- `GET` `/api/documents/user/{user_id}` — List a user's documents
125128
- `DELETE` `/api/documents/doc/{doc_id}` — Delete a document
129+
- `POST` `/api/documents/doc/{doc_id}/scan-concepts` — Re-extract concepts from a stored document into the course graph
130+
- `POST` `/api/documents/course/{course_id}/scan-concepts` — Extend a course's concept graph from its label alone
126131

127132
**Social**
128133
- `POST` `/api/social/rooms/create` — Create a study room
@@ -189,6 +194,14 @@ npm run dev # → http://localhost:3000
189194
| `GOOGLE_CLIENT_ID` || Google OAuth client ID (for sign-in and Calendar) |
190195
| `GOOGLE_CLIENT_SECRET` || Google OAuth client secret |
191196
| `SESSION_SECRET` || HMAC secret for session tokens (min 32 bytes) |
197+
| `LOGFIRE_TOKEN` || If set, traces ship to logfire.pydantic.dev. Without it, Logfire stays local-only. The Sapling scrubber redacts prompt/output content before egress regardless. |
198+
| `SAPLING_MODEL_CLASSIFIER` || Override classifier-agent model (default `gemini-2.5-flash-lite`) |
199+
| `SAPLING_MODEL_SUMMARY` || Override summary-agent model (default `gemini-2.5-flash-lite`) |
200+
| `SAPLING_MODEL_CONCEPTS` || Override concept-extraction-agent model (default `gemini-2.5-flash`) |
201+
| `SAPLING_MODEL_SYLLABUS` || Override syllabus-extraction-agent model (default `gemini-2.5-flash`) |
202+
| `OCR_ASYNC_ENABLED` || When `true`, the streaming `/upload` route runs OCR off the request critical path with a `progress:extracting_text` SSE event. Default `false`. |
203+
| `DBOS_ENABLED` || When `true` AND `dbos` is installed AND `DBOS_DATABASE_URL` is set, `process_document` runs as a checkpointed DBOS workflow with per-step resume on crash. Default `false` (decorators are no-op passthroughs). See `docs/decisions/0011-durable-execution-dbos.md`. |
204+
| `DBOS_DATABASE_URL` || Postgres connection string for DBOS metadata (separate from Supabase). Required only when `DBOS_ENABLED=true`. |
192205

193206
**`frontend/.env.local`**
194207

@@ -197,6 +210,75 @@ npm run dev # → http://localhost:3000
197210
| `NEXT_PUBLIC_API_URL` || Backend base URL (e.g. `http://localhost:5000`) |
198211
| `SESSION_SECRET` || Same HMAC secret as backend (for middleware token verification) |
199212

213+
## Tests
214+
215+
**Backend** — pytest, mocked Gemini + Supabase. ~430 tests, ~40s.
216+
```bash
217+
cd backend
218+
python -m pytest tests/ -q --ignore=tests/evals
219+
```
220+
221+
**Frontend** — Vitest. Pure-logic tests (`sse.ts`, `api.ts`) run in node; component tests (`DocumentUploadModal.test.tsx`) use jsdom + React Testing Library + `@testing-library/jest-dom`. Per-file `// @vitest-environment jsdom` directive keeps the lib tests fast.
222+
```bash
223+
cd frontend
224+
npm install
225+
npm run typecheck
226+
npm test # vitest run
227+
npm run test:watch # vitest watch
228+
```
229+
230+
**Evals** (live Gemini, on demand) — 70 cases across the 4 worker agents (`document_classification`, `document_summary`, `concept_extraction`, `syllabus_extraction`). Three modes via `SAPLING_EVAL_MODE`:
231+
```bash
232+
cd backend
233+
# Replay (default; no network, requires recorded cassettes):
234+
SAPLING_EVAL_MODE=replay python tests/evals/document_classification.py
235+
# Record (hits live Gemini, writes cassettes to tests/evals/cassettes/):
236+
SAPLING_EVAL_MODE=record python tests/evals/document_classification.py
237+
# Live (hits live Gemini, no recording):
238+
SAPLING_EVAL_MODE=live python tests/evals/document_classification.py
239+
```
240+
The `.github/workflows/evals.yml` workflow runs replay-mode in CI; it's currently `workflow_dispatch`-only until cassette coverage is complete (4 / 70 recorded today).
241+
242+
## Architecture & Dev Context
243+
244+
**Live architecture overview**`docs/architecture.md`.
245+
246+
**Architectural Decision Records**`docs/decisions/` (append-only, MADR-minimal format). Twelve ADRs as of merge:
247+
- `0001` — Adopt Pydantic AI as the agent framework
248+
- `0002` — Markdown-based dev-context vault structure
249+
- `0003` — Per-call `usage_limits=` and inline system prompts
250+
- `0004``graph_service` as the next agent-tool surface
251+
- `0005` — Quiz generation as the next agentic refactor
252+
- `0006` — SSE protocol choice (`sse-starlette` + custom mapper, not `VercelAIAdapter`)
253+
- `0007` — Drop the document orchestrator agent (saves a Gemini Pro call per upload)
254+
- `0008` — Per-task model routing
255+
- `0009` — Request correlation IDs (`X-Request-ID`)
256+
- `0010` — OCR async / two-phase upload (partial — feature flag shipped, full design deferred)
257+
- `0011` — Durable execution via DBOS (partial — optional shim shipped, real DBOS opt-in)
258+
- `0012` — Concept-by-concept streaming (deferred — needs eval data on Gemini's emission ordering first)
259+
260+
**Things that didn't work**`docs/attempts/` (each entry has a mandatory "What I'd try next" section).
261+
262+
**Slash commands for Claude Code sessions**`.claude/commands/log-decision.md`, `log-attempt.md`, `recall.md`, `sync-context.md`. Run `/sync-context` at session start to load the most relevant ADRs as a digest.
263+
264+
**Read-only context curator subagent**`.claude/agents/context-curator.md` keeps the main session's context window lean by forking off vault searches into a separate subagent.
265+
266+
## Migrations
267+
268+
The agentic refactor added one schema migration that must be applied to staging and prod before idempotency dedupe takes effect (the route code degrades gracefully if the column is missing, so deploying without running it is safe but the dedupe is a no-op):
269+
270+
```sql
271+
-- backend/db/migration_documents_request_id.sql
272+
ALTER TABLE documents
273+
ADD COLUMN IF NOT EXISTS request_id text;
274+
275+
CREATE UNIQUE INDEX IF NOT EXISTS documents_request_id_user_unique
276+
ON documents (user_id, request_id)
277+
WHERE request_id IS NOT NULL;
278+
```
279+
280+
Apply via the Supabase SQL editor or your migration tool of choice. Idempotent — safe to re-run.
281+
200282
## License
201283

202284
Copyright (c) 2026 Andres Lopez, Jack He, Luke Cooper, and Jose Gael Cruz-Lopez

backend/db/connection.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,37 @@ def select(
4646
r.raise_for_status()
4747
return r.json()
4848

49+
def select_with_count(
50+
self,
51+
columns: str = "*",
52+
filters: Optional[dict] = None,
53+
order: Optional[str] = None,
54+
limit: Optional[int] = None,
55+
offset: Optional[int] = None,
56+
) -> tuple[list, int]:
57+
"""Like select(), but also returns total row count via Content-Range."""
58+
params: dict = {"select": columns}
59+
if filters:
60+
params.update(filters)
61+
if order:
62+
params["order"] = order
63+
if limit is not None:
64+
params["limit"] = str(limit)
65+
if offset is not None:
66+
params["offset"] = str(offset)
67+
headers = {"Prefer": "count=exact"}
68+
r = _client.get(self.url, params=params, headers=headers)
69+
r.raise_for_status()
70+
rows = r.json()
71+
total = 0
72+
cr = r.headers.get("Content-Range") or r.headers.get("content-range")
73+
if cr and "/" in cr:
74+
try:
75+
total = int(cr.rsplit("/", 1)[1])
76+
except ValueError:
77+
total = 0
78+
return rows, total
79+
4980
def insert(self, data) -> list:
5081
r = _client.post(self.url, json=data)
5182
r.raise_for_status()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Migration: Admin portal — audit log + last_sign_in tracking
2+
-- Run once in Supabase SQL editor.
3+
4+
CREATE TABLE IF NOT EXISTS admin_audit_log (
5+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6+
actor_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
7+
action TEXT NOT NULL, -- e.g. 'user.approve', 'role.assign'
8+
target_type TEXT NOT NULL, -- 'user' | 'role' | 'achievement' | 'cosmetic' | 'allowlist' | 'trigger' | 'role_cosmetic' | 'achievement_cosmetic'
9+
target_id TEXT, -- nullable for actions without a single target
10+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
11+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12+
);
13+
14+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at
15+
ON admin_audit_log (created_at DESC);
16+
17+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_actor
18+
ON admin_audit_log (actor_id, created_at DESC);
19+
20+
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_target
21+
ON admin_audit_log (target_type, target_id);
22+
23+
ALTER TABLE users
24+
ADD COLUMN IF NOT EXISTS last_sign_in_at TIMESTAMPTZ;
25+
26+
CREATE INDEX IF NOT EXISTS idx_users_created_at
27+
ON users (created_at DESC);

backend/models/__init__.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Union, List
1+
from typing import Optional, Union, List, Literal
22
from pydantic import BaseModel, Field
33

44

@@ -10,6 +10,7 @@ class StartSessionBody(BaseModel):
1010
mode: str = "socratic"
1111
use_shared_context: bool = True
1212
course_id: Optional[str] = None # Direct course_id lookup instead of resolving from topic
13+
model_pref: Optional[Literal["fast", "smart"]] = None # "fast" (default, gemini-2.5-flash) or "smart" (gemini-2.5-pro)
1314

1415

1516
class ChatBody(BaseModel):
@@ -18,6 +19,7 @@ class ChatBody(BaseModel):
1819
message: str
1920
mode: str = "socratic"
2021
use_shared_context: bool = True
22+
model_pref: Optional[Literal["fast", "smart"]] = None # "fast" (default, gemini-2.5-flash) or "smart" (gemini-2.5-pro)
2123

2224

2325
class EndSessionBody(BaseModel):
@@ -31,6 +33,7 @@ class ActionBody(BaseModel):
3133
action_type: str = "hint"
3234
mode: str = "socratic"
3335
use_shared_context: bool = True
36+
model_pref: Optional[Literal["fast", "smart"]] = None # "fast" (default, gemini-2.5-flash) or "smart" (gemini-2.5-pro)
3437

3538

3639
# ── Quiz ──────────────────────────────────────────────────────────────────────
@@ -282,7 +285,6 @@ class CreateRoleBody(BaseModel):
282285
class AssignRoleBody(BaseModel):
283286
user_id: str
284287
role_id: str
285-
granted_by: Optional[str] = None
286288

287289

288290
class RevokeRoleBody(BaseModel):
@@ -313,6 +315,21 @@ class GrantAchievementBody(BaseModel):
313315
achievement_id: str
314316

315317

318+
class UpdateAchievementTriggerBody(BaseModel):
319+
trigger_type: Optional[str] = None
320+
trigger_threshold: Optional[int] = None
321+
322+
323+
class LinkAchievementCosmeticBody(BaseModel):
324+
achievement_id: str
325+
cosmetic_id: str
326+
327+
328+
class LinkRoleCosmeticBody(BaseModel):
329+
role_id: str
330+
cosmetic_id: str
331+
332+
316333
# ── Cosmetics (Admin) ────────────────────────────────────────────────────────
317334

318335
class CreateCosmeticBody(BaseModel):
@@ -384,3 +401,9 @@ class SyllabusApplyBody(BaseModel):
384401
doc_id: str
385402
categories: list[CategoryItem]
386403
assignments: list[dict] # uses the same shape as syllabus extraction
404+
405+
406+
# ── Newsletter & Allowlist (Admin) ──────────────────────────────────────────
407+
408+
class AllowlistEmailBody(BaseModel):
409+
email: str

0 commit comments

Comments
 (0)