samruddhisadar commited on
Commit
b5bd684
·
0 Parent(s):

initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +49 -0
  2. .gitignore +28 -0
  3. README.md +223 -0
  4. backend/__init__.py +0 -0
  5. backend/audit.py +41 -0
  6. backend/config.py +89 -0
  7. backend/db.py +180 -0
  8. backend/grader/__init__.py +2 -0
  9. backend/grader/aggregate.py +32 -0
  10. backend/grader/graph.py +99 -0
  11. backend/grader/llm.py +88 -0
  12. backend/grader/nodes.py +166 -0
  13. backend/grader/prompts.py +168 -0
  14. backend/grader/rate_limit.py +35 -0
  15. backend/grader/state.py +35 -0
  16. backend/ingestion/__init__.py +3 -0
  17. backend/ingestion/anonymize.py +43 -0
  18. backend/ingestion/crop.py +120 -0
  19. backend/ingestion/pdf_split.py +39 -0
  20. backend/main.py +490 -0
  21. backend/ocr/__init__.py +1 -0
  22. backend/ocr/hosted_adapter.py +45 -0
  23. backend/ocr/qwen_vl_adapter.py +75 -0
  24. backend/ocr/router.py +21 -0
  25. backend/plagiarism.py +62 -0
  26. backend/schemas.py +164 -0
  27. data/samples/.gitkeep +0 -0
  28. debug_llm.py +95 -0
  29. docker-compose.yml +23 -0
  30. frontend/index.html +18 -0
  31. frontend/package-lock.json +0 -0
  32. frontend/package.json +24 -0
  33. frontend/postcss.config.js +6 -0
  34. frontend/src/App.jsx +118 -0
  35. frontend/src/api.js +43 -0
  36. frontend/src/components/ui.jsx +203 -0
  37. frontend/src/index.css +28 -0
  38. frontend/src/main.jsx +13 -0
  39. frontend/src/pages/Audit.jsx +149 -0
  40. frontend/src/pages/Grading.jsx +169 -0
  41. frontend/src/pages/Review.jsx +302 -0
  42. frontend/src/pages/Rubrics.jsx +349 -0
  43. frontend/tailwind.config.js +82 -0
  44. frontend/vite.config.js +17 -0
  45. requirements.txt +36 -0
  46. scripts/init_db.py +29 -0
  47. scripts/run_demo.py +126 -0
  48. scripts/seed_demo.py +62 -0
  49. storage/crops/.gitkeep +0 -0
  50. storage/pages/.gitkeep +0 -0
.env.example ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env and fill in.
2
+
3
+ # --- LLM provider --------------------------------------------------------
4
+ # 'google' : Gemini via langchain-google-genai (FREE tier — recommended)
5
+ # 'anthropic' : Claude via langchain-anthropic (paid)
6
+ LLM_PROVIDER=google
7
+
8
+ # --- Google Gemini (free) ------------------------------------------------
9
+ # Get a key at: https://aistudio.google.com/app/apikey
10
+ # No billing required.
11
+ GOOGLE_API_KEY=
12
+
13
+ # gemini-2.5-flash-lite has the highest free-tier quota of any vision-
14
+ # capable Gemini (15 RPM / 1000 RPD). Switch to gemini-2.5-flash or
15
+ # gemini-1.5-flash if you prefer their output quality and accept tighter
16
+ # rate limits.
17
+ GRADER_MODEL_GOOGLE=gemini-2.5-flash-lite
18
+
19
+ # --- Anthropic Claude (paid; only used if LLM_PROVIDER=anthropic) --------
20
+ ANTHROPIC_API_KEY=
21
+ GRADER_MODEL_ANTHROPIC=claude-sonnet-4-20250514
22
+
23
+ # --- Database ------------------------------------------------------------
24
+ DATABASE_URL=sqlite:///./gradeops.db
25
+ # DATABASE_URL=postgresql+psycopg2://gradeops:gradeops@localhost:5432/gradeops
26
+
27
+ # --- OCR routing ---------------------------------------------------------
28
+ # 'hosted' uses whatever LLM_PROVIDER above (works without GPU).
29
+ # 'qwen_vl' uses Qwen2-VL via HuggingFace transformers (needs GPU).
30
+ OCR_BACKEND=hosted
31
+
32
+ # --- Storage -------------------------------------------------------------
33
+ STORAGE_ROOT=./storage
34
+
35
+ # --- Grader behavior -----------------------------------------------------
36
+ # Number of independent grading passes per answer. 1 = free-tier friendly,
37
+ # 5 = the full multi-pass design with median/max/std-dev aggregation.
38
+ GRADER_NUM_PASSES=1
39
+ # Critic retry limit. 0 disables the retry edge.
40
+ GRADER_CRITIC_RETRY=0
41
+
42
+ # --- Rate-limit throttling -----------------------------------------------
43
+ # Seconds between LLM calls. 4.5 keeps us under Gemini's free-tier 15 RPM.
44
+ # Set to 0 on paid tiers to disable throttling.
45
+ LLM_MIN_GAP_SECONDS=4.5
46
+
47
+ # --- Plagiarism ----------------------------------------------------------
48
+ PLAGIARISM_THRESHOLD=0.82
49
+ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
.gitignore ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ *.db
8
+ *.sqlite
9
+
10
+ # Env
11
+ .env
12
+ .env.local
13
+
14
+ # Node
15
+ node_modules/
16
+ dist/
17
+ .vite/
18
+
19
+ # Storage (user data — keep out of source control)
20
+ storage/pdfs/*
21
+ storage/pages/*
22
+ storage/crops/*
23
+ !storage/pdfs/.gitkeep
24
+ !storage/pages/.gitkeep
25
+ !storage/crops/.gitkeep
26
+
27
+ # IDE
28
+ .vscode/
README.md ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GradeOps
2
+
3
+ > Human-in-the-loop grading pipeline for handwritten exams.
4
+
5
+ GradeOps lets an instructor upload scanned answer sheets and grading rubrics, automatically transcribes each handwritten answer, runs it through a multi-step LLM grader that awards partial credit with citations, and surfaces every AI-proposed grade on a high-throughput review dashboard where teaching assistants approve, override, or flag with keyboard shortcuts.
6
+
7
+ ---
8
+
9
+ ## Stack
10
+
11
+ | Layer | Tech |
12
+ |---|---|
13
+ | Frontend | React 18, Vite, Tailwind, Inter typography |
14
+ | Backend | FastAPI, SQLAlchemy |
15
+ | Database | SQLite (default) or Postgres |
16
+ | Agentic grader | Langchain + Langgraph (Extractor → Scorer → Justifier → Critic) |
17
+ | OCR / Vision | Gemini (free tier) or Claude vision via a swappable adapter; Qwen-VL adapter stub for HuggingFace use |
18
+ | Plagiarism | sentence-transformers cosine similarity |
19
+ | Storage | Local `storage/` folder behind a path abstraction (drop-in for S3) |
20
+
21
+ ---
22
+
23
+ ## Quick start
24
+
25
+ ### 1. Clone, create a venv, install dependencies
26
+
27
+ ```bash
28
+ cd gradeops
29
+ python -m venv .venv
30
+ # Windows:
31
+ .venv\Scripts\activate
32
+ # macOS / Linux:
33
+ source .venv/bin/activate
34
+
35
+ pip install -r requirements.txt
36
+ ```
37
+
38
+ ### 2. Configure environment
39
+
40
+ ```bash
41
+ cp .env.example .env
42
+ ```
43
+
44
+ Open `.env` and set at minimum:
45
+
46
+ ```
47
+ GOOGLE_API_KEY=AIzaSy...
48
+ ```
49
+
50
+ Get a free Gemini API key at <https://aistudio.google.com/app/apikey> — no billing required.
51
+
52
+ ### 3. Initialize the database
53
+
54
+ ```bash
55
+ python scripts/init_db.py
56
+ python scripts/seed_demo.py # optional: pre-load a sample exam + rubric
57
+ ```
58
+
59
+ ### 4. Run the backend
60
+
61
+ ```bash
62
+ uvicorn backend.main:app --reload --port 8000
63
+ ```
64
+
65
+ API docs at <http://localhost:8000/docs>.
66
+
67
+ ### 5. Run the frontend (new terminal)
68
+
69
+ ```bash
70
+ cd frontend
71
+ npm install
72
+ npm run dev
73
+ ```
74
+
75
+ Open <http://localhost:5173>.
76
+
77
+ ### 6. Optional CLI demo
78
+
79
+ For a no-UI walkthrough on one image:
80
+
81
+ ```bash
82
+ python scripts/run_demo.py path/to/handwritten_answer.jpg
83
+ ```
84
+
85
+ ---
86
+
87
+ ## How the grader works
88
+
89
+ The grader is a 4-node agentic state machine compiled with Langgraph:
90
+
91
+ ```
92
+ ┌────────────────────────────────────────────┐
93
+ │ │
94
+ image ─► Extractor ─► Scorer ─► Justifier ─► Critic ─┐ │
95
+ ▲ │ │
96
+ │ │ │
97
+ └─── retry with feedback ───┘ │
98
+ │
99
+ END ◄─── pass ─────────────┘
100
+ ```
101
+
102
+ - **Extractor** sees the image. Transcribes the handwriting literally and breaks it into atomic claim-steps. Never scores.
103
+ - **Scorer** sees only the extracted claims (not the image). Maps them to rubric criteria and awards points. The image-blindness is deliberate — it is the structural defense against the grader awarding credit for things it imagines rather than what is on the page.
104
+ - **Justifier** writes a 2–3 sentence rationale citing specific claim numbers.
105
+ - **Critic** audits whether every awarded point is supported by a claim. If not, it sends the Scorer back with concrete feedback. Configurable retry limit; disabled by default to keep the call budget tight.
106
+
107
+ For higher-confidence grading, set `GRADER_NUM_PASSES=5` in `.env`. The system will run the full graph 5 times per answer and aggregate median, max, and standard deviation. The review queue then sorts by std-dev DESC, surfacing the AI's most uncertain grades to TAs first.
108
+
109
+ ---
110
+
111
+ ## Review dashboard shortcuts
112
+
113
+ | Key | Action |
114
+ |---|---|
115
+ | `Enter` | Approve the AI's median score |
116
+ | `O` | Open the override input |
117
+ | `F` | Flag for further review |
118
+ | `J` / `→` | Next item |
119
+ | `K` / `←` | Previous item |
120
+ | `Esc` (in override) | Cancel |
121
+ | `Ctrl/⌘ + Enter` (in override) | Save override |
122
+
123
+ ---
124
+
125
+ ## Configuration reference
126
+
127
+ All settings live in `.env`. Key knobs:
128
+
129
+ | Variable | Default | Purpose |
130
+ |---|---|---|
131
+ | `LLM_PROVIDER` | `google` | `google` or `anthropic` |
132
+ | `GOOGLE_API_KEY` | — | Required for the default Google provider |
133
+ | `GRADER_MODEL_GOOGLE` | `gemini-2.5-flash-lite` | Free-tier-friendly vision model |
134
+ | `ANTHROPIC_API_KEY` | — | Required if `LLM_PROVIDER=anthropic` |
135
+ | `GRADER_MODEL_ANTHROPIC` | `claude-sonnet-4-20250514` | |
136
+ | `DATABASE_URL` | `sqlite:///./gradeops.db` | Swap to a Postgres URL when needed |
137
+ | `OCR_BACKEND` | `hosted` | `hosted` (uses configured LLM) or `qwen_vl` (HuggingFace, needs GPU) |
138
+ | `GRADER_NUM_PASSES` | `1` | Passes per answer; raise to 5 for variance-based review priority |
139
+ | `GRADER_CRITIC_RETRY` | `0` | How many times the Critic may send the Scorer back |
140
+ | `LLM_MIN_GAP_SECONDS` | `4.5` | Throttle between LLM calls; keeps free-tier RPM caps safe |
141
+ | `PLAGIARISM_THRESHOLD` | `0.82` | Cosine similarity cutoff |
142
+
143
+ ---
144
+
145
+ ## Schema (DB tables)
146
+
147
+ ```
148
+ users role: instructor | ta
149
+ exams
150
+ rubrics versioned (v1, v2, ...); immutable on update
151
+ papers one row per uploaded PDF
152
+ crops one row per cropped answer region
153
+ gradings one row per grading pass
154
+ grading_aggregates median / max / min / std-dev per crop
155
+ reviews every approve / override / flag
156
+ plagiarism_flags pairs of crops above similarity threshold
157
+ audit_log append-only; every row stamped with rubric_ver + prompt_ver + model_ver
158
+ ```
159
+
160
+ Every grading and every review writes an audit row, stamped with the rubric, prompt, and model versions in effect at the time. The audit log is the source of truth for traceability.
161
+
162
+ ---
163
+
164
+ ## Project layout
165
+
166
+ ```
167
+ gradeops/
168
+ ├── backend/
169
+ │ ├── main.py FastAPI app
170
+ │ ├── config.py · db.py · schemas.py
171
+ │ ├── audit.py · plagiarism.py
172
+ │ ├── ingestion/ PDF split · crop · anonymize
173
+ │ ├── ocr/ Hosted + Qwen-VL adapters + router
174
+ │ └── grader/ Langgraph state machine + rate limiter
175
+ ├── frontend/
176
+ │ ├── package.json · vite.config.js · tailwind.config.js
177
+ │ └── src/
178
+ │ ├── App.jsx · main.jsx · index.css · api.js
179
+ │ ├── components/ui.jsx
180
+ │ └── pages/{Rubrics,Grading,Review,Audit}.jsx
181
+ ├── scripts/
182
+ │ ├── init_db.py
183
+ │ ├── seed_demo.py
184
+ │ └── run_demo.py
185
+ ├── storage/ Local stand-in for cloud object store
186
+ ├── docker-compose.yml Optional Postgres
187
+ ├── requirements.txt
188
+ └── .env.example
189
+ ```
190
+
191
+ ---
192
+
193
+ ## API reference
194
+
195
+ Quick tour. Full schema at `/docs` when the server is running.
196
+
197
+ | Method | Path | Description |
198
+ |---|---|---|
199
+ | GET | `/health` | Liveness probe + active model |
200
+ | GET | `/debug/env` | Diagnostic — what env vars actually loaded |
201
+ | GET | `/users` | List instructor + TA accounts |
202
+ | POST | `/exams` | Create an exam |
203
+ | GET | `/exams` | List exams |
204
+ | POST | `/rubrics` | Create a rubric (immutable; auto-versioned) |
205
+ | GET | `/rubrics?exam_id={id}` | List rubrics for an exam |
206
+ | POST | `/papers/upload` | Upload a PDF or image; runs the full pipeline |
207
+ | GET | `/crops/{id}/image` | Fetch the anonymized crop image |
208
+ | GET | `/review/queue` | Crops sorted by std-dev DESC, pending only |
209
+ | POST | `/reviews` | Submit an approve / override / flag |
210
+ | GET | `/audit` | Decision log |
211
+ | GET | `/plagiarism` | Similarity pairs above threshold |
212
+ | GET | `/stats` | Dashboard metrics |
213
+
214
+ ---
215
+
216
+ ## Out of scope for this build
217
+
218
+ - Real authentication / SSO — there is a role toggle in the header for RBAC demonstration
219
+ - Background workers — the pipeline runs synchronously on upload
220
+ - Cloud object storage — the local `storage/` folder is behind a path abstraction; S3 swap is a small change
221
+ - Failure recovery, idempotency, retries beyond the Critic loop
222
+
223
+ These are flagged in the architecture as the next things to add when moving to production.
backend/__init__.py ADDED
File without changes
backend/audit.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Append-only audit logger.
2
+
3
+ Every grading and every review decision writes a row here, stamped with the
4
+ rubric / prompt / model versions in effect at the time. This is what gives
5
+ the brief's "Traceability for all grading decisions" non-functional
6
+ requirement actual teeth.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from sqlalchemy.orm import Session
13
+
14
+ from .config import settings
15
+ from .db import AuditLog
16
+
17
+
18
+ def log_event(
19
+ db: Session,
20
+ entity_type: str,
21
+ entity_id: int,
22
+ action: str,
23
+ actor_id: int | None = None,
24
+ before: dict | None = None,
25
+ after: dict | None = None,
26
+ rubric_version: str | None = None,
27
+ ) -> AuditLog:
28
+ """Write one audit row. Caller is responsible for db.commit()."""
29
+ row = AuditLog(
30
+ entity_type=entity_type,
31
+ entity_id=entity_id,
32
+ action=action,
33
+ actor_id=actor_id,
34
+ before=before,
35
+ after=after,
36
+ rubric_version=rubric_version,
37
+ prompt_version=settings.prompt_version,
38
+ model_version=settings.grader_model,
39
+ )
40
+ db.add(row)
41
+ return row
backend/config.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized configuration loaded from environment variables.
2
+
3
+ Loading order (first non-empty wins):
4
+ 1. Shell environment variable (e.g. `$env:GOOGLE_API_KEY=...`)
5
+ 2. Values in `gradeops/.env` (loaded via python-dotenv from an ABSOLUTE
6
+ path resolved from this file's location — robust to whatever CWD
7
+ uvicorn was launched with)
8
+ 3. Class-level defaults below
9
+ """
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv
13
+ from pydantic_settings import BaseSettings, SettingsConfigDict
14
+
15
+
16
+ # backend/config.py → ../ → gradeops/ → .env (absolute path, CWD-independent)
17
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
18
+ _ENV_FILE = _PROJECT_ROOT / ".env"
19
+
20
+ if _ENV_FILE.exists():
21
+ load_dotenv(_ENV_FILE, override=False)
22
+
23
+
24
+ class Settings(BaseSettings):
25
+ # --- LLM provider selection ----------------------------------------
26
+ # 'google' : Gemini via langchain-google-genai (FREE tier)
27
+ # 'anthropic' : Claude via langchain-anthropic (paid)
28
+ llm_provider: str = "google"
29
+
30
+ # --- Google Gemini -------------------------------------------------
31
+ # 2.5-flash-lite has the highest free-tier quota of any vision-capable
32
+ # Gemini (15 RPM / 1000 RPD) — picked for reliability over peak quality.
33
+ google_api_key: str = ""
34
+ grader_model_google: str = "gemini-2.5-flash-lite"
35
+
36
+ # --- Anthropic Claude (paid; used only if LLM_PROVIDER=anthropic) --
37
+ anthropic_api_key: str = ""
38
+ grader_model_anthropic: str = "claude-sonnet-4-20250514"
39
+
40
+ # --- Database ------------------------------------------------------
41
+ database_url: str = "sqlite:///./gradeops.db"
42
+
43
+ # --- OCR routing ---------------------------------------------------
44
+ ocr_backend: str = "hosted"
45
+
46
+ # --- Storage -------------------------------------------------------
47
+ storage_root: str = "./storage"
48
+
49
+ # --- Grader behavior -----------------------------------------------
50
+ grader_num_passes: int = 1 # 1 keeps free-tier-friendly; 5 matches the multi-pass design
51
+ grader_critic_retry: int = 0 # set to 1 to enable critic→scorer retry
52
+
53
+ # --- Rate-limit throttling -----------------------------------------
54
+ # Seconds between successive LLM calls. Gemini free tier = 15 RPM,
55
+ # so the safe floor is 4s. We default to 4.5s with a small jitter
56
+ # cushion so even rapid back-to-back uploads stay under quota.
57
+ llm_min_gap_seconds: float = 4.5
58
+
59
+ # --- Plagiarism ----------------------------------------------------
60
+ plagiarism_threshold: float = 0.82
61
+ embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2"
62
+
63
+ # --- Versioning ----------------------------------------------------
64
+ prompt_version: str = "v1.0"
65
+ schema_version: str = "v1.0"
66
+
67
+ model_config = SettingsConfigDict(
68
+ env_file=str(_ENV_FILE) if _ENV_FILE.exists() else None,
69
+ env_file_encoding="utf-8-sig",
70
+ extra="ignore",
71
+ case_sensitive=False,
72
+ )
73
+
74
+ @property
75
+ def storage_path(self) -> Path:
76
+ p = Path(self.storage_root)
77
+ (p / "pdfs").mkdir(parents=True, exist_ok=True)
78
+ (p / "pages").mkdir(parents=True, exist_ok=True)
79
+ (p / "crops").mkdir(parents=True, exist_ok=True)
80
+ return p
81
+
82
+ @property
83
+ def grader_model(self) -> str:
84
+ if (self.llm_provider or "google").lower() == "anthropic":
85
+ return self.grader_model_anthropic
86
+ return self.grader_model_google
87
+
88
+
89
+ settings = Settings()
backend/db.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy ORM models matching the architecture diagram.
2
+
3
+ Tables:
4
+ users instructor / TA accounts (simple RBAC)
5
+ exams one row per exam instance
6
+ rubrics versioned per exam (immutable; updates create new rows)
7
+ papers one row per uploaded student PDF
8
+ crops one row per cropped answer region
9
+ gradings one row per grading pass (5 per crop)
10
+ grading_aggregates median / max / std-dev per crop
11
+ reviews TA decisions (approve / override / flag)
12
+ plagiarism_flags pairs of crops above similarity threshold
13
+ audit_log immutable decision trail with model + prompt + rubric versions
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ from sqlalchemy import (
19
+ create_engine, Column, Integer, String, Float, DateTime,
20
+ ForeignKey, JSON, Text, Boolean, UniqueConstraint
21
+ )
22
+ from sqlalchemy.orm import declarative_base, relationship, sessionmaker, Session
23
+
24
+ from .config import settings
25
+
26
+
27
+ # Engine + session ------------------------------------------------------------
28
+ connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
29
+ engine = create_engine(settings.database_url, connect_args=connect_args, future=True)
30
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
31
+ Base = declarative_base()
32
+
33
+
34
+ def get_db() -> Session:
35
+ """FastAPI dependency that yields a database session and closes it on exit."""
36
+ db = SessionLocal()
37
+ try:
38
+ yield db
39
+ finally:
40
+ db.close()
41
+
42
+
43
+ def init_db() -> None:
44
+ """Create all tables. Idempotent — safe to call on every boot."""
45
+ Base.metadata.create_all(bind=engine)
46
+
47
+
48
+ # Tables ---------------------------------------------------------------------
49
+ class User(Base):
50
+ __tablename__ = "users"
51
+ id = Column(Integer, primary_key=True)
52
+ email = Column(String, unique=True, nullable=False)
53
+ name = Column(String, nullable=False)
54
+ role = Column(String, nullable=False) # 'instructor' | 'ta'
55
+ created_at = Column(DateTime, default=datetime.utcnow)
56
+
57
+
58
+ class Exam(Base):
59
+ __tablename__ = "exams"
60
+ id = Column(Integer, primary_key=True)
61
+ title = Column(String, nullable=False)
62
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
63
+ created_at = Column(DateTime, default=datetime.utcnow)
64
+
65
+ rubrics = relationship("Rubric", back_populates="exam", cascade="all, delete-orphan")
66
+ papers = relationship("Paper", back_populates="exam", cascade="all, delete-orphan")
67
+
68
+
69
+ class Rubric(Base):
70
+ __tablename__ = "rubrics"
71
+ id = Column(Integer, primary_key=True)
72
+ exam_id = Column(Integer, ForeignKey("exams.id"), nullable=False)
73
+ version = Column(String, nullable=False) # e.g. 'v1', 'v2'
74
+ title = Column(String, nullable=False)
75
+ question_text = Column(Text, nullable=False)
76
+ max_marks = Column(Float, nullable=False)
77
+ course_instructions = Column(Text, nullable=False)
78
+ criteria = Column(JSON, nullable=False) # list of dicts; see schemas.py
79
+ created_at = Column(DateTime, default=datetime.utcnow)
80
+
81
+ exam = relationship("Exam", back_populates="rubrics")
82
+
83
+
84
+ class Paper(Base):
85
+ __tablename__ = "papers"
86
+ id = Column(Integer, primary_key=True)
87
+ exam_id = Column(Integer, ForeignKey("exams.id"), nullable=False)
88
+ student_anon_id = Column(String, nullable=False) # e.g. 'STU-0001'; never the real ID
89
+ source_pdf_path = Column(String, nullable=False)
90
+ uploaded_at = Column(DateTime, default=datetime.utcnow)
91
+
92
+ exam = relationship("Exam", back_populates="papers")
93
+ crops = relationship("Crop", back_populates="paper", cascade="all, delete-orphan")
94
+
95
+
96
+ class Crop(Base):
97
+ __tablename__ = "crops"
98
+ id = Column(Integer, primary_key=True)
99
+ paper_id = Column(Integer, ForeignKey("papers.id"), nullable=False)
100
+ question_id = Column(String, nullable=False) # e.g. 'Q1', 'Q2a'
101
+ rubric_id = Column(Integer, ForeignKey("rubrics.id"), nullable=False)
102
+ image_path = Column(String, nullable=False) # raw crop
103
+ anonymized_path = Column(String, nullable=False) # post-anonymization
104
+ bbox = Column(JSON, nullable=True) # {x,y,w,h} on source page
105
+ created_at = Column(DateTime, default=datetime.utcnow)
106
+
107
+ paper = relationship("Paper", back_populates="crops")
108
+ gradings = relationship("Grading", back_populates="crop", cascade="all, delete-orphan")
109
+
110
+
111
+ class Grading(Base):
112
+ """One row per LLM grading pass. NUM_PASSES rows expected per crop."""
113
+ __tablename__ = "gradings"
114
+ id = Column(Integer, primary_key=True)
115
+ crop_id = Column(Integer, ForeignKey("crops.id"), nullable=False)
116
+ rubric_id = Column(Integer, ForeignKey("rubrics.id"), nullable=False)
117
+ pass_num = Column(Integer, nullable=False)
118
+ score = Column(Float, nullable=False)
119
+ max_score = Column(Float, nullable=False)
120
+ per_criterion = Column(JSON, nullable=False) # list[{name, awarded, max, reasoning}]
121
+ justification = Column(Text, nullable=False)
122
+ transcript = Column(Text, nullable=False)
123
+ flags = Column(JSON, nullable=False, default=list)
124
+ critic_passed = Column(Boolean, nullable=False, default=True)
125
+ critic_feedback = Column(Text, nullable=True)
126
+ model_version = Column(String, nullable=False)
127
+ prompt_version = Column(String, nullable=False)
128
+ created_at = Column(DateTime, default=datetime.utcnow)
129
+
130
+ crop = relationship("Crop", back_populates="gradings")
131
+
132
+
133
+ class GradingAggregate(Base):
134
+ """Aggregated scores across passes; one row per crop."""
135
+ __tablename__ = "grading_aggregates"
136
+ crop_id = Column(Integer, ForeignKey("crops.id"), primary_key=True)
137
+ median = Column(Float, nullable=False)
138
+ max_score = Column(Float, nullable=False)
139
+ min_score = Column(Float, nullable=False)
140
+ std_dev = Column(Float, nullable=False)
141
+ n_passes = Column(Integer, nullable=False)
142
+ computed_at = Column(DateTime, default=datetime.utcnow)
143
+
144
+
145
+ class Review(Base):
146
+ __tablename__ = "reviews"
147
+ id = Column(Integer, primary_key=True)
148
+ crop_id = Column(Integer, ForeignKey("crops.id"), nullable=False)
149
+ reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=True)
150
+ action = Column(String, nullable=False) # approve | override | flag
151
+ ai_score = Column(Float, nullable=False)
152
+ final_score = Column(Float, nullable=False)
153
+ notes = Column(Text, nullable=True)
154
+ created_at = Column(DateTime, default=datetime.utcnow)
155
+
156
+
157
+ class PlagiarismFlag(Base):
158
+ __tablename__ = "plagiarism_flags"
159
+ id = Column(Integer, primary_key=True)
160
+ crop_a_id = Column(Integer, ForeignKey("crops.id"), nullable=False)
161
+ crop_b_id = Column(Integer, ForeignKey("crops.id"), nullable=False)
162
+ similarity = Column(Float, nullable=False)
163
+ flagged_at = Column(DateTime, default=datetime.utcnow)
164
+ __table_args__ = (UniqueConstraint("crop_a_id", "crop_b_id", name="uniq_pair"),)
165
+
166
+
167
+ class AuditLog(Base):
168
+ """Immutable trail of every state change. Append-only."""
169
+ __tablename__ = "audit_log"
170
+ id = Column(Integer, primary_key=True)
171
+ entity_type = Column(String, nullable=False) # 'grading', 'review', etc
172
+ entity_id = Column(Integer, nullable=False)
173
+ action = Column(String, nullable=False)
174
+ actor_id = Column(Integer, ForeignKey("users.id"), nullable=True)
175
+ before = Column(JSON, nullable=True)
176
+ after = Column(JSON, nullable=True)
177
+ rubric_version = Column(String, nullable=True)
178
+ prompt_version = Column(String, nullable=True)
179
+ model_version = Column(String, nullable=True)
180
+ created_at = Column(DateTime, default=datetime.utcnow)
backend/grader/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .graph import grade_one_pass, grade_multi_pass # noqa: F401
2
+ from .aggregate import aggregate_scores # noqa: F401
backend/grader/aggregate.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregate scores across multiple grading passes.
2
+
3
+ We deliberately keep median, max, AND std-dev:
4
+ - median is the "fair" provisional score (less sensitive to outliers)
5
+ - max is the conservative-in-student's-favor choice (Paper 2's primary)
6
+ - std-dev is the routing signal for the review queue (highest variance first)
7
+
8
+ Paper 2 found no reliable threshold for fully-automated low-confidence
9
+ skipping. So std-dev is used here as a SORTING signal, not a SKIP signal —
10
+ every grade still goes to a human, but high-std-dev ones go to the front
11
+ of the queue.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import statistics
16
+
17
+
18
+ def aggregate_scores(passes: list[dict]) -> dict:
19
+ """Given N pass dicts (each with 'score'), produce summary stats."""
20
+ scores = [float(p.get("score", 0.0)) for p in passes if "score" in p]
21
+ if not scores:
22
+ return {
23
+ "median": 0.0, "max_score": 0.0, "min_score": 0.0,
24
+ "std_dev": 0.0, "n_passes": 0,
25
+ }
26
+ return {
27
+ "median": float(statistics.median(scores)),
28
+ "max_score": float(max(scores)),
29
+ "min_score": float(min(scores)),
30
+ "std_dev": float(statistics.pstdev(scores)) if len(scores) > 1 else 0.0,
31
+ "n_passes": len(scores),
32
+ }
backend/grader/graph.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Langgraph wiring for the agentic grader.
2
+
3
+ Graph:
4
+ extractor -> scorer -> justifier -> critic --pass--> END
5
+ ^ |
6
+ +----- retry-with-feedback (optional)
7
+
8
+ Multi-pass:
9
+ Runs the graph NUM_PASSES times PER CROP, sequentially. Sequential
10
+ (not threadpooled) so the global rate limiter in rate_limit.py can
11
+ space all calls to stay under Gemini's free-tier RPM cap. For paid
12
+ tiers, set llm_min_gap_seconds=0 in .env to remove the throttle.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from pathlib import Path
18
+
19
+ from langgraph.graph import StateGraph, START, END
20
+
21
+ from ..config import settings
22
+ from .state import GradingState
23
+ from .nodes import (
24
+ extractor_node, scorer_node, justifier_node, critic_node, critic_router,
25
+ )
26
+
27
+
28
+ def build_grading_graph():
29
+ g = StateGraph(GradingState)
30
+ g.add_node("extractor", extractor_node)
31
+ g.add_node("scorer", scorer_node)
32
+ g.add_node("justifier", justifier_node)
33
+ g.add_node("critic", critic_node)
34
+ g.add_edge(START, "extractor")
35
+ g.add_edge("extractor", "scorer")
36
+ g.add_edge("scorer", "justifier")
37
+ g.add_edge("justifier", "critic")
38
+ g.add_conditional_edges("critic", critic_router, {"scorer": "scorer", END: END})
39
+ return g.compile()
40
+
41
+
42
+ GRAPH = build_grading_graph()
43
+
44
+
45
+ def _read_image_b64(image_path: str) -> tuple[str, str]:
46
+ p = Path(image_path)
47
+ data = p.read_bytes()
48
+ suffix = p.suffix.lower().lstrip(".")
49
+ media_type = "image/jpeg" if suffix in ("jpg", "jpeg") else f"image/{suffix or 'jpeg'}"
50
+ return base64.b64encode(data).decode("ascii"), media_type
51
+
52
+
53
+ def grade_one_pass(image_path: str, question: str, max_marks: float,
54
+ criteria: list[dict]) -> dict:
55
+ img_b64, media_type = _read_image_b64(image_path)
56
+ initial: GradingState = {
57
+ "image_b64": img_b64,
58
+ "media_type": media_type,
59
+ "question": question,
60
+ "max_marks": max_marks,
61
+ "criteria": criteria,
62
+ "retry_count": 0,
63
+ "errors": [],
64
+ }
65
+ try:
66
+ final = GRAPH.invoke(initial)
67
+ except Exception as e:
68
+ return {
69
+ "score": 0.0,
70
+ "max_score": max_marks,
71
+ "per_criterion": [],
72
+ "transcript": "",
73
+ "justification": f"[grading failed: {e}]",
74
+ "flags": ["api_error"],
75
+ "critic_passed": False,
76
+ "critic_feedback": str(e),
77
+ }
78
+ return {
79
+ "score": float(final.get("score", 0.0)),
80
+ "max_score": float(final.get("max_score", max_marks)),
81
+ "per_criterion": final.get("per_criterion", []),
82
+ "transcript": final.get("transcript", ""),
83
+ "justification": final.get("justification", ""),
84
+ "flags": final.get("flags", []),
85
+ "critic_passed": bool(final.get("critic_passed", True)),
86
+ "critic_feedback": final.get("critic_feedback", ""),
87
+ }
88
+
89
+
90
+ def grade_multi_pass(image_path: str, question: str, max_marks: float,
91
+ criteria: list[dict], num_passes: int | None = None) -> list[dict]:
92
+ """Run NUM_PASSES grading invocations sequentially.
93
+
94
+ Sequential is intentional: the rate-limit module enforces a global
95
+ minimum gap between calls, so parallelism doesn't help and only
96
+ increases the risk of bursting past the per-minute quota.
97
+ """
98
+ n = num_passes or settings.grader_num_passes
99
+ return [grade_one_pass(image_path, question, max_marks, criteria) for _ in range(n)]
backend/grader/llm.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider-abstracted LLM factory.
2
+
3
+ Returns a langchain ChatModel for whichever provider is configured. Both
4
+ the agentic grader nodes and the OCR transcriber import from here, so
5
+ flipping `LLM_PROVIDER` in .env swaps the entire LLM backend in one place.
6
+
7
+ Supported providers:
8
+ - 'google' → Gemini via langchain-google-genai (FREE tier; recommended)
9
+ - 'anthropic' → Claude via langchain-anthropic (paid)
10
+
11
+ Key resolution is belt-and-braces:
12
+ - First consults `settings.<provider>_api_key` (loaded by pydantic-settings
13
+ from .env)
14
+ - Falls back directly to os.environ, so a manually-set shell variable
15
+ works even if pydantic-settings missed the file
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from langchain_core.language_models import BaseChatModel
22
+
23
+ from ..config import settings
24
+
25
+
26
+ _llm: BaseChatModel | None = None
27
+
28
+
29
+ def _resolve_key(field_value: str, env_var: str) -> str:
30
+ """Settings field first, then os.environ. Returns '' if neither has it."""
31
+ if field_value:
32
+ return field_value
33
+ return os.environ.get(env_var, "") or ""
34
+
35
+
36
+ def get_llm() -> BaseChatModel:
37
+ """Construct (and cache) the chat model for the configured provider."""
38
+ global _llm
39
+ if _llm is not None:
40
+ return _llm
41
+
42
+ provider = (settings.llm_provider or "google").lower()
43
+
44
+ if provider == "google":
45
+ from langchain_google_genai import ChatGoogleGenerativeAI
46
+ key = _resolve_key(settings.google_api_key, "GOOGLE_API_KEY")
47
+ if not key:
48
+ raise RuntimeError(
49
+ "LLM_PROVIDER=google but GOOGLE_API_KEY is empty.\n"
50
+ "Get a free key at https://aistudio.google.com/app/apikey "
51
+ "and set GOOGLE_API_KEY in your .env file (or shell env var).\n"
52
+ "Hit GET /debug/env to see what the backend is loading."
53
+ )
54
+ _llm = ChatGoogleGenerativeAI(
55
+ model=settings.grader_model_google,
56
+ google_api_key=key,
57
+ max_output_tokens=2048,
58
+ timeout=120,
59
+ # Free tier is 15 RPM — we use 5 parallel passes per crop.
60
+ max_retries=3,
61
+ )
62
+ return _llm
63
+
64
+ if provider == "anthropic":
65
+ from langchain_anthropic import ChatAnthropic
66
+ key = _resolve_key(settings.anthropic_api_key, "ANTHROPIC_API_KEY")
67
+ if not key:
68
+ raise RuntimeError(
69
+ "LLM_PROVIDER=anthropic but ANTHROPIC_API_KEY is empty. "
70
+ "Set it in .env or switch LLM_PROVIDER=google for the free tier."
71
+ )
72
+ _llm = ChatAnthropic(
73
+ model=settings.grader_model_anthropic,
74
+ max_tokens=2000,
75
+ api_key=key,
76
+ timeout=120,
77
+ )
78
+ return _llm
79
+
80
+ raise RuntimeError(
81
+ f"Unknown LLM_PROVIDER: {provider!r}. Use 'google' or 'anthropic'."
82
+ )
83
+
84
+
85
+ def reset_llm() -> None:
86
+ """Clear the cached LLM instance (used by tests when settings change)."""
87
+ global _llm
88
+ _llm = None
backend/grader/nodes.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The four agentic nodes wired together by graph.py.
2
+
3
+ Architecture: Extractor sees the image, Scorer sees only the extracted claims.
4
+ This separation is the structural defense against the LLM "fixing" or
5
+ hallucinating intermediate steps that aren't on the page.
6
+
7
+ LLM calls go through `backend.grader.llm.get_llm()`, which returns
8
+ Gemini or Claude depending on settings.llm_provider. The node code does
9
+ not know or care which provider is in use.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from typing import Any
16
+
17
+ from langchain_core.messages import HumanMessage, SystemMessage
18
+
19
+ from ..config import settings
20
+ from . import prompts
21
+ from .llm import get_llm
22
+ from .rate_limit import wait_turn
23
+ from .state import GradingState
24
+
25
+
26
+ # LLM is obtained from .llm.get_llm() (provider-agnostic factory).
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # JSON helpers — every node returns strict JSON, so a robust parser earns
31
+ # its keep when the model occasionally wraps in fences.
32
+ # ---------------------------------------------------------------------------
33
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
34
+
35
+
36
+ def parse_json(raw: str) -> dict | None:
37
+ if not raw:
38
+ return None
39
+ cleaned = _FENCE_RE.sub("", raw.strip()).strip()
40
+ # If the model added prose around the JSON, try to grab the outermost {...}
41
+ try:
42
+ return json.loads(cleaned)
43
+ except json.JSONDecodeError:
44
+ start = cleaned.find("{")
45
+ end = cleaned.rfind("}")
46
+ if start != -1 and end != -1 and end > start:
47
+ try:
48
+ return json.loads(cleaned[start:end + 1])
49
+ except json.JSONDecodeError:
50
+ return None
51
+ return None
52
+
53
+
54
+ def _invoke_text(system: str, user_content: list[dict] | str) -> str:
55
+ """Call the LLM with a text-only or multimodal user message."""
56
+ wait_turn()
57
+ llm = get_llm()
58
+ if isinstance(user_content, str):
59
+ msgs = [SystemMessage(content=system), HumanMessage(content=user_content)]
60
+ else:
61
+ msgs = [SystemMessage(content=system), HumanMessage(content=user_content)]
62
+ response = llm.invoke(msgs)
63
+ return response.content if isinstance(response.content, str) else str(response.content)
64
+
65
+
66
+ # ===========================================================================
67
+ # Node 1: Extractor — sees the IMAGE, produces transcript + claims
68
+ # ===========================================================================
69
+ def extractor_node(state: GradingState) -> dict:
70
+ user = prompts.EXTRACTOR_USER.format(question=state["question"])
71
+ content = [
72
+ {
73
+ "type": "image_url",
74
+ "image_url": {"url": f"data:{state.get('media_type', 'image/jpeg')};base64,{state['image_b64']}"},
75
+ },
76
+ {"type": "text", "text": user},
77
+ ]
78
+ raw = _invoke_text(prompts.EXTRACTOR_SYSTEM, content)
79
+ parsed = parse_json(raw) or {}
80
+ return {
81
+ "transcript": parsed.get("transcript", ""),
82
+ "claims": parsed.get("claims", []) or [],
83
+ "global_notes": parsed.get("global_notes", ""),
84
+ }
85
+
86
+
87
+ # ===========================================================================
88
+ # Node 2: Scorer — sees ONLY the extracted claims, not the image
89
+ # ===========================================================================
90
+ def scorer_node(state: GradingState) -> dict:
91
+ user = prompts.SCORER_USER.format(
92
+ question=state["question"],
93
+ max_marks=state["max_marks"],
94
+ criteria_block=prompts.format_criteria(state["criteria"]),
95
+ claims_block=prompts.format_claims(state.get("claims", [])),
96
+ critic_feedback=state.get("critic_feedback") or "(none — this is the first attempt)",
97
+ )
98
+ raw = _invoke_text(prompts.SCORER_SYSTEM, user)
99
+ parsed = parse_json(raw) or {}
100
+ per_criterion = parsed.get("per_criterion", []) or []
101
+ # Defensive: clamp awarded values inside [0, max]
102
+ for pc in per_criterion:
103
+ try:
104
+ pc["awarded"] = max(0.0, min(float(pc.get("awarded", 0)), float(pc.get("max", 0))))
105
+ pc["max"] = float(pc.get("max", 0))
106
+ except (TypeError, ValueError):
107
+ pc["awarded"] = 0.0
108
+ total = sum(pc.get("awarded", 0) for pc in per_criterion)
109
+ return {
110
+ "per_criterion": per_criterion,
111
+ "score": total,
112
+ "max_score": float(parsed.get("max_score") or state["max_marks"]),
113
+ }
114
+
115
+
116
+ # ===========================================================================
117
+ # Node 3: Justifier — writes the natural-language explanation for TAs
118
+ # ===========================================================================
119
+ def justifier_node(state: GradingState) -> dict:
120
+ user = prompts.JUSTIFIER_USER.format(
121
+ question=state["question"],
122
+ claims_block=prompts.format_claims(state.get("claims", [])),
123
+ scoring_block=prompts.format_scoring(state.get("per_criterion", [])),
124
+ )
125
+ raw = _invoke_text(prompts.JUSTIFIER_SYSTEM, user)
126
+ parsed = parse_json(raw) or {}
127
+ return {
128
+ "justification": parsed.get("justification", "(no justification generated)"),
129
+ "flags": parsed.get("flags", []) or [],
130
+ }
131
+
132
+
133
+ # ===========================================================================
134
+ # Node 4: Critic — audits whether awarded points are claim-supported
135
+ # ===========================================================================
136
+ def critic_node(state: GradingState) -> dict:
137
+ user = prompts.CRITIC_USER.format(
138
+ question=state["question"],
139
+ claims_block=prompts.format_claims(state.get("claims", [])),
140
+ scoring_block=prompts.format_scoring(state.get("per_criterion", [])),
141
+ justification=state.get("justification", ""),
142
+ )
143
+ raw = _invoke_text(prompts.CRITIC_SYSTEM, user)
144
+ parsed = parse_json(raw) or {}
145
+ passed = bool(parsed.get("passed", True))
146
+ feedback = parsed.get("feedback", "") if not passed else ""
147
+ return {
148
+ "critic_passed": passed,
149
+ "critic_feedback": feedback,
150
+ "retry_count": state.get("retry_count", 0) + (0 if passed else 1),
151
+ }
152
+
153
+
154
+ # ===========================================================================
155
+ # Conditional router — decides whether to retry the Scorer
156
+ # ===========================================================================
157
+ def critic_router(state: GradingState) -> str:
158
+ """Return the name of the next node to run, or '__end__' to finish."""
159
+ from langgraph.graph import END
160
+
161
+ if state.get("critic_passed", True):
162
+ return END
163
+ if state.get("retry_count", 0) > settings.grader_critic_retry:
164
+ # Bail out after configured retries so we never loop forever
165
+ return END
166
+ return "scorer"
backend/grader/prompts.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """All prompts used by the agentic grader.
2
+
3
+ Per Vanhoyweghen et al. (2026), the prompts are THE most iterated artifact in
4
+ the pipeline. They are kept here, separate from logic, so a non-engineer can
5
+ tune them. Every change should bump PROMPT_VERSION in config so audit trails
6
+ stay honest.
7
+ """
8
+
9
+ # ---- Course-level anti-hallucination guard (used by every node) ------------
10
+ COURSE_GUARD = """Base all scoring strictly on evidence visible in the student's handwritten work. If evidence is missing, assign zero — do not guess, infer, or hallucinate.
11
+ Award credit for intermediate steps only if they are explicitly written by the student. Do not infer non-trivial reasoning from a correct final answer except for trivial algebraic simplifications.
12
+ Be aware of nonsensical segments; apply partial credit only to valid, supported steps. Do not award credit for reasoning that would not imply the subsequent result.
13
+ If the student's solution does not match the question being asked, assign a score of 0."""
14
+
15
+
16
+ # ---- Node 1: Extractor -----------------------------------------------------
17
+ # Forces the model to enumerate what it *sees* before any scoring decision.
18
+ # This is the structural defense against over-optimism (Paper 2's main failure mode).
19
+ EXTRACTOR_SYSTEM = """You are the EXTRACTOR node of a handwriting-grading pipeline. Your only job is to look at the image and produce a faithful, literal transcription of what the student wrote, broken into atomic steps.
20
+
21
+ {course_guard}
22
+
23
+ You do not score. You do not judge. You only describe.
24
+ You output strict JSON only — no markdown, no commentary outside the JSON.""".format(course_guard=COURSE_GUARD)
25
+
26
+
27
+ EXTRACTOR_USER = """Question on the exam: {question}
28
+
29
+ The image below is one student's handwritten answer to the above question.
30
+
31
+ Your job:
32
+ 1. Read the handwriting literally. Do not correct mistakes.
33
+ 2. Break what the student wrote into a list of atomic claim-steps, in order.
34
+ 3. Note anything illegible, crossed out, or written outside the answer area.
35
+
36
+ Return EXACTLY this JSON schema and nothing else:
37
+ {{
38
+ "transcript": "<full literal transcription, line by line>",
39
+ "claims": [
40
+ {{"step": 1, "content": "<what the student wrote at this step, verbatim>", "concern": "<empty string, or one of: illegible, crossed_out, off_topic, partial>"}}
41
+ ],
42
+ "global_notes": "<any observations about layout, attempts, or non-mathematical content>"
43
+ }}"""
44
+
45
+
46
+ # ---- Node 2: Scorer --------------------------------------------------------
47
+ # Takes ONLY the extracted claims (text), not the image. This is deliberate:
48
+ # the scorer is forced to score what was extracted, not what it imagines.
49
+ SCORER_SYSTEM = """You are the SCORER node of a handwriting-grading pipeline. You receive a list of claims that the student wrote (already extracted by another node). You map each claim to the grading criteria and assign points.
50
+
51
+ {course_guard}
52
+
53
+ Important: you score ONLY what is in the claims list. If a criterion's required content is not present in any claim, that criterion gets ZERO points. Do not award sympathy points. Do not invent intermediate steps.
54
+
55
+ You output strict JSON only — no markdown, no commentary outside the JSON.""".format(course_guard=COURSE_GUARD)
56
+
57
+
58
+ SCORER_USER = """Question: {question}
59
+ Maximum marks: {max_marks}
60
+
61
+ GRADING CRITERIA:
62
+ {criteria_block}
63
+
64
+ PREVIOUS CRITIC FEEDBACK (apply this if non-empty):
65
+ {critic_feedback}
66
+
67
+ STUDENT'S CLAIMS (extracted from their handwriting):
68
+ {claims_block}
69
+
70
+ For each criterion, decide how many points are supported by the claims. Be strict.
71
+
72
+ Return EXACTLY this JSON schema:
73
+ {{
74
+ "per_criterion": [
75
+ {{"name": "<criterion name>", "awarded": <number, 0..max>, "max": <max for this criterion>, "reasoning": "<one short sentence citing claim numbers, e.g. 'claims 2 and 3 show F(x) correctly'>"}}
76
+ ],
77
+ "score": <sum of all awarded>,
78
+ "max_score": <sum of all max>
79
+ }}"""
80
+
81
+
82
+ # ---- Node 3: Justifier -----------------------------------------------------
83
+ # Writes a TA-facing natural-language explanation. This is what shows up in the
84
+ # review dashboard. It must be useful to a busy TA — concise, evidence-cited.
85
+ JUSTIFIER_SYSTEM = """You are the JUSTIFIER node. Given the student's claims and the criterion-by-criterion scoring, write a short, TA-facing explanation of the grade. Cite specific claim numbers where you can. Do not introduce new judgments.
86
+
87
+ You output strict JSON only.""".format()
88
+
89
+
90
+ JUSTIFIER_USER = """Question: {question}
91
+
92
+ CLAIMS:
93
+ {claims_block}
94
+
95
+ PER-CRITERION SCORES:
96
+ {scoring_block}
97
+
98
+ Write a 2-3 sentence justification a teaching assistant can read in 5 seconds. Cite claim numbers. Be neutral.
99
+
100
+ Return EXACTLY:
101
+ {{
102
+ "justification": "<2-3 sentences>",
103
+ "flags": ["<zero or more of: hallucination_risk, illegible, off_topic, ambiguous_evidence, partial_solution_only, blank>"]
104
+ }}"""
105
+
106
+
107
+ # ---- Node 4: Critic --------------------------------------------------------
108
+ # Reads the (claims, scores, justification) tuple and decides whether to retry.
109
+ # The Critic checks: does the scoring actually follow from the claims? This is
110
+ # the structural guard against the model "fixing" the student's answer.
111
+ CRITIC_SYSTEM = """You are the CRITIC node. Your job is to audit the scoring for one specific failure mode: did the SCORER award points for criteria that are NOT actually supported by the EXTRACTED CLAIMS?
112
+
113
+ You do NOT re-grade. You only check internal consistency: every awarded point must be traceable to a specific claim. If you find awarded points with no supporting claim, you fail the check and request a retry with specific feedback.
114
+
115
+ You output strict JSON only.""".format()
116
+
117
+
118
+ CRITIC_USER = """Question: {question}
119
+
120
+ CLAIMS:
121
+ {claims_block}
122
+
123
+ PER-CRITERION SCORES (from the SCORER):
124
+ {scoring_block}
125
+
126
+ JUSTIFICATION (from the JUSTIFIER):
127
+ {justification}
128
+
129
+ Check each criterion. For each one with awarded > 0, is there a claim that actually supports it? Or did the scorer reward something not present in the claims?
130
+
131
+ Return EXACTLY:
132
+ {{
133
+ "passed": <true if all awarded points are supported by claims, false otherwise>,
134
+ "feedback": "<empty string if passed; otherwise concrete instructions to the scorer about which criteria are over-credited and why>"
135
+ }}"""
136
+
137
+
138
+ def format_criteria(criteria: list[dict]) -> str:
139
+ """Render a criteria list into the indented block used by the prompts."""
140
+ lines = []
141
+ for i, c in enumerate(criteria, 1):
142
+ lines.append(f"Criterion {i}: {c['name']} ({c['points']} pts)")
143
+ lines.append(f" Conditions: {c.get('conditions') or '(none)'}")
144
+ if c.get("accept_alternatives"):
145
+ lines.append(f" Accept also: {c['accept_alternatives']}")
146
+ if c.get("do_not_deduct_for"):
147
+ lines.append(f" Do not deduct for: {c['do_not_deduct_for']}")
148
+ lines.append("")
149
+ return "\n".join(lines).rstrip()
150
+
151
+
152
+ def format_claims(claims: list[dict]) -> str:
153
+ if not claims:
154
+ return "(no claims extracted)"
155
+ out = []
156
+ for c in claims:
157
+ line = f"Claim {c['step']}: {c['content']}"
158
+ if c.get("concern"):
159
+ line += f" [concern: {c['concern']}]"
160
+ out.append(line)
161
+ return "\n".join(out)
162
+
163
+
164
+ def format_scoring(per_criterion: list[dict]) -> str:
165
+ return "\n".join(
166
+ f"- {pc['name']}: {pc['awarded']}/{pc['max']} — {pc['reasoning']}"
167
+ for pc in per_criterion
168
+ )
backend/grader/rate_limit.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Process-wide rate limiter for all LLM calls.
2
+
3
+ Every call to the Gemini/Claude API — from the agentic grader nodes AND
4
+ from the OCR transcriber — passes through `wait_turn()`. We enforce a
5
+ minimum gap between calls (default 4.5s, configurable via .env), which
6
+ keeps us comfortably under the Gemini free-tier 15 RPM cap even when
7
+ several uploads land back-to-back.
8
+
9
+ This is deliberately a hard, global throttle rather than per-thread:
10
+ running 5 parallel grading passes would otherwise blow the per-minute
11
+ budget within a second.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ import time
17
+
18
+ from ..config import settings
19
+
20
+
21
+ _lock = threading.Lock()
22
+ _last_call_ts: float = 0.0
23
+
24
+
25
+ def wait_turn() -> None:
26
+ """Block until the configured minimum gap has elapsed since the last call."""
27
+ global _last_call_ts
28
+ gap = float(settings.llm_min_gap_seconds or 0.0)
29
+ if gap <= 0:
30
+ return
31
+ with _lock:
32
+ elapsed = time.monotonic() - _last_call_ts
33
+ if elapsed < gap:
34
+ time.sleep(gap - elapsed)
35
+ _last_call_ts = time.monotonic()
backend/grader/state.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared state that flows through the Langgraph nodes."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TypedDict, Optional
5
+
6
+
7
+ class GradingState(TypedDict, total=False):
8
+ # Inputs (set at graph entry)
9
+ image_b64: str
10
+ media_type: str
11
+ question: str
12
+ max_marks: float
13
+ criteria: list[dict]
14
+
15
+ # Set by Extractor
16
+ transcript: str
17
+ claims: list[dict]
18
+ global_notes: str
19
+
20
+ # Set by Scorer (may be overwritten on retry)
21
+ per_criterion: list[dict]
22
+ score: float
23
+ max_score: float
24
+
25
+ # Set by Justifier
26
+ justification: str
27
+ flags: list[str]
28
+
29
+ # Set by Critic
30
+ critic_passed: bool
31
+ critic_feedback: str
32
+
33
+ # Bookkeeping
34
+ retry_count: int
35
+ errors: list[str]
backend/ingestion/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .pdf_split import split_pdf_to_pages # noqa: F401
2
+ from .crop import detect_answer_regions, crop_region # noqa: F401
3
+ from .anonymize import anonymize_crop # noqa: F401
backend/ingestion/anonymize.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anonymize a crop by masking regions that may contain student identifiers.
2
+
3
+ Per Paper 2's privacy design, identifier regions are erased from the crop
4
+ BEFORE the image is sent to any external API. Two modes:
5
+
6
+ - 'top_strip' (default): paints over the top 8% of the image, where
7
+ name/ID bubbles typically appear on the GRADEOPS template.
8
+ - 'none': pass-through (use when you know the crop is already free of
9
+ identifiers — e.g. the crop was already taken from below the ID area).
10
+
11
+ In production the anonymizer should read bbox coordinates from the rubric
12
+ metadata so it knows exactly which sub-regions contain identifiers.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ from PIL import Image, ImageDraw
20
+
21
+
22
+ def anonymize_crop(crop_path: str, output_dir: str, mode: str = "top_strip") -> str:
23
+ """Anonymize a crop and write the result. Returns the new path."""
24
+ output_dir = Path(output_dir)
25
+ output_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ img = Image.open(crop_path).convert("RGB")
28
+ if mode == "none":
29
+ out = output_dir / f"anon_{uuid.uuid4().hex[:10]}.png"
30
+ img.save(str(out), format="PNG")
31
+ return str(out)
32
+
33
+ w, h = img.size
34
+ strip_h = int(h * 0.08)
35
+ draw = ImageDraw.Draw(img)
36
+ # Paint white over the top strip to remove any ID/name region
37
+ draw.rectangle([0, 0, w, strip_h], fill="white")
38
+ # Add a thin grey line as a visual marker that anonymization happened
39
+ draw.line([0, strip_h, w, strip_h], fill=(200, 200, 200), width=2)
40
+
41
+ out = output_dir / f"anon_{uuid.uuid4().hex[:10]}.png"
42
+ img.save(str(out), format="PNG")
43
+ return str(out)
backend/ingestion/crop.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Detect and crop answer regions from a page image.
2
+
3
+ Two modes:
4
+ - Template-marker mode: looks for fiducial markers (filled black squares)
5
+ at the corners of the answer sheet, then crops the rectangle they bound.
6
+ This is the production path, matching the Vanhoyweghen et al. (2026)
7
+ sheet layout (Figure 7 in that paper).
8
+ - Fallback mode: if no markers are found, treat the whole page as one
9
+ answer region. Useful for ad-hoc demos and for scans of arbitrary
10
+ exam papers that don't follow the GRADEOPS template.
11
+
12
+ For a fully production setup, the question-to-crop binding (Q1 vs Q2a) is
13
+ determined by detecting labeled regions inside the marker rectangle. Here
14
+ we expose a simple `detect_answer_regions` that returns one box per page
15
+ (the fallback), but the structure supports multi-region pages.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ import cv2
23
+ import numpy as np
24
+ from PIL import Image
25
+
26
+
27
+ def _find_corner_markers(gray: np.ndarray) -> list[tuple[int, int]] | None:
28
+ """Locate the four corner fiducial markers (filled black squares).
29
+
30
+ Returns four (x, y) centroids or None if fewer than 4 are found.
31
+ """
32
+ # Threshold to isolate dark regions
33
+ _, bw = cv2.threshold(gray, 60, 255, cv2.THRESH_BINARY_INV)
34
+ contours, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
35
+
36
+ h, w = gray.shape
37
+ img_area = h * w
38
+ candidates: list[tuple[int, int, int]] = [] # (x, y, area)
39
+
40
+ for c in contours:
41
+ x, y, cw, ch = cv2.boundingRect(c)
42
+ area = cw * ch
43
+ # Marker heuristics: ~square, small fraction of page, dark fill
44
+ if area < img_area * 0.0001 or area > img_area * 0.01:
45
+ continue
46
+ ratio = cw / max(ch, 1)
47
+ if not 0.7 < ratio < 1.4:
48
+ continue
49
+ # Mostly-filled black?
50
+ roi = bw[y:y + ch, x:x + cw]
51
+ fill = roi.mean() / 255.0
52
+ if fill < 0.6:
53
+ continue
54
+ candidates.append((x + cw // 2, y + ch // 2, area))
55
+
56
+ if len(candidates) < 4:
57
+ return None
58
+
59
+ # Pick the candidates closest to the four corners of the page.
60
+ corners = [(0, 0), (w, 0), (0, h), (w, h)]
61
+ chosen: list[tuple[int, int]] = []
62
+ used = set()
63
+ for cx, cy in corners:
64
+ best = None
65
+ best_d = float("inf")
66
+ for i, (x, y, _) in enumerate(candidates):
67
+ if i in used:
68
+ continue
69
+ d = (x - cx) ** 2 + (y - cy) ** 2
70
+ if d < best_d:
71
+ best_d = d
72
+ best = (i, x, y)
73
+ if best is None:
74
+ return None
75
+ used.add(best[0])
76
+ chosen.append((best[1], best[2]))
77
+ return chosen # [TL, TR, BL, BR]
78
+
79
+
80
+ def detect_answer_regions(page_image_path: str) -> list[dict]:
81
+ """Return a list of {bbox, question_id} for each detected answer region.
82
+
83
+ Falls back to a single full-page region if markers are absent.
84
+ """
85
+ img = cv2.imread(page_image_path, cv2.IMREAD_GRAYSCALE)
86
+ if img is None:
87
+ raise ValueError(f"Could not read image: {page_image_path}")
88
+ h, w = img.shape
89
+
90
+ markers = _find_corner_markers(img)
91
+ if markers is not None:
92
+ xs = [m[0] for m in markers]
93
+ ys = [m[1] for m in markers]
94
+ x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys)
95
+ # Pad inward 1% so the markers themselves aren't included
96
+ pad = int(min(w, h) * 0.01)
97
+ return [{
98
+ "bbox": {"x": x0 + pad, "y": y0 + pad,
99
+ "w": (x1 - x0) - 2 * pad, "h": (y1 - y0) - 2 * pad},
100
+ "question_id": "Q1",
101
+ }]
102
+
103
+ # Fallback: whole page is one answer region
104
+ return [{
105
+ "bbox": {"x": 0, "y": 0, "w": w, "h": h},
106
+ "question_id": "Q1",
107
+ }]
108
+
109
+
110
+ def crop_region(page_image_path: str, bbox: dict, output_dir: str) -> str:
111
+ """Crop the given bbox from the page image and save as PNG."""
112
+ output_dir = Path(output_dir)
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ img = Image.open(page_image_path)
116
+ x, y, w, h = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
117
+ crop = img.crop((x, y, x + w, y + h))
118
+ out = output_dir / f"crop_{uuid.uuid4().hex[:10]}.png"
119
+ crop.save(str(out), format="PNG")
120
+ return str(out)
backend/ingestion/pdf_split.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Split a multi-page PDF into per-page PNG images.
2
+
3
+ PyMuPDF handles all major PDF dialects including scanned exam papers.
4
+ Pages are rasterized at 200 DPI by default — high enough for handwriting
5
+ OCR, low enough to keep crops under a few hundred KB each.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ import fitz # PyMuPDF
13
+
14
+
15
+ def split_pdf_to_pages(pdf_path: str, output_dir: str, dpi: int = 200) -> list[str]:
16
+ """Rasterize each page of a PDF and write to output_dir.
17
+
18
+ Returns a list of page image paths in page order.
19
+ """
20
+ pdf_path = Path(pdf_path)
21
+ output_dir = Path(output_dir)
22
+ output_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ doc = fitz.open(pdf_path)
25
+ paths: list[str] = []
26
+ zoom = dpi / 72.0 # PyMuPDF default is 72 DPI
27
+ matrix = fitz.Matrix(zoom, zoom)
28
+ prefix = uuid.uuid4().hex[:8]
29
+
30
+ try:
31
+ for page_idx in range(doc.page_count):
32
+ page = doc.load_page(page_idx)
33
+ pix = page.get_pixmap(matrix=matrix, alpha=False)
34
+ out = output_dir / f"{pdf_path.stem}_{prefix}_p{page_idx + 1:03d}.png"
35
+ pix.save(str(out))
36
+ paths.append(str(out))
37
+ finally:
38
+ doc.close()
39
+ return paths
backend/main.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application: the API surface for the React frontend.
2
+
3
+ Routes:
4
+ /users bootstrap instructor + TA users for RBAC
5
+ /exams CRUD exams
6
+ /rubrics CRUD rubrics (immutable; updates create new versions)
7
+ /papers upload PDFs, kick off the full pipeline
8
+ /crops list crops + image bytes
9
+ /review review queue (sorted by std-dev DESC) + decision endpoint
10
+ /audit immutable audit log
11
+ /plagiarism pair list above threshold
12
+ /stats override-rate dashboard metrics
13
+ /debug/env diagnostic — what env vars actually loaded
14
+ /health liveness probe
15
+
16
+ Pipeline (kicked off on POST /papers/upload):
17
+ PDF split → answer-region crop → anonymize →
18
+ OCR transcribe → agentic grader 5x in parallel →
19
+ aggregate → plagiarism worker over this exam
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import uuid
26
+ from pathlib import Path
27
+
28
+ from fastapi import FastAPI, Depends, File, UploadFile, HTTPException, Form
29
+ from fastapi.middleware.cors import CORSMiddleware
30
+ from fastapi.responses import FileResponse
31
+ from sqlalchemy.orm import Session
32
+ from sqlalchemy import func
33
+
34
+ from .config import settings, _ENV_FILE
35
+ from .db import (
36
+ get_db, init_db, User, Exam, Rubric, Paper, Crop,
37
+ Grading, GradingAggregate, Review, PlagiarismFlag, AuditLog,
38
+ )
39
+ from . import schemas
40
+ from .audit import log_event
41
+ from .ingestion import split_pdf_to_pages, detect_answer_regions, crop_region, anonymize_crop
42
+ from .ocr import transcribe as ocr_transcribe
43
+ from .grader import grade_multi_pass, aggregate_scores
44
+ from .plagiarism import find_similar_pairs
45
+
46
+
47
+ app = FastAPI(title="GRADEOPS", version="0.1.0")
48
+
49
+ # Open CORS for the React dev server (Vite defaults to 5173).
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+
58
+ @app.on_event("startup")
59
+ def _startup():
60
+ init_db()
61
+ # Seed two default users so RBAC has something to point at.
62
+ from .db import SessionLocal
63
+ db = SessionLocal()
64
+ try:
65
+ if db.query(User).count() == 0:
66
+ db.add_all([
67
+ User(email="instructor@gradeops.local", name="Instructor", role="instructor"),
68
+ User(email="ta@gradeops.local", name="TA", role="ta"),
69
+ ])
70
+ db.commit()
71
+ finally:
72
+ db.close()
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Health + diagnostics
77
+ # ---------------------------------------------------------------------------
78
+ @app.get("/health")
79
+ def health():
80
+ return {
81
+ "status": "ok",
82
+ "provider": settings.llm_provider,
83
+ "model": settings.grader_model,
84
+ "ocr_backend": settings.ocr_backend,
85
+ }
86
+
87
+
88
+ @app.get("/debug/env")
89
+ def debug_env():
90
+ """Diagnostic — shows exactly what the backend loaded.
91
+ Remove or restrict by role in production."""
92
+ def safe(val: str) -> dict:
93
+ return {"len": len(val or ""), "prefix": ((val or "")[:6] + "...") if val else "(empty)"}
94
+
95
+ return {
96
+ "cwd": os.getcwd(),
97
+ "project_root": str(_ENV_FILE.parent),
98
+ "env_file_path": str(_ENV_FILE),
99
+ "env_file_exists": _ENV_FILE.exists(),
100
+ "settings_llm_provider": settings.llm_provider,
101
+ "settings_google_api_key": safe(settings.google_api_key),
102
+ "settings_anthropic_api_key": safe(settings.anthropic_api_key),
103
+ "os_environ_GOOGLE_API_KEY": safe(os.environ.get("GOOGLE_API_KEY", "")),
104
+ "os_environ_ANTHROPIC_API_KEY": safe(os.environ.get("ANTHROPIC_API_KEY", "")),
105
+ }
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Users
110
+ # ---------------------------------------------------------------------------
111
+ @app.get("/users")
112
+ def list_users(db: Session = Depends(get_db)):
113
+ return [{"id": u.id, "email": u.email, "name": u.name, "role": u.role}
114
+ for u in db.query(User).all()]
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Exams
119
+ # ---------------------------------------------------------------------------
120
+ @app.post("/exams", response_model=schemas.ExamOut)
121
+ def create_exam(payload: schemas.ExamCreate, db: Session = Depends(get_db)):
122
+ e = Exam(title=payload.title)
123
+ db.add(e)
124
+ db.commit()
125
+ db.refresh(e)
126
+ return e
127
+
128
+
129
+ @app.get("/exams", response_model=list[schemas.ExamOut])
130
+ def list_exams(db: Session = Depends(get_db)):
131
+ return db.query(Exam).order_by(Exam.created_at.desc()).all()
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Rubrics (immutable, versioned per exam)
136
+ # ---------------------------------------------------------------------------
137
+ @app.post("/rubrics", response_model=schemas.RubricOut)
138
+ def create_rubric(payload: schemas.RubricCreate, db: Session = Depends(get_db)):
139
+ exam = db.get(Exam, payload.exam_id)
140
+ if exam is None:
141
+ raise HTTPException(404, "exam not found")
142
+
143
+ existing = db.query(Rubric).filter(Rubric.exam_id == exam.id).count()
144
+ version = f"v{existing + 1}"
145
+
146
+ r = Rubric(
147
+ exam_id=exam.id,
148
+ version=version,
149
+ title=payload.title,
150
+ question_text=payload.question_text,
151
+ max_marks=payload.max_marks,
152
+ course_instructions=payload.course_instructions,
153
+ criteria=[c.model_dump() for c in payload.criteria],
154
+ )
155
+ db.add(r)
156
+ db.commit()
157
+ db.refresh(r)
158
+ log_event(db, "rubric", r.id, "create",
159
+ after={"version": version, "title": r.title},
160
+ rubric_version=version)
161
+ db.commit()
162
+ return _rubric_to_out(r)
163
+
164
+
165
+ @app.get("/rubrics", response_model=list[schemas.RubricOut])
166
+ def list_rubrics(exam_id: int | None = None, db: Session = Depends(get_db)):
167
+ q = db.query(Rubric)
168
+ if exam_id is not None:
169
+ q = q.filter(Rubric.exam_id == exam_id)
170
+ return [_rubric_to_out(r) for r in q.order_by(Rubric.created_at.desc()).all()]
171
+
172
+
173
+ @app.get("/rubrics/{rubric_id}", response_model=schemas.RubricOut)
174
+ def get_rubric(rubric_id: int, db: Session = Depends(get_db)):
175
+ r = db.get(Rubric, rubric_id)
176
+ if r is None:
177
+ raise HTTPException(404, "rubric not found")
178
+ return _rubric_to_out(r)
179
+
180
+
181
+ def _rubric_to_out(r: Rubric) -> schemas.RubricOut:
182
+ return schemas.RubricOut(
183
+ id=r.id, exam_id=r.exam_id, version=r.version, title=r.title,
184
+ question_text=r.question_text, max_marks=r.max_marks,
185
+ course_instructions=r.course_instructions,
186
+ criteria=[schemas.Criterion(**c) for c in (r.criteria or [])],
187
+ created_at=r.created_at,
188
+ )
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Papers — upload + run pipeline
193
+ # ---------------------------------------------------------------------------
194
+ @app.post("/papers/upload")
195
+ def upload_papers(
196
+ exam_id: int = Form(...),
197
+ rubric_id: int = Form(...),
198
+ file: UploadFile = File(...),
199
+ student_anon_id: str | None = Form(default=None),
200
+ db: Session = Depends(get_db),
201
+ ):
202
+ """Upload one PDF (or one image acting as a single-page PDF).
203
+
204
+ Runs the full pipeline synchronously and returns the resulting crop ids.
205
+ In production this should move to a background task; for the demo,
206
+ synchronous keeps the trace easy to follow.
207
+ """
208
+ exam = db.get(Exam, exam_id)
209
+ rubric = db.get(Rubric, rubric_id)
210
+ if exam is None or rubric is None:
211
+ raise HTTPException(404, "exam or rubric not found")
212
+
213
+ storage = settings.storage_path
214
+ anon_id = student_anon_id or f"STU-{uuid.uuid4().hex[:6].upper()}"
215
+
216
+ # 1. Save uploaded file
217
+ suffix = Path(file.filename or "upload.pdf").suffix.lower() or ".pdf"
218
+ upload_path = storage / "pdfs" / f"{anon_id}_{uuid.uuid4().hex[:8]}{suffix}"
219
+ with upload_path.open("wb") as f:
220
+ shutil.copyfileobj(file.file, f)
221
+
222
+ paper = Paper(exam_id=exam.id, student_anon_id=anon_id, source_pdf_path=str(upload_path))
223
+ db.add(paper)
224
+ db.commit()
225
+ db.refresh(paper)
226
+
227
+ # 2. Split into pages
228
+ if suffix == ".pdf":
229
+ page_paths = split_pdf_to_pages(str(upload_path), str(storage / "pages"))
230
+ else:
231
+ page_paths = [str(upload_path)]
232
+
233
+ crop_records: list[Crop] = []
234
+
235
+ for page_path in page_paths:
236
+ # 3. Detect answer regions (with whole-page fallback)
237
+ regions = detect_answer_regions(page_path)
238
+ for region in regions:
239
+ # 4. Crop + 5. Anonymize
240
+ raw_crop = crop_region(page_path, region["bbox"], str(storage / "crops"))
241
+ anon = anonymize_crop(raw_crop, str(storage / "crops"))
242
+
243
+ crop = Crop(
244
+ paper_id=paper.id,
245
+ question_id=region["question_id"],
246
+ rubric_id=rubric.id,
247
+ image_path=raw_crop,
248
+ anonymized_path=anon,
249
+ bbox=region["bbox"],
250
+ )
251
+ db.add(crop)
252
+ db.commit()
253
+ db.refresh(crop)
254
+
255
+ # 6. OCR transcribe (canonical transcript for plagiarism + search)
256
+ try:
257
+ transcript = ocr_transcribe(anon)
258
+ except Exception as e:
259
+ transcript = f"[OCR error: {e}]"
260
+
261
+ # 7. Run the agentic grader NUM_PASSES times in parallel
262
+ passes = grade_multi_pass(
263
+ anon,
264
+ question=rubric.question_text,
265
+ max_marks=rubric.max_marks,
266
+ criteria=rubric.criteria,
267
+ )
268
+
269
+ # 8. Persist gradings + aggregate
270
+ for i, p in enumerate(passes, 1):
271
+ g = Grading(
272
+ crop_id=crop.id,
273
+ rubric_id=rubric.id,
274
+ pass_num=i,
275
+ score=p["score"],
276
+ max_score=p["max_score"],
277
+ per_criterion=p["per_criterion"],
278
+ justification=p["justification"],
279
+ transcript=transcript,
280
+ flags=p["flags"],
281
+ critic_passed=p["critic_passed"],
282
+ critic_feedback=p["critic_feedback"],
283
+ model_version=settings.grader_model,
284
+ prompt_version=settings.prompt_version,
285
+ )
286
+ db.add(g)
287
+
288
+ agg = aggregate_scores(passes)
289
+ db.add(GradingAggregate(
290
+ crop_id=crop.id,
291
+ median=agg["median"],
292
+ max_score=agg["max_score"],
293
+ min_score=agg["min_score"],
294
+ std_dev=agg["std_dev"],
295
+ n_passes=agg["n_passes"],
296
+ ))
297
+ log_event(db, "grading", crop.id, "create",
298
+ after={"median": agg["median"], "std_dev": agg["std_dev"]},
299
+ rubric_version=rubric.version)
300
+ db.commit()
301
+ crop_records.append(crop)
302
+
303
+ # 9. Plagiarism worker across all crops in this exam
304
+ try:
305
+ _refresh_plagiarism(db, exam_id=exam.id)
306
+ except Exception as e:
307
+ print(f"[plagiarism] failed: {e}")
308
+
309
+ return {
310
+ "paper_id": paper.id,
311
+ "student_anon_id": anon_id,
312
+ "crop_ids": [c.id for c in crop_records],
313
+ }
314
+
315
+
316
+ def _refresh_plagiarism(db: Session, exam_id: int) -> None:
317
+ """Recompute similarity pairs across all crops in this exam."""
318
+ rows = (
319
+ db.query(Crop.id, Grading.transcript)
320
+ .join(Grading, Grading.crop_id == Crop.id)
321
+ .join(Paper, Paper.id == Crop.paper_id)
322
+ .filter(Paper.exam_id == exam_id, Grading.pass_num == 1)
323
+ .all()
324
+ )
325
+ transcripts = [(cid, t) for cid, t in rows if t]
326
+ if len(transcripts) < 2:
327
+ return
328
+
329
+ pairs = find_similar_pairs(transcripts)
330
+ for a, b, s in pairs:
331
+ existing = db.query(PlagiarismFlag).filter_by(crop_a_id=a, crop_b_id=b).first()
332
+ if existing:
333
+ existing.similarity = s
334
+ else:
335
+ db.add(PlagiarismFlag(crop_a_id=a, crop_b_id=b, similarity=s))
336
+ db.commit()
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Crops + images
341
+ # ---------------------------------------------------------------------------
342
+ @app.get("/crops/{crop_id}/image")
343
+ def get_crop_image(crop_id: int, db: Session = Depends(get_db)):
344
+ crop = db.get(Crop, crop_id)
345
+ if crop is None:
346
+ raise HTTPException(404, "crop not found")
347
+ path = crop.anonymized_path
348
+ if not Path(path).exists():
349
+ raise HTTPException(404, "image file missing")
350
+ return FileResponse(path)
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Review queue
355
+ # ---------------------------------------------------------------------------
356
+ @app.get("/review/queue", response_model=list[schemas.ReviewQueueItem])
357
+ def review_queue(
358
+ exam_id: int | None = None,
359
+ only_pending: bool = True,
360
+ db: Session = Depends(get_db),
361
+ ):
362
+ """Return crops sorted by std-dev DESC (highest uncertainty first)."""
363
+ q = (
364
+ db.query(Crop, GradingAggregate, Paper, Rubric)
365
+ .join(GradingAggregate, GradingAggregate.crop_id == Crop.id)
366
+ .join(Paper, Paper.id == Crop.paper_id)
367
+ .join(Rubric, Rubric.id == Crop.rubric_id)
368
+ )
369
+ if exam_id is not None:
370
+ q = q.filter(Paper.exam_id == exam_id)
371
+
372
+ items: list[schemas.ReviewQueueItem] = []
373
+ for crop, agg, paper, rubric in q.order_by(GradingAggregate.std_dev.desc()).all():
374
+ latest_review = (
375
+ db.query(Review)
376
+ .filter(Review.crop_id == crop.id)
377
+ .order_by(Review.created_at.desc())
378
+ .first()
379
+ )
380
+ if only_pending and latest_review is not None:
381
+ continue
382
+
383
+ gradings = (
384
+ db.query(Grading)
385
+ .filter(Grading.crop_id == crop.id)
386
+ .order_by(Grading.pass_num.asc())
387
+ .all()
388
+ )
389
+
390
+ flagged = (
391
+ db.query(PlagiarismFlag)
392
+ .filter((PlagiarismFlag.crop_a_id == crop.id) | (PlagiarismFlag.crop_b_id == crop.id))
393
+ .first()
394
+ is not None
395
+ )
396
+
397
+ items.append(schemas.ReviewQueueItem(
398
+ crop_id=crop.id,
399
+ paper_id=paper.id,
400
+ student_anon_id=paper.student_anon_id,
401
+ question_id=crop.question_id,
402
+ rubric_id=rubric.id,
403
+ rubric_title=rubric.title,
404
+ image_path=f"/crops/{crop.id}/image",
405
+ aggregate=schemas.AggregateOut(
406
+ crop_id=crop.id,
407
+ median=agg.median,
408
+ max_score=agg.max_score,
409
+ min_score=agg.min_score,
410
+ std_dev=agg.std_dev,
411
+ n_passes=agg.n_passes,
412
+ ),
413
+ gradings=[schemas.GradingOut.model_validate(g) for g in gradings],
414
+ plagiarism_flagged=flagged,
415
+ reviewed=latest_review is not None,
416
+ final_score=latest_review.final_score if latest_review else None,
417
+ ))
418
+ return items
419
+
420
+
421
+ @app.post("/reviews", response_model=schemas.ReviewOut)
422
+ def submit_review(payload: schemas.ReviewCreate, db: Session = Depends(get_db)):
423
+ crop = db.get(Crop, payload.crop_id)
424
+ if crop is None:
425
+ raise HTTPException(404, "crop not found")
426
+
427
+ agg = db.get(GradingAggregate, payload.crop_id)
428
+ ai_score = agg.median if agg else 0.0
429
+ rubric = db.get(Rubric, crop.rubric_id)
430
+
431
+ r = Review(
432
+ crop_id=payload.crop_id,
433
+ reviewer_id=payload.reviewer_id,
434
+ action=payload.action,
435
+ ai_score=ai_score,
436
+ final_score=payload.final_score,
437
+ notes=payload.notes,
438
+ )
439
+ db.add(r)
440
+ db.commit()
441
+ db.refresh(r)
442
+
443
+ log_event(
444
+ db, "review", r.id, payload.action,
445
+ actor_id=payload.reviewer_id,
446
+ before={"ai_score": ai_score},
447
+ after={"final_score": payload.final_score, "notes": payload.notes},
448
+ rubric_version=rubric.version if rubric else None,
449
+ )
450
+ db.commit()
451
+ return r
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Audit + plagiarism views
456
+ # ---------------------------------------------------------------------------
457
+ @app.get("/audit", response_model=list[schemas.AuditEntry])
458
+ def list_audit(limit: int = 200, db: Session = Depends(get_db)):
459
+ return db.query(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).all()
460
+
461
+
462
+ @app.get("/plagiarism", response_model=list[schemas.PlagiarismPair])
463
+ def list_plagiarism(db: Session = Depends(get_db)):
464
+ return db.query(PlagiarismFlag).order_by(PlagiarismFlag.similarity.desc()).all()
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Stats — surfaces the metrics dashboard
469
+ # ---------------------------------------------------------------------------
470
+ @app.get("/stats")
471
+ def get_stats(db: Session = Depends(get_db)):
472
+ total_crops = db.query(Crop).count()
473
+ total_reviewed = db.query(Review).count()
474
+ overrides = db.query(Review).filter(Review.action == "override").count()
475
+ flags = db.query(Review).filter(Review.action == "flag").count()
476
+ mean_std = db.query(func.avg(GradingAggregate.std_dev)).scalar() or 0.0
477
+
478
+ override_rows = db.query(Review).filter(Review.action == "override").all()
479
+ deltas = [abs(r.final_score - r.ai_score) for r in override_rows]
480
+ mean_delta = sum(deltas) / len(deltas) if deltas else 0.0
481
+
482
+ return {
483
+ "total_crops": total_crops,
484
+ "total_reviewed": total_reviewed,
485
+ "override_rate": overrides / total_reviewed if total_reviewed else 0.0,
486
+ "flag_rate": flags / total_reviewed if total_reviewed else 0.0,
487
+ "mean_std_dev": float(mean_std),
488
+ "mean_override_delta": float(mean_delta),
489
+ "plagiarism_pairs": db.query(PlagiarismFlag).count(),
490
+ }
backend/ocr/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .router import transcribe # noqa: F401
backend/ocr/hosted_adapter.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hosted vision-API OCR adapter — passes through the shared LLM factory.
2
+
3
+ Uses whichever LLM is configured in settings.llm_provider (Gemini by
4
+ default — free tier — or Claude). Runs on any laptop with no GPU.
5
+ Throttled through the shared rate limiter so OCR calls cannot blow past
6
+ the per-minute quota when followed by grading calls.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from pathlib import Path
12
+
13
+ from langchain_core.messages import HumanMessage
14
+
15
+ from ..grader.llm import get_llm
16
+ from ..grader.rate_limit import wait_turn
17
+
18
+
19
+ TRANSCRIBE_PROMPT = (
20
+ "You are an OCR system specialized in handwritten student exam answers — "
21
+ "math equations, derivations, prose, diagrams.\n\n"
22
+ "Transcribe the handwritten content in the image LITERALLY. Do not correct "
23
+ "mistakes. Do not interpret intent. Preserve line breaks. Use LaTeX for "
24
+ "math expressions. Mark illegible segments as [illegible].\n\n"
25
+ "Output the transcript only — no preamble, no commentary, no markdown fences."
26
+ )
27
+
28
+
29
+ def transcribe(image_path: str) -> str:
30
+ """Transcribe handwritten content via the configured chat model."""
31
+ wait_turn()
32
+ img_bytes = Path(image_path).read_bytes()
33
+ img_b64 = base64.b64encode(img_bytes).decode("ascii")
34
+ suffix = Path(image_path).suffix.lower().lstrip(".")
35
+ media_type = "image/jpeg" if suffix in ("jpg", "jpeg") else f"image/{suffix or 'jpeg'}"
36
+ data_url = f"data:{media_type};base64,{img_b64}"
37
+
38
+ llm = get_llm()
39
+ msg = HumanMessage(content=[
40
+ {"type": "image_url", "image_url": {"url": data_url}},
41
+ {"type": "text", "text": TRANSCRIBE_PROMPT},
42
+ ])
43
+ response = llm.invoke([msg])
44
+ content = response.content
45
+ return content if isinstance(content, str) else str(content)
backend/ocr/qwen_vl_adapter.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen2-VL adapter via HuggingFace transformers.
2
+
3
+ This is the production-target adapter the brief asks for. It is intentionally
4
+ behind a lazy import so the demo runs without GPU dependencies installed.
5
+
6
+ To enable:
7
+ 1. Uncomment transformers / torch / accelerate in requirements.txt
8
+ 2. Install on a GPU host
9
+ 3. Set OCR_BACKEND=qwen_vl in .env
10
+
11
+ On a CPU-only machine the model will load extremely slowly. For Nougat
12
+ (a math-specialised alternative), swap Qwen2-VL-7B for facebook/nougat-base
13
+ and update the processor calls accordingly.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+
20
+ _model = None
21
+ _processor = None
22
+
23
+
24
+ def _load_model():
25
+ """Lazy load Qwen2-VL. Raises an informative error if HF deps aren't present."""
26
+ global _model, _processor
27
+ if _model is not None:
28
+ return _model, _processor
29
+
30
+ try:
31
+ import torch # noqa: F401
32
+ from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
33
+ except ImportError as e:
34
+ raise RuntimeError(
35
+ "Qwen-VL adapter requires `transformers` and `torch`. "
36
+ "Either install them (see requirements.txt comments) or set "
37
+ "OCR_BACKEND=hosted in your .env to use the hosted Claude vision adapter."
38
+ ) from e
39
+
40
+ model_id = "Qwen/Qwen2-VL-7B-Instruct"
41
+ _processor = AutoProcessor.from_pretrained(model_id)
42
+ _model = Qwen2VLForConditionalGeneration.from_pretrained(
43
+ model_id, torch_dtype="auto", device_map="auto"
44
+ )
45
+ return _model, _processor
46
+
47
+
48
+ TRANSCRIBE_PROMPT = (
49
+ "You are an OCR system specialised in handwritten student exam answers, "
50
+ "including math, derivations and free text. Transcribe the handwritten "
51
+ "content in the image LITERALLY. Use LaTeX for math. Preserve line breaks. "
52
+ "Mark illegible segments as [illegible]. Output the transcript only."
53
+ )
54
+
55
+
56
+ def transcribe(image_path: str) -> str:
57
+ from PIL import Image
58
+
59
+ model, processor = _load_model()
60
+ image = Image.open(image_path).convert("RGB")
61
+
62
+ messages = [{
63
+ "role": "user",
64
+ "content": [
65
+ {"type": "image", "image": image},
66
+ {"type": "text", "text": TRANSCRIBE_PROMPT},
67
+ ],
68
+ }]
69
+ text_prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
70
+ inputs = processor(text=[text_prompt], images=[image], return_tensors="pt").to(model.device)
71
+
72
+ out_ids = model.generate(**inputs, max_new_tokens=1500)
73
+ out_trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, out_ids)]
74
+ text = processor.batch_decode(out_trimmed, skip_special_tokens=True)[0]
75
+ return text.strip()
backend/ocr/router.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OCR router. Picks an adapter based on settings.ocr_backend.
2
+
3
+ This is the abstraction layer mentioned in the architecture diagram: any
4
+ new OCR model (Nougat, TrOCR, a fine-tuned model) plugs in by adding a new
5
+ adapter and a new branch here. The grader and the rest of the pipeline
6
+ don't change.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ..config import settings
11
+
12
+
13
+ def transcribe(image_path: str) -> str:
14
+ """Dispatch to the configured OCR adapter."""
15
+ backend = (settings.ocr_backend or "hosted").lower()
16
+ if backend == "qwen_vl":
17
+ from . import qwen_vl_adapter
18
+ return qwen_vl_adapter.transcribe(image_path)
19
+ # default
20
+ from . import hosted_adapter
21
+ return hosted_adapter.transcribe(image_path)
backend/plagiarism.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plagiarism flagging via sentence-embedding similarity.
2
+
3
+ The brief calls for flagging "highly similar logic structures across
4
+ papers." We do this on the OCR transcripts (not the raw images) because:
5
+ - handwriting varies even when reasoning is copied, so image similarity
6
+ is the wrong signal
7
+ - transcripts capture the LOGIC the student used
8
+ - embedding similarity catches paraphrases that string matching misses
9
+
10
+ This is a background worker. It is intentionally simple: O(n^2) cosine
11
+ similarity, with a threshold check. For real exam scale (thousands of
12
+ papers per exam) you would FAISS-index the embeddings.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from sentence_transformers import SentenceTransformer
17
+ from sklearn.metrics.pairwise import cosine_similarity
18
+ import numpy as np
19
+
20
+ from .config import settings
21
+
22
+
23
+ _model: SentenceTransformer | None = None
24
+
25
+
26
+ def _get_model() -> SentenceTransformer:
27
+ global _model
28
+ if _model is None:
29
+ _model = SentenceTransformer(settings.embedding_model)
30
+ return _model
31
+
32
+
33
+ def find_similar_pairs(
34
+ transcripts: list[tuple[int, str]],
35
+ threshold: float | None = None,
36
+ ) -> list[tuple[int, int, float]]:
37
+ """Return all (crop_a_id, crop_b_id, similarity) tuples above the threshold.
38
+
39
+ transcripts: list of (crop_id, transcript_text) tuples.
40
+ """
41
+ if len(transcripts) < 2:
42
+ return []
43
+
44
+ threshold = threshold if threshold is not None else settings.plagiarism_threshold
45
+ ids = [t[0] for t in transcripts]
46
+ texts = [t[1] or "" for t in transcripts]
47
+
48
+ model = _get_model()
49
+ embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
50
+
51
+ sim = cosine_similarity(embeddings)
52
+ pairs: list[tuple[int, int, float]] = []
53
+ n = len(ids)
54
+ for i in range(n):
55
+ for j in range(i + 1, n):
56
+ s = float(sim[i, j])
57
+ if s >= threshold:
58
+ # Always store with a < b for the uniqueness constraint
59
+ a, b = sorted((ids[i], ids[j]))
60
+ pairs.append((a, b, s))
61
+ pairs.sort(key=lambda p: p[2], reverse=True)
62
+ return pairs
backend/schemas.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas — the contract between frontend, API, and grader.
2
+
3
+ The Criterion / Rubric schemas implement the design principles from
4
+ Vanhoyweghen et al. (2026): fine-grained criteria with explicit alternatives
5
+ and explicit do-not-deduct conditions, so the LLM has no room to invent
6
+ its own interpretation.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from datetime import datetime
12
+ from pydantic import BaseModel, Field, ConfigDict
13
+
14
+
15
+ # --- Rubric ----------------------------------------------------------------
16
+ class Criterion(BaseModel):
17
+ name: str
18
+ points: float = Field(..., ge=0)
19
+ conditions: str = Field(..., description="What must be present in the answer to earn these points.")
20
+ accept_alternatives: str = Field("", description="Equivalent forms to also accept.")
21
+ do_not_deduct_for: str = Field("", description="Surface issues to ignore.")
22
+
23
+
24
+ class RubricCreate(BaseModel):
25
+ exam_id: int
26
+ title: str
27
+ question_text: str
28
+ max_marks: float
29
+ course_instructions: str
30
+ criteria: list[Criterion]
31
+
32
+
33
+ class RubricOut(BaseModel):
34
+ id: int
35
+ exam_id: int
36
+ version: str
37
+ title: str
38
+ question_text: str
39
+ max_marks: float
40
+ course_instructions: str
41
+ criteria: list[Criterion]
42
+ created_at: datetime
43
+ model_config = ConfigDict(from_attributes=True)
44
+
45
+
46
+ # --- Exam ------------------------------------------------------------------
47
+ class ExamCreate(BaseModel):
48
+ title: str
49
+
50
+
51
+ class ExamOut(BaseModel):
52
+ id: int
53
+ title: str
54
+ created_at: datetime
55
+ model_config = ConfigDict(from_attributes=True)
56
+
57
+
58
+ # --- Paper / Crop ----------------------------------------------------------
59
+ class CropOut(BaseModel):
60
+ id: int
61
+ paper_id: int
62
+ question_id: str
63
+ rubric_id: int
64
+ image_path: str
65
+ anonymized_path: str
66
+ created_at: datetime
67
+ model_config = ConfigDict(from_attributes=True)
68
+
69
+
70
+ # --- Grading ---------------------------------------------------------------
71
+ class PerCriterionScore(BaseModel):
72
+ name: str
73
+ awarded: float
74
+ max: float
75
+ reasoning: str
76
+
77
+
78
+ class GradingOut(BaseModel):
79
+ id: int
80
+ crop_id: int
81
+ pass_num: int
82
+ score: float
83
+ max_score: float
84
+ per_criterion: list[PerCriterionScore]
85
+ justification: str
86
+ transcript: str
87
+ flags: list[str]
88
+ critic_passed: bool
89
+ critic_feedback: str | None
90
+ model_version: str
91
+ prompt_version: str
92
+ created_at: datetime
93
+ model_config = ConfigDict(from_attributes=True)
94
+
95
+
96
+ class AggregateOut(BaseModel):
97
+ crop_id: int
98
+ median: float
99
+ max_score: float
100
+ min_score: float
101
+ std_dev: float
102
+ n_passes: int
103
+ model_config = ConfigDict(from_attributes=True)
104
+
105
+
106
+ # --- Review queue item -----------------------------------------------------
107
+ class ReviewQueueItem(BaseModel):
108
+ """One row in the TA review dashboard."""
109
+ crop_id: int
110
+ paper_id: int
111
+ student_anon_id: str
112
+ question_id: str
113
+ rubric_id: int
114
+ rubric_title: str
115
+ image_path: str
116
+ aggregate: AggregateOut
117
+ gradings: list[GradingOut]
118
+ plagiarism_flagged: bool = False
119
+ reviewed: bool = False
120
+ final_score: float | None = None
121
+
122
+
123
+ # --- Review action ---------------------------------------------------------
124
+ class ReviewCreate(BaseModel):
125
+ crop_id: int
126
+ action: str # 'approve' | 'override' | 'flag'
127
+ final_score: float
128
+ notes: str = ""
129
+ reviewer_id: int | None = None
130
+
131
+
132
+ class ReviewOut(BaseModel):
133
+ id: int
134
+ crop_id: int
135
+ action: str
136
+ ai_score: float
137
+ final_score: float
138
+ notes: str | None
139
+ created_at: datetime
140
+ model_config = ConfigDict(from_attributes=True)
141
+
142
+
143
+ # --- Plagiarism ------------------------------------------------------------
144
+ class PlagiarismPair(BaseModel):
145
+ crop_a_id: int
146
+ crop_b_id: int
147
+ similarity: float
148
+ flagged_at: datetime
149
+ model_config = ConfigDict(from_attributes=True)
150
+
151
+
152
+ # --- Audit log -------------------------------------------------------------
153
+ class AuditEntry(BaseModel):
154
+ id: int
155
+ entity_type: str
156
+ entity_id: int
157
+ action: str
158
+ before: dict | None = None
159
+ after: dict | None = None
160
+ rubric_version: str | None = None
161
+ prompt_version: str | None = None
162
+ model_version: str | None = None
163
+ created_at: datetime
164
+ model_config = ConfigDict(from_attributes=True)
data/samples/.gitkeep ADDED
File without changes
debug_llm.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone diagnostic for the GRADEOPS LLM chain.
2
+
3
+ Walks every link from OS env → .env file → pydantic settings →
4
+ llm.get_llm() → an actual test API call. Prints exactly which step fails.
5
+
6
+ Run from the gradeops/ folder:
7
+ python debug_llm.py
8
+ """
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ print("=" * 60)
14
+ print("GRADEOPS LLM DIAGNOSTIC")
15
+ print("=" * 60)
16
+
17
+ # ---------- STEP 1: OS environment ----------
18
+ print("\n[1] os.environ check")
19
+ print("-" * 60)
20
+ key_in_env = "GOOGLE_API_KEY" in os.environ
21
+ print(f" GOOGLE_API_KEY in os.environ : {key_in_env}")
22
+ if key_in_env:
23
+ k = os.environ["GOOGLE_API_KEY"]
24
+ print(f" length : {len(k)}")
25
+ print(f" prefix : {k[:6]}")
26
+
27
+ # ---------- STEP 2: .env file ----------
28
+ print("\n[2] .env file check")
29
+ print("-" * 60)
30
+ env_file = Path(__file__).resolve().parent / ".env"
31
+ print(f" Looking at : {env_file}")
32
+ print(f" Exists : {env_file.exists()}")
33
+ if env_file.exists():
34
+ try:
35
+ content = env_file.read_text(encoding="utf-8-sig")
36
+ for ln in content.splitlines():
37
+ ln = ln.strip()
38
+ if not ln or ln.startswith("#"):
39
+ continue
40
+ if "=" in ln:
41
+ k, _, v = ln.partition("=")
42
+ k = k.strip()
43
+ v = v.strip().strip('"').strip("'")
44
+ if "API_KEY" in k or k.upper() == "LLM_PROVIDER":
45
+ shown = (v[:6] + "...") if v else "(empty)"
46
+ print(f" {k} = {shown} (len {len(v)})")
47
+ except Exception as e:
48
+ print(f" READ FAILED: {e}")
49
+
50
+ # ---------- STEP 3: backend.config.settings ----------
51
+ print("\n[3] backend.config.settings")
52
+ print("-" * 60)
53
+ try:
54
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
55
+ from backend.config import settings
56
+ print(f" settings.llm_provider : {settings.llm_provider}")
57
+ gk = settings.google_api_key or ""
58
+ print(f" settings.google_api_key length : {len(gk)}")
59
+ print(f" settings.google_api_key prefix : {(gk[:6] + '...') if gk else '(empty)'}")
60
+ print(f" settings.grader_model_google : {settings.grader_model_google}")
61
+ except Exception as e:
62
+ print(f" IMPORT FAILED: {e}")
63
+ sys.exit(1)
64
+
65
+ # ---------- STEP 4: llm.get_llm() ----------
66
+ print("\n[4] backend.grader.llm.get_llm()")
67
+ print("-" * 60)
68
+ try:
69
+ from backend.grader.llm import get_llm
70
+ llm = get_llm()
71
+ print(f" LLM loaded : {type(llm).__name__}")
72
+ print(f" Model : {getattr(llm, 'model', '?')}")
73
+ except Exception as e:
74
+ print(f" FAILED: {e}")
75
+ print("\n → fix this before going further.")
76
+ sys.exit(2)
77
+
78
+ # ---------- STEP 5: actual API call ----------
79
+ print("\n[5] live API call (1 token, costs ~nothing)")
80
+ print("-" * 60)
81
+ try:
82
+ from langchain_core.messages import HumanMessage
83
+ resp = llm.invoke([HumanMessage(content="Reply with exactly the single word OK.")])
84
+ text = resp.content if hasattr(resp, "content") else str(resp)
85
+ print(f" Response: {text!r}")
86
+ print("\n ✓ EVERYTHING WORKS. The pipeline should grade correctly.")
87
+ print(" If the Review page still shows the old error, those crops are")
88
+ print(" pre-fix DB rows — upload a new paper to verify.")
89
+ except Exception as e:
90
+ print(f" FAILED: {type(e).__name__}: {e}")
91
+ print("\n This is the actual API error. Common causes:")
92
+ print(" - Invalid key → regenerate at https://aistudio.google.com/app/apikey")
93
+ print(" - Model name wrong → try gemini-1.5-flash or gemini-2.5-flash")
94
+ print(" - Region restriction on the key")
95
+ sys.exit(3)
docker-compose.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+
3
+ # Optional: Postgres for the brief's tech-stack alignment.
4
+ # The app works with SQLite by default (DATABASE_URL=sqlite:///./gradeops.db).
5
+ # To use Postgres:
6
+ # docker compose up -d db
7
+ # set DATABASE_URL=postgresql+psycopg2://gradeops:gradeops@localhost:5432/gradeops in .env
8
+ # python scripts/init_db.py
9
+
10
+ services:
11
+ db:
12
+ image: postgres:16-alpine
13
+ environment:
14
+ POSTGRES_USER: gradeops
15
+ POSTGRES_PASSWORD: gradeops
16
+ POSTGRES_DB: gradeops
17
+ ports:
18
+ - "5432:5432"
19
+ volumes:
20
+ - gradeops_pgdata:/var/lib/postgresql/data
21
+
22
+ volumes:
23
+ gradeops_pgdata:
frontend/index.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>GradeOps</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+ <script type="module" src="/src/main.jsx"></script>
17
+ </body>
18
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "gradeops-frontend",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "axios": "^1.7.7",
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1",
15
+ "react-router-dom": "^6.26.2"
16
+ },
17
+ "devDependencies": {
18
+ "@vitejs/plugin-react": "^4.3.2",
19
+ "autoprefixer": "^10.4.20",
20
+ "postcss": "^8.4.47",
21
+ "tailwindcss": "^3.4.13",
22
+ "vite": "^5.4.8"
23
+ }
24
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useEffect, useState } from 'react'
2
+ import { Routes, Route, NavLink, Navigate } from 'react-router-dom'
3
+ import { api } from './api'
4
+
5
+ import Rubrics from './pages/Rubrics.jsx'
6
+ import Grading from './pages/Grading.jsx'
7
+ import Review from './pages/Review.jsx'
8
+ import Audit from './pages/Audit.jsx'
9
+
10
+ export const RoleContext = createContext({ role: 'instructor', user: null, users: [] })
11
+
12
+ const NAV = [
13
+ { to: '/rubrics', label: 'Rubrics' },
14
+ { to: '/grading', label: 'Grading' },
15
+ { to: '/review', label: 'Review' },
16
+ { to: '/audit', label: 'Audit' },
17
+ ]
18
+
19
+ export default function App() {
20
+ const [users, setUsers] = useState([])
21
+ const [user, setUser] = useState(null)
22
+ const [health, setHealth] = useState(null)
23
+
24
+ useEffect(() => {
25
+ api.users().then(us => { setUsers(us); setUser(us[0] || null) }).catch(() => {})
26
+ api.health().then(setHealth).catch(() => setHealth({ status: 'error' }))
27
+ }, [])
28
+
29
+ return (
30
+ <RoleContext.Provider value={{ role: user?.role || 'instructor', user, users }}>
31
+ <div className="min-h-full flex flex-col bg-background">
32
+ <Header user={user} users={users} setUser={setUser} health={health} />
33
+
34
+ <main className="flex-1 max-w-[1400px] w-full mx-auto px-8 py-8">
35
+ <Routes>
36
+ <Route path="/" element={<Navigate to="/rubrics" replace />} />
37
+ <Route path="/rubrics" element={<Rubrics />} />
38
+ <Route path="/grading" element={<Grading />} />
39
+ <Route path="/review" element={<Review />} />
40
+ <Route path="/audit" element={<Audit />} />
41
+ </Routes>
42
+ </main>
43
+
44
+ <footer className="border-t border-border bg-surface">
45
+ <div className="max-w-[1400px] mx-auto px-8 py-3 flex justify-between text-label-sm text-ink-subtle">
46
+ <span>GradeOps</span>
47
+ <span>v0.1.0</span>
48
+ </div>
49
+ </footer>
50
+ </div>
51
+ </RoleContext.Provider>
52
+ )
53
+ }
54
+
55
+ function Header({ user, users, setUser, health }) {
56
+ return (
57
+ <header className="bg-surface border-b border-border sticky top-0 z-30">
58
+ <div className="max-w-[1400px] mx-auto px-8 h-14 flex items-center justify-between gap-6">
59
+ <div className="flex items-center gap-8">
60
+ <Wordmark />
61
+ <nav className="flex items-center gap-1">
62
+ {NAV.map(item => (
63
+ <NavLink
64
+ key={item.to}
65
+ to={item.to}
66
+ className={({ isActive }) =>
67
+ `relative h-9 px-3 inline-flex items-center text-label-md rounded transition-colors ${
68
+ isActive
69
+ ? 'text-brand bg-brand-subtle'
70
+ : 'text-ink-muted hover:text-ink hover:bg-surface-muted'
71
+ }`
72
+ }
73
+ >
74
+ {item.label}
75
+ </NavLink>
76
+ ))}
77
+ </nav>
78
+ </div>
79
+
80
+ <div className="flex items-center gap-4">
81
+ {health && (
82
+ <div className="hidden md:flex items-center gap-1.5 text-label-sm text-ink-subtle">
83
+ <span className={`h-1.5 w-1.5 rounded-full ${health.status === 'ok' ? 'bg-success' : 'bg-danger'}`} />
84
+ <span>{health.status === 'ok' ? 'Connected' : 'Disconnected'}</span>
85
+ </div>
86
+ )}
87
+ {users.length > 0 && (
88
+ <select
89
+ value={user?.id || ''}
90
+ onChange={(e) => setUser(users.find(u => String(u.id) === e.target.value))}
91
+ className="h-8 px-2.5 pr-7 text-label-md text-ink bg-surface border border-border rounded
92
+ focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-brand"
93
+ >
94
+ {users.map(u => (
95
+ <option key={u.id} value={u.id}>
96
+ {u.role === 'instructor' ? 'Instructor' : 'TA'} · {u.name}
97
+ </option>
98
+ ))}
99
+ </select>
100
+ )}
101
+ </div>
102
+ </div>
103
+ </header>
104
+ )
105
+ }
106
+
107
+ function Wordmark() {
108
+ return (
109
+ <div className="flex items-center gap-2.5">
110
+ <div className="h-7 w-7 rounded bg-brand flex items-center justify-center">
111
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
112
+ <path d="M3 7L6 10L11 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
113
+ </svg>
114
+ </div>
115
+ <span className="text-headline-sm tracking-tight text-ink">GradeOps</span>
116
+ </div>
117
+ )
118
+ }
frontend/src/api.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+
3
+ const client = axios.create({
4
+ baseURL: '/api',
5
+ timeout: 600_000, // 10 min — grading is slow on first run
6
+ })
7
+
8
+ export const api = {
9
+ health: () => client.get('/health').then(r => r.data),
10
+ users: () => client.get('/users').then(r => r.data),
11
+
12
+ // Exams
13
+ createExam: (payload) => client.post('/exams', payload).then(r => r.data),
14
+ listExams: () => client.get('/exams').then(r => r.data),
15
+
16
+ // Rubrics
17
+ createRubric: (payload) => client.post('/rubrics', payload).then(r => r.data),
18
+ listRubrics: (examId) => client.get('/rubrics', { params: examId ? { exam_id: examId } : {} }).then(r => r.data),
19
+
20
+ // Papers / upload
21
+ uploadPaper: (examId, rubricId, file, anonId) => {
22
+ const fd = new FormData()
23
+ fd.append('exam_id', examId)
24
+ fd.append('rubric_id', rubricId)
25
+ if (anonId) fd.append('student_anon_id', anonId)
26
+ fd.append('file', file)
27
+ return client.post('/papers/upload', fd, {
28
+ headers: { 'Content-Type': 'multipart/form-data' },
29
+ }).then(r => r.data)
30
+ },
31
+
32
+ // Review queue
33
+ reviewQueue: (examId) => client.get('/review/queue', { params: examId ? { exam_id: examId } : {} }).then(r => r.data),
34
+ submitReview: (payload) => client.post('/reviews', payload).then(r => r.data),
35
+
36
+ // Audit + stats
37
+ audit: () => client.get('/audit').then(r => r.data),
38
+ plagiarism: () => client.get('/plagiarism').then(r => r.data),
39
+ stats: () => client.get('/stats').then(r => r.data),
40
+
41
+ // Crop image URL
42
+ cropImageUrl: (cropId) => `/api/crops/${cropId}/image`,
43
+ }
frontend/src/components/ui.jsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react'
2
+
3
+ // ===========================================================================
4
+ // Button
5
+ // ===========================================================================
6
+ export const Button = forwardRef(function Button(
7
+ { variant = 'secondary', size = 'md', className = '', children, disabled, ...rest }, ref
8
+ ) {
9
+ const base =
10
+ 'inline-flex items-center justify-center gap-1.5 font-medium rounded transition-colors duration-150 ease-standard ' +
11
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30 ' +
12
+ 'disabled:opacity-40 disabled:cursor-not-allowed select-none whitespace-nowrap'
13
+ const variants = {
14
+ primary: 'bg-brand text-brand-fg hover:bg-brand-hover',
15
+ secondary: 'bg-surface text-ink border border-border hover:bg-surface-subtle',
16
+ ghost: 'text-ink-muted hover:bg-surface-muted hover:text-ink',
17
+ danger: 'bg-surface text-danger border border-border hover:bg-danger-subtle hover:border-danger/30',
18
+ success: 'bg-success text-white hover:bg-success-strong',
19
+ link: 'text-brand hover:text-brand-hover underline-offset-2 hover:underline',
20
+ }
21
+ const sizes = {
22
+ sm: 'h-7 px-2.5 text-label-sm',
23
+ md: 'h-9 px-3.5 text-label-md',
24
+ lg: 'h-11 px-5 text-label-md',
25
+ icon: 'h-9 w-9 text-label-md',
26
+ }
27
+ return (
28
+ <button
29
+ ref={ref}
30
+ disabled={disabled}
31
+ className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
32
+ {...rest}
33
+ >
34
+ {children}
35
+ </button>
36
+ )
37
+ })
38
+
39
+ // ===========================================================================
40
+ // Card / Surface
41
+ // ===========================================================================
42
+ export function Card({ className = '', children, ...rest }) {
43
+ return (
44
+ <div
45
+ className={`bg-surface border border-border rounded-lg shadow-card ${className}`}
46
+ {...rest}
47
+ >
48
+ {children}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function CardHeader({ title, description, action, className = '' }) {
54
+ return (
55
+ <div className={`flex items-start justify-between gap-4 px-5 py-4 border-b border-border ${className}`}>
56
+ <div>
57
+ <h3 className="text-headline-sm text-ink">{title}</h3>
58
+ {description && <p className="mt-0.5 text-body-sm text-ink-muted">{description}</p>}
59
+ </div>
60
+ {action}
61
+ </div>
62
+ )
63
+ }
64
+
65
+ export function CardBody({ className = '', children }) {
66
+ return <div className={`px-5 py-4 ${className}`}>{children}</div>
67
+ }
68
+
69
+ // ===========================================================================
70
+ // Input + TextArea
71
+ // ===========================================================================
72
+ export const Input = forwardRef(function Input({ className = '', invalid, ...rest }, ref) {
73
+ return (
74
+ <input
75
+ ref={ref}
76
+ className={`block w-full h-9 px-3 text-body-sm text-ink bg-surface border rounded transition-colors duration-150 placeholder:text-ink-faint
77
+ ${invalid ? 'border-danger focus-visible:ring-danger/20 focus-visible:border-danger' : 'border-border focus-visible:ring-brand/30 focus-visible:border-brand'}
78
+ focus-visible:outline-none focus-visible:ring-2 ${className}`}
79
+ {...rest}
80
+ />
81
+ )
82
+ })
83
+
84
+ export const TextArea = forwardRef(function TextArea({ className = '', rows = 3, invalid, ...rest }, ref) {
85
+ return (
86
+ <textarea
87
+ ref={ref}
88
+ rows={rows}
89
+ className={`block w-full px-3 py-2 text-body-sm text-ink bg-surface border rounded transition-colors duration-150 resize-y placeholder:text-ink-faint
90
+ ${invalid ? 'border-danger focus-visible:ring-danger/20 focus-visible:border-danger' : 'border-border focus-visible:ring-brand/30 focus-visible:border-brand'}
91
+ focus-visible:outline-none focus-visible:ring-2 ${className}`}
92
+ {...rest}
93
+ />
94
+ )
95
+ })
96
+
97
+ export function Select({ className = '', children, ...rest }) {
98
+ return (
99
+ <select
100
+ className={`block w-full h-9 px-3 text-body-sm text-ink bg-surface border border-border rounded transition-colors duration-150
101
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30 focus-visible:border-brand ${className}`}
102
+ {...rest}
103
+ >
104
+ {children}
105
+ </select>
106
+ )
107
+ }
108
+
109
+ // ===========================================================================
110
+ // Field — label + control + hint pattern
111
+ // ===========================================================================
112
+ export function Field({ label, hint, error, required, children, className = '' }) {
113
+ return (
114
+ <label className={`block ${className}`}>
115
+ {label && (
116
+ <div className="mb-1.5 flex items-baseline gap-1">
117
+ <span className="text-label-sm uppercase tracking-wide text-ink-muted">{label}</span>
118
+ {required && <span className="text-danger text-label-sm">*</span>}
119
+ </div>
120
+ )}
121
+ {children}
122
+ {error
123
+ ? <p className="mt-1 text-label-sm text-danger">{error}</p>
124
+ : hint && <p className="mt-1 text-label-sm text-ink-subtle">{hint}</p>}
125
+ </label>
126
+ )
127
+ }
128
+
129
+ // ===========================================================================
130
+ // Badge / Pill
131
+ // ===========================================================================
132
+ export function Badge({ variant = 'neutral', children, className = '' }) {
133
+ const variants = {
134
+ neutral: 'bg-surface-muted text-ink-muted border-border',
135
+ brand: 'bg-brand-subtle text-brand border-brand/15',
136
+ success: 'bg-success-subtle text-success-strong border-success/15',
137
+ warning: 'bg-warning-subtle text-warning-strong border-warning/15',
138
+ danger: 'bg-danger-subtle text-danger-strong border-danger/15',
139
+ info: 'bg-info-subtle text-info-strong border-info/15',
140
+ }
141
+ return (
142
+ <span className={`inline-flex items-center gap-1 px-2 h-5 rounded-full border text-label-sm ${variants[variant]} ${className}`}>
143
+ {children}
144
+ </span>
145
+ )
146
+ }
147
+
148
+ // ===========================================================================
149
+ // Stat — used in the audit metrics row
150
+ // ===========================================================================
151
+ export function Stat({ label, value, hint, accent }) {
152
+ return (
153
+ <Card>
154
+ <div className="px-5 py-4">
155
+ <div className="text-label-sm uppercase tracking-wide text-ink-muted">{label}</div>
156
+ <div className={`mt-1 text-headline-md ${accent || 'text-ink'}`}>{value}</div>
157
+ {hint && <div className="mt-0.5 text-label-sm text-ink-subtle">{hint}</div>}
158
+ </div>
159
+ </Card>
160
+ )
161
+ }
162
+
163
+ // ===========================================================================
164
+ // Empty state
165
+ // ===========================================================================
166
+ export function EmptyState({ title, description, action, icon }) {
167
+ return (
168
+ <Card>
169
+ <div className="px-10 py-16 text-center">
170
+ {icon && <div className="mx-auto mb-3 h-10 w-10 text-ink-faint">{icon}</div>}
171
+ <p className="text-body-md text-ink font-medium">{title}</p>
172
+ {description && <p className="mt-1 text-body-sm text-ink-muted max-w-md mx-auto">{description}</p>}
173
+ {action && <div className="mt-5">{action}</div>}
174
+ </div>
175
+ </Card>
176
+ )
177
+ }
178
+
179
+ // ===========================================================================
180
+ // PageHeader
181
+ // ===========================================================================
182
+ export function PageHeader({ title, description, actions }) {
183
+ return (
184
+ <div className="flex items-start justify-between gap-4 mb-6">
185
+ <div>
186
+ <h1 className="text-headline-lg text-ink">{title}</h1>
187
+ {description && <p className="mt-1 text-body-md text-ink-muted">{description}</p>}
188
+ </div>
189
+ {actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
190
+ </div>
191
+ )
192
+ }
193
+
194
+ // ===========================================================================
195
+ // Keyboard hint chip
196
+ // ===========================================================================
197
+ export function Kbd({ children }) {
198
+ return (
199
+ <kbd className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded border border-border-strong bg-surface-muted text-label-sm text-ink-muted font-medium">
200
+ {children}
201
+ </kbd>
202
+ )
203
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ html, body, #root { height: 100%; }
7
+ html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
8
+ body {
9
+ font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
10
+ background-color: #F9FAFB;
11
+ color: #111827;
12
+ font-feature-settings: 'cv11', 'ss01', 'ss03';
13
+ }
14
+ /* Make all native focus rings consistent with the design system */
15
+ *:focus { outline: none; }
16
+ ::selection { background: rgba(79, 70, 229, 0.18); color: #111827; }
17
+
18
+ /* Number inputs without spinner clutter */
19
+ input[type='number']::-webkit-inner-spin-button,
20
+ input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
21
+ input[type='number'] { -moz-appearance: textfield; }
22
+ }
23
+
24
+ @layer components {
25
+ .focus-ring {
26
+ @apply focus-visible:ring-2 focus-visible:ring-brand/30 focus-visible:border-brand;
27
+ }
28
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { BrowserRouter } from 'react-router-dom'
4
+ import App from './App.jsx'
5
+ import './index.css'
6
+
7
+ ReactDOM.createRoot(document.getElementById('root')).render(
8
+ <React.StrictMode>
9
+ <BrowserRouter>
10
+ <App />
11
+ </BrowserRouter>
12
+ </React.StrictMode>
13
+ )
frontend/src/pages/Audit.jsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api } from '../api'
3
+ import {
4
+ Button, Card, CardHeader, Badge, Stat, EmptyState, PageHeader,
5
+ } from '../components/ui.jsx'
6
+
7
+ export default function Audit() {
8
+ const [stats, setStats] = useState(null)
9
+ const [entries, setEntries] = useState([])
10
+ const [plag, setPlag] = useState([])
11
+ const [loading, setLoading] = useState(true)
12
+
13
+ const load = async () => {
14
+ setLoading(true)
15
+ try {
16
+ const [s, e, p] = await Promise.all([api.stats(), api.audit(), api.plagiarism()])
17
+ setStats(s); setEntries(e); setPlag(p)
18
+ } finally {
19
+ setLoading(false)
20
+ }
21
+ }
22
+ useEffect(() => { load() }, [])
23
+
24
+ const pct = (n) => `${Math.round((n || 0) * 100)}%`
25
+
26
+ return (
27
+ <div>
28
+ <PageHeader
29
+ title="Audit & analytics"
30
+ description="Immutable record of every grading and review decision. Each row is stamped with the rubric, prompt, and model versions in effect at the time."
31
+ actions={<Button onClick={load}>Refresh</Button>}
32
+ />
33
+
34
+ {stats && (
35
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
36
+ <Stat label="Total answers" value={stats.total_crops} />
37
+ <Stat label="Reviewed" value={stats.total_reviewed} />
38
+ <Stat
39
+ label="Override rate" value={pct(stats.override_rate)}
40
+ accent={(stats.override_rate || 0) > 0.2 ? 'text-warning-strong' : 'text-ink'}
41
+ />
42
+ <Stat label="Flag rate" value={pct(stats.flag_rate)} />
43
+ <Stat
44
+ label="Mean σ" value={(stats.mean_std_dev || 0).toFixed(2)}
45
+ accent={(stats.mean_std_dev || 0) > 1 ? 'text-warning-strong' : 'text-success-strong'}
46
+ />
47
+ <Stat
48
+ label="Similarity pairs" value={stats.plagiarism_pairs}
49
+ accent={stats.plagiarism_pairs > 0 ? 'text-danger-strong' : 'text-ink'}
50
+ />
51
+ </div>
52
+ )}
53
+
54
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
55
+ <Card>
56
+ <CardHeader title="Decision log" description="Newest first" />
57
+ <div className="max-h-[64vh] overflow-auto">
58
+ {loading ? (
59
+ <p className="px-5 py-6 text-body-sm text-ink-muted">Loading…</p>
60
+ ) : entries.length === 0 ? (
61
+ <p className="px-5 py-6 text-body-sm text-ink-muted text-center">No audit entries yet.</p>
62
+ ) : (
63
+ <table className="w-full text-body-sm">
64
+ <thead className="sticky top-0 bg-surface-subtle">
65
+ <tr className="text-left text-label-sm uppercase tracking-wide text-ink-subtle border-b border-border">
66
+ <th className="px-5 py-2 font-medium">When</th>
67
+ <th className="px-2 py-2 font-medium">Entity</th>
68
+ <th className="px-2 py-2 font-medium">Action</th>
69
+ <th className="px-2 py-2 font-medium">Versions</th>
70
+ <th className="px-5 py-2 font-medium">Detail</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ {entries.map(e => (
75
+ <tr key={e.id} className="border-b border-border last:border-0 hover:bg-surface-subtle">
76
+ <td className="px-5 py-2.5 text-ink-muted whitespace-nowrap">
77
+ {new Date(e.created_at).toLocaleTimeString()}
78
+ </td>
79
+ <td className="px-2 py-2.5 text-ink">{e.entity_type}#{e.entity_id}</td>
80
+ <td className="px-2 py-2.5">
81
+ <Badge variant={actionVariant(e.action)}>{e.action}</Badge>
82
+ </td>
83
+ <td className="px-2 py-2.5 text-label-sm text-ink-subtle">
84
+ r:{e.rubric_version || '—'} · p:{e.prompt_version || '—'}
85
+ </td>
86
+ <td className="px-5 py-2.5 text-ink-muted truncate max-w-[200px]" title={JSON.stringify(e.after)}>
87
+ {summarizeDelta(e)}
88
+ </td>
89
+ </tr>
90
+ ))}
91
+ </tbody>
92
+ </table>
93
+ )}
94
+ </div>
95
+ </Card>
96
+
97
+ <Card>
98
+ <CardHeader
99
+ title="Similarity flags"
100
+ description="Pairs of answers with highly similar reasoning"
101
+ />
102
+ <div className="max-h-[64vh] overflow-auto">
103
+ {loading ? (
104
+ <p className="px-5 py-6 text-body-sm text-ink-muted">Loading…</p>
105
+ ) : plag.length === 0 ? (
106
+ <p className="px-5 py-6 text-body-sm text-ink-muted text-center">No similarity pairs above the configured threshold.</p>
107
+ ) : (
108
+ <table className="w-full text-body-sm">
109
+ <thead className="sticky top-0 bg-surface-subtle">
110
+ <tr className="text-left text-label-sm uppercase tracking-wide text-ink-subtle border-b border-border">
111
+ <th className="px-5 py-2 font-medium">Answer A</th>
112
+ <th className="px-2 py-2 font-medium">Answer B</th>
113
+ <th className="px-5 py-2 font-medium text-right">Similarity</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody>
117
+ {plag.map(p => (
118
+ <tr key={`${p.crop_a_id}-${p.crop_b_id}`} className="border-b border-border last:border-0">
119
+ <td className="px-5 py-2.5 text-ink">#{p.crop_a_id}</td>
120
+ <td className="px-2 py-2.5 text-ink">#{p.crop_b_id}</td>
121
+ <td className="px-5 py-2.5 text-right">
122
+ <span className="text-danger-strong font-medium">{(p.similarity * 100).toFixed(1)}%</span>
123
+ </td>
124
+ </tr>
125
+ ))}
126
+ </tbody>
127
+ </table>
128
+ )}
129
+ </div>
130
+ </Card>
131
+ </div>
132
+ </div>
133
+ )
134
+ }
135
+
136
+ function actionVariant(action) {
137
+ if (action === 'approve') return 'success'
138
+ if (action === 'override') return 'warning'
139
+ if (action === 'flag') return 'danger'
140
+ return 'neutral'
141
+ }
142
+
143
+ function summarizeDelta(e) {
144
+ const a = e.after || {}
145
+ if (a.final_score !== undefined) return `→ ${a.final_score}`
146
+ if (a.median !== undefined) return `median ${a.median} · σ ${(a.std_dev || 0).toFixed(2)}`
147
+ if (a.title) return a.title
148
+ return '—'
149
+ }
frontend/src/pages/Grading.jsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api } from '../api'
3
+ import {
4
+ Button, Card, CardHeader, CardBody, Select, Field, Badge,
5
+ EmptyState, PageHeader,
6
+ } from '../components/ui.jsx'
7
+
8
+ export default function Grading() {
9
+ const [exams, setExams] = useState([])
10
+ const [examId, setExamId] = useState(null)
11
+ const [rubrics, setRubrics] = useState([])
12
+ const [rubricId, setRubricId] = useState(null)
13
+ const [files, setFiles] = useState([])
14
+ const [batch, setBatch] = useState([])
15
+ const [running, setRunning] = useState(false)
16
+
17
+ const refresh = async () => {
18
+ const es = await api.listExams()
19
+ setExams(es)
20
+ const eid = examId ?? es[0]?.id ?? null
21
+ setExamId(eid)
22
+ if (eid) {
23
+ const rs = await api.listRubrics(eid)
24
+ setRubrics(rs)
25
+ if (!rubricId && rs[0]) setRubricId(rs[0].id)
26
+ }
27
+ }
28
+ useEffect(() => { refresh() }, []) // eslint-disable-line
29
+ useEffect(() => { if (examId) api.listRubrics(examId).then(setRubrics) }, [examId])
30
+
31
+ const onPickFiles = (e) => setFiles(Array.from(e.target.files || []))
32
+
33
+ const runBatch = async () => {
34
+ if (!examId || !rubricId || !files.length) return
35
+ setRunning(true)
36
+ const fresh = files.map((f, i) => ({
37
+ id: `${Date.now()}_${i}`,
38
+ name: f.name,
39
+ status: 'queued',
40
+ }))
41
+ setBatch(fresh)
42
+ for (let i = 0; i < files.length; i++) {
43
+ const f = files[i]
44
+ setBatch(b => b.map((x, idx) => idx === i ? { ...x, status: 'grading' } : x))
45
+ try {
46
+ const res = await api.uploadPaper(examId, rubricId, f)
47
+ setBatch(b => b.map((x, idx) => idx === i ? {
48
+ ...x, status: 'done',
49
+ paperId: res.paper_id, anonId: res.student_anon_id,
50
+ cropIds: res.crop_ids,
51
+ } : x))
52
+ } catch (err) {
53
+ setBatch(b => b.map((x, idx) => idx === i ? {
54
+ ...x, status: 'failed',
55
+ error: err?.response?.data?.detail || err.message,
56
+ } : x))
57
+ }
58
+ }
59
+ setRunning(false)
60
+ setFiles([])
61
+ }
62
+
63
+ const rubric = rubrics.find(r => r.id === rubricId)
64
+ const anyDone = batch.some(b => b.status === 'done')
65
+
66
+ return (
67
+ <div>
68
+ <PageHeader
69
+ title="Grading"
70
+ description="Upload scanned answer sheets. The system extracts each answer region, transcribes the handwriting, and grades against the selected rubric."
71
+ />
72
+
73
+ {exams.length === 0 ? (
74
+ <EmptyState
75
+ title="Create an exam and a rubric first"
76
+ description="Go to the Rubrics tab, create an exam, and add at least one rubric. Then come back here to upload answer sheets."
77
+ />
78
+ ) : (
79
+ <>
80
+ <Card className="mb-4">
81
+ <CardBody>
82
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
83
+ <Field label="Exam">
84
+ <Select value={examId || ''} onChange={e => setExamId(Number(e.target.value))}>
85
+ {exams.map(e => <option key={e.id} value={e.id}>{e.title}</option>)}
86
+ </Select>
87
+ </Field>
88
+ <Field label="Rubric">
89
+ <Select value={rubricId || ''} onChange={e => setRubricId(Number(e.target.value))}>
90
+ {rubrics.length === 0 && <option value="">No rubrics in this exam</option>}
91
+ {rubrics.map(r => <option key={r.id} value={r.id}>{r.title} ({r.version})</option>)}
92
+ </Select>
93
+ </Field>
94
+ <Field label="Answer sheets" hint="PDF or image. One student per file.">
95
+ <label className="cursor-pointer">
96
+ <input type="file" accept="application/pdf,image/*" multiple
97
+ onChange={onPickFiles} className="sr-only" />
98
+ <span className="block w-full h-9 px-3 rounded border border-dashed border-border bg-surface-subtle text-body-sm text-ink-muted flex items-center hover:border-brand hover:bg-brand-subtle hover:text-brand transition-colors">
99
+ {files.length > 0
100
+ ? `${files.length} file${files.length === 1 ? '' : 's'} selected`
101
+ : 'Click to choose files'}
102
+ </span>
103
+ </label>
104
+ </Field>
105
+ </div>
106
+
107
+ {rubric && (
108
+ <div className="mt-4 rounded border border-border bg-surface-subtle px-4 py-3">
109
+ <div className="flex items-center gap-2 mb-1">
110
+ <Badge variant="brand">{rubric.version}</Badge>
111
+ <span className="text-label-md text-ink font-medium">{rubric.title}</span>
112
+ <span className="text-label-sm text-ink-muted">· {rubric.max_marks} pts · {rubric.criteria.length} criteria</span>
113
+ </div>
114
+ <p className="text-body-sm text-ink-muted whitespace-pre-line line-clamp-2">{rubric.question_text}</p>
115
+ </div>
116
+ )}
117
+
118
+ <div className="mt-4 flex items-center justify-between">
119
+ <span className="text-label-md text-ink-muted">
120
+ {files.length} file{files.length === 1 ? '' : 's'} queued
121
+ </span>
122
+ <Button
123
+ variant="primary" onClick={runBatch}
124
+ disabled={!rubricId || !files.length || running}
125
+ >
126
+ {running ? 'Grading…' : `Run grading on ${files.length || 0}`}
127
+ </Button>
128
+ </div>
129
+ </CardBody>
130
+ </Card>
131
+
132
+ {batch.length > 0 && (
133
+ <Card>
134
+ <CardHeader title="Batch progress" />
135
+ <div className="divide-y divide-border">
136
+ {batch.map(item => <BatchRow key={item.id} item={item} />)}
137
+ </div>
138
+ </Card>
139
+ )}
140
+
141
+ {anyDone && (
142
+ <p className="mt-6 text-body-sm text-ink-muted">
143
+ Done. Open the <span className="text-brand font-medium">Review</span> tab to see grades and approve, override, or flag each answer.
144
+ </p>
145
+ )}
146
+ </>
147
+ )}
148
+ </div>
149
+ )
150
+ }
151
+
152
+ function BatchRow({ item }) {
153
+ return (
154
+ <div className="flex items-center gap-3 px-5 py-3">
155
+ <span className="flex-1 text-body-sm text-ink truncate">{item.name}</span>
156
+ {item.status === 'queued' && <Badge>queued</Badge>}
157
+ {item.status === 'grading' && <Badge variant="info">Grading…</Badge>}
158
+ {item.status === 'done' && (
159
+ <>
160
+ <Badge variant="success">✓ {item.cropIds?.length || 0} answer{item.cropIds?.length === 1 ? '' : 's'}</Badge>
161
+ <span className="text-label-sm text-ink-subtle">{item.anonId}</span>
162
+ </>
163
+ )}
164
+ {item.status === 'failed' && (
165
+ <Badge variant="danger" title={item.error}>Failed</Badge>
166
+ )}
167
+ </div>
168
+ )
169
+ }
frontend/src/pages/Review.jsx ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
2
+ import { api } from '../api'
3
+ import {
4
+ Button, Card, CardHeader, CardBody, Input, Field, Badge,
5
+ EmptyState, PageHeader, Kbd,
6
+ } from '../components/ui.jsx'
7
+ import { RoleContext } from '../App.jsx'
8
+
9
+ export default function Review() {
10
+ const { user } = useContext(RoleContext)
11
+ const [queue, setQueue] = useState([])
12
+ const [idx, setIdx] = useState(0)
13
+ const [loading, setLoading] = useState(true)
14
+ const [override, setOverride] = useState({ open: false, score: '', notes: '' })
15
+ const overrideInputRef = useRef(null)
16
+
17
+ const load = async () => {
18
+ setLoading(true)
19
+ try {
20
+ const items = await api.reviewQueue()
21
+ setQueue(items)
22
+ setIdx(0)
23
+ } finally {
24
+ setLoading(false)
25
+ }
26
+ }
27
+ useEffect(() => { load() }, [])
28
+
29
+ const current = queue[idx]
30
+
31
+ const submit = useCallback(async (action, finalScore, notes) => {
32
+ if (!current) return
33
+ await api.submitReview({
34
+ crop_id: current.crop_id,
35
+ action,
36
+ final_score: finalScore,
37
+ notes: notes || '',
38
+ reviewer_id: user?.id,
39
+ })
40
+ setQueue(q => q.filter((_, i) => i !== idx))
41
+ setIdx(i => Math.max(0, Math.min(i, queue.length - 2)))
42
+ setOverride({ open: false, score: '', notes: '' })
43
+ }, [current, idx, queue.length, user])
44
+
45
+ const onApprove = () => current && submit('approve', current.aggregate.median, '')
46
+ const onFlag = () => current && submit('flag', current.aggregate.median, 'Flagged by reviewer')
47
+ const openOverride = () => {
48
+ if (!current) return
49
+ setOverride({ open: true, score: String(current.aggregate.median), notes: '' })
50
+ setTimeout(() => overrideInputRef.current?.focus(), 30)
51
+ }
52
+ const submitOverride = () => {
53
+ const s = Number(override.score)
54
+ if (isNaN(s)) return
55
+ submit('override', s, override.notes)
56
+ }
57
+
58
+ // Keyboard shortcuts
59
+ useEffect(() => {
60
+ const handler = (e) => {
61
+ if (override.open) {
62
+ if (e.key === 'Escape') { setOverride({ open: false, score: '', notes: '' }); e.preventDefault() }
63
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { submitOverride(); e.preventDefault() }
64
+ return
65
+ }
66
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return
67
+ switch (e.key.toLowerCase()) {
68
+ case 'enter': onApprove(); e.preventDefault(); break
69
+ case 'o': openOverride(); e.preventDefault(); break
70
+ case 'f': onFlag(); e.preventDefault(); break
71
+ case 'j': case 'arrowright':
72
+ setIdx(i => Math.min(i + 1, queue.length - 1)); e.preventDefault(); break
73
+ case 'k': case 'arrowleft':
74
+ setIdx(i => Math.max(i - 1, 0)); e.preventDefault(); break
75
+ }
76
+ }
77
+ window.addEventListener('keydown', handler)
78
+ return () => window.removeEventListener('keydown', handler)
79
+ }, [override.open, queue.length, current, idx]) // eslint-disable-line
80
+
81
+ if (loading) {
82
+ return (
83
+ <div>
84
+ <PageHeader title="Review queue" description="Loading…" />
85
+ </div>
86
+ )
87
+ }
88
+
89
+ if (queue.length === 0) {
90
+ return (
91
+ <div>
92
+ <PageHeader
93
+ title="Review queue"
94
+ description="Answers awaiting human approval, sorted by AI uncertainty (highest variance first)."
95
+ actions={<Button onClick={load}>Refresh</Button>}
96
+ />
97
+ <EmptyState
98
+ title="Queue is clear"
99
+ description="Either nothing has been graded yet, or every graded answer has been reviewed. Upload more answer sheets in the Grading tab."
100
+ action={<Button onClick={load}>Refresh queue</Button>}
101
+ />
102
+ </div>
103
+ )
104
+ }
105
+
106
+ return (
107
+ <div>
108
+ <PageHeader
109
+ title="Review queue"
110
+ description={`${queue.length} pending · sorted by AI uncertainty (highest variance first)`}
111
+ actions={
112
+ <>
113
+ <div className="hidden md:flex items-center gap-3 text-label-sm text-ink-muted mr-2">
114
+ <span className="flex items-center gap-1"><Kbd>↵</Kbd> approve</span>
115
+ <span className="flex items-center gap-1"><Kbd>O</Kbd> override</span>
116
+ <span className="flex items-center gap-1"><Kbd>F</Kbd> flag</span>
117
+ <span className="flex items-center gap-1"><Kbd>J</Kbd>/<Kbd>K</Kbd> next/prev</span>
118
+ </div>
119
+ <Button onClick={load} variant="ghost">↻</Button>
120
+ </>
121
+ }
122
+ />
123
+
124
+ <div className="flex items-center justify-between mb-3">
125
+ <span className="text-label-md text-ink-muted">Item {idx + 1} of {queue.length}</span>
126
+ <div className="flex gap-1">
127
+ <Button variant="ghost" size="sm" onClick={() => setIdx(i => Math.max(0, i - 1))} disabled={idx === 0}>← Prev</Button>
128
+ <Button variant="ghost" size="sm" onClick={() => setIdx(i => Math.min(queue.length - 1, i + 1))} disabled={idx === queue.length - 1}>Next →</Button>
129
+ </div>
130
+ </div>
131
+
132
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
133
+ {/* LEFT: image + metadata */}
134
+ <Card>
135
+ <CardHeader
136
+ title={current.student_anon_id}
137
+ description={`${current.question_id} · ${current.rubric_title}`}
138
+ action={
139
+ <div className="flex items-center gap-2">
140
+ {current.plagiarism_flagged && <Badge variant="danger">Similarity</Badge>}
141
+ <span className="text-label-sm text-ink-subtle">#{current.crop_id}</span>
142
+ </div>
143
+ }
144
+ />
145
+ <div className="bg-surface-subtle p-3 border-t border-border">
146
+ <img
147
+ src={api.cropImageUrl(current.crop_id)}
148
+ alt="student answer"
149
+ className="w-full h-auto max-h-[64vh] object-contain bg-white rounded border border-border"
150
+ />
151
+ </div>
152
+ </Card>
153
+
154
+ {/* RIGHT: AI grade panels */}
155
+ <div className="space-y-4">
156
+ {/* Aggregate */}
157
+ <Card>
158
+ <CardHeader title={`AI grade · ${current.aggregate.n_passes} pass${current.aggregate.n_passes === 1 ? '' : 'es'}`} />
159
+ <CardBody>
160
+ <div className="grid grid-cols-4 gap-2">
161
+ <ScoreBlock label="Median" value={current.aggregate.median} accent="text-brand" />
162
+ <ScoreBlock label="Max" value={current.aggregate.max_score} />
163
+ <ScoreBlock label="Min" value={current.aggregate.min_score} />
164
+ <ScoreBlock
165
+ label="σ"
166
+ value={current.aggregate.std_dev.toFixed(2)}
167
+ accent={current.aggregate.std_dev > 1 ? 'text-warning-strong' : 'text-success-strong'}
168
+ />
169
+ </div>
170
+ {current.gradings.length > 1 && (
171
+ <div className="mt-3 flex gap-1.5">
172
+ {current.gradings.map(g => (
173
+ <div
174
+ key={g.id}
175
+ title={`Pass ${g.pass_num}: ${g.score}/${g.max_score}${g.critic_passed ? '' : ' (critic failed)'}`}
176
+ className="flex-1 h-7 rounded border border-border bg-surface-subtle flex items-center justify-center text-label-md text-ink"
177
+ >
178
+ {g.score}{!g.critic_passed && <span className="ml-1 text-warning">!</span>}
179
+ </div>
180
+ ))}
181
+ </div>
182
+ )}
183
+ </CardBody>
184
+ </Card>
185
+
186
+ {/* Per-criterion */}
187
+ <Card>
188
+ <CardHeader title="Per-criterion" description="From the median pass" />
189
+ <div className="divide-y divide-border">
190
+ {pickMedianGrading(current).per_criterion.map((c, i) => (
191
+ <div key={i} className="px-5 py-3">
192
+ <div className="flex items-center justify-between gap-3">
193
+ <span className="text-body-sm text-ink font-medium">
194
+ <span className="text-ink-subtle mr-2">#{i + 1}</span>{c.name}
195
+ </span>
196
+ <span className="text-body-sm text-ink font-medium">{c.awarded} / {c.max}</span>
197
+ </div>
198
+ <p className="text-label-sm text-ink-muted mt-0.5 pl-6">{c.reasoning}</p>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ </Card>
203
+
204
+ {/* Justification */}
205
+ <Card>
206
+ <CardHeader title="Justification" />
207
+ <CardBody>
208
+ <p className="text-body-sm text-ink leading-relaxed">
209
+ {pickMedianGrading(current).justification}
210
+ </p>
211
+ {pickMedianGrading(current).flags?.length > 0 && (
212
+ <div className="mt-3 flex gap-1.5 flex-wrap">
213
+ {pickMedianGrading(current).flags.map(f => (
214
+ <Badge key={f} variant="warning">{f}</Badge>
215
+ ))}
216
+ </div>
217
+ )}
218
+ </CardBody>
219
+ </Card>
220
+
221
+ {/* Actions */}
222
+ <Card className="border-brand/20 bg-brand-subtle/40">
223
+ <CardBody>
224
+ {!override.open ? (
225
+ <div className="flex items-center gap-2">
226
+ <Button variant="primary" onClick={onApprove}>
227
+ <Kbd>↵</Kbd> Approve {current.aggregate.median}
228
+ </Button>
229
+ <Button onClick={openOverride}>
230
+ <Kbd>O</Kbd> Override
231
+ </Button>
232
+ <Button variant="danger" onClick={onFlag}>
233
+ <Kbd>F</Kbd> Flag
234
+ </Button>
235
+ </div>
236
+ ) : (
237
+ <div className="space-y-3">
238
+ <div className="grid grid-cols-3 gap-3">
239
+ <Field label={`New score (0 — ${current.gradings[0]?.max_score || 0})`}>
240
+ <Input
241
+ ref={overrideInputRef} type="number" step="0.5" min="0"
242
+ max={current.gradings[0]?.max_score || 0}
243
+ value={override.score}
244
+ onChange={e => setOverride({ ...override, score: e.target.value })}
245
+ />
246
+ </Field>
247
+ <div className="col-span-2">
248
+ <Field label="Note (optional)">
249
+ <Input
250
+ value={override.notes}
251
+ onChange={e => setOverride({ ...override, notes: e.target.value })}
252
+ placeholder="Why you changed the score"
253
+ />
254
+ </Field>
255
+ </div>
256
+ </div>
257
+ <div className="flex gap-2 justify-end">
258
+ <Button onClick={() => setOverride({ open: false, score: '', notes: '' })}>
259
+ <Kbd>Esc</Kbd> Cancel
260
+ </Button>
261
+ <Button variant="success" onClick={submitOverride}>
262
+ <Kbd>⌘↵</Kbd> Save override
263
+ </Button>
264
+ </div>
265
+ </div>
266
+ )}
267
+ </CardBody>
268
+ </Card>
269
+ </div>
270
+ </div>
271
+
272
+ {/* Transcript drawer */}
273
+ <details className="mt-4 group">
274
+ <summary className="list-none cursor-pointer rounded-lg border border-border bg-surface px-5 py-3 text-label-md text-ink-muted hover:text-ink hover:bg-surface-subtle transition-colors">
275
+ <span className="inline-flex items-center gap-2">
276
+ <span className="text-ink-faint group-open:rotate-90 transition-transform">›</span>
277
+ OCR transcript
278
+ </span>
279
+ </summary>
280
+ <pre className="mt-2 p-4 rounded-lg border border-border bg-surface text-body-sm text-ink whitespace-pre-wrap font-sans leading-relaxed">
281
+ {current.gradings[0]?.transcript || '(no transcript captured)'}
282
+ </pre>
283
+ </details>
284
+ </div>
285
+ )
286
+ }
287
+
288
+ function pickMedianGrading(item) {
289
+ const sorted = [...item.gradings].sort(
290
+ (a, b) => Math.abs(a.score - item.aggregate.median) - Math.abs(b.score - item.aggregate.median)
291
+ )
292
+ return sorted[0] || item.gradings[0]
293
+ }
294
+
295
+ function ScoreBlock({ label, value, accent }) {
296
+ return (
297
+ <div className="rounded border border-border bg-surface px-3 py-2.5">
298
+ <div className="text-label-sm uppercase tracking-wide text-ink-subtle">{label}</div>
299
+ <div className={`text-headline-md ${accent || 'text-ink'}`}>{value}</div>
300
+ </div>
301
+ )
302
+ }
frontend/src/pages/Rubrics.jsx ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api } from '../api'
3
+ import {
4
+ Button, Card, CardHeader, CardBody, Input, TextArea, Select,
5
+ Field, Badge, EmptyState, PageHeader,
6
+ } from '../components/ui.jsx'
7
+
8
+ const DEFAULT_INSTRUCTIONS = `Base all scoring strictly on evidence visible in the student's handwritten work. If evidence is missing, assign zero — do not guess, infer, or hallucinate.
9
+ Award credit for intermediate steps only if they are explicitly written by the student. Do not infer non-trivial reasoning from a correct final answer except for trivial algebraic simplifications.
10
+ If the student's solution does not match the question being asked, assign a score of 0.`
11
+
12
+ const SAMPLE_RUBRIC = {
13
+ title: 'Linear equation — sample',
14
+ question_text: 'Solve for x and show all steps:\n\n2x + 5 = 15',
15
+ max_marks: 10,
16
+ course_instructions: DEFAULT_INSTRUCTIONS,
17
+ criteria: [
18
+ {
19
+ name: 'Isolate the variable term',
20
+ points: 5,
21
+ conditions: 'Student subtracts 5 from both sides to obtain 2x = 10.',
22
+ accept_alternatives: 'Equivalent algebraic rearrangements that arrive at 2x = 10.',
23
+ do_not_deduct_for: 'Minor handwriting or notation differences.',
24
+ },
25
+ {
26
+ name: 'Solve for x',
27
+ points: 5,
28
+ conditions: 'Student divides both sides by 2 and obtains x = 5.',
29
+ accept_alternatives: 'Equivalent forms such as x=5 or x = 5.0.',
30
+ do_not_deduct_for: 'Minor notation differences.',
31
+ },
32
+ ],
33
+ }
34
+
35
+ export default function Rubrics() {
36
+ const [exams, setExams] = useState([])
37
+ const [examId, setExamId] = useState(null)
38
+ const [rubrics, setRubrics] = useState([])
39
+ const [editing, setEditing] = useState(null)
40
+ const [loading, setLoading] = useState(true)
41
+
42
+ const refresh = async (eid) => {
43
+ setLoading(true)
44
+ try {
45
+ const es = await api.listExams()
46
+ setExams(es)
47
+ const useExam = eid ?? examId ?? (es[0]?.id || null)
48
+ setExamId(useExam)
49
+ setRubrics(useExam ? await api.listRubrics(useExam) : [])
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ }
54
+
55
+ useEffect(() => { refresh() }, []) // eslint-disable-line
56
+
57
+ const createExam = async () => {
58
+ const title = prompt('Name this exam')
59
+ if (!title) return
60
+ const e = await api.createExam({ title })
61
+ await refresh(e.id)
62
+ }
63
+
64
+ const startBlank = () => {
65
+ if (!examId) { alert('Create an exam first.'); return }
66
+ setEditing({
67
+ _new: true, exam_id: examId,
68
+ title: '', question_text: '', max_marks: 10,
69
+ course_instructions: DEFAULT_INSTRUCTIONS,
70
+ criteria: [{ name: '', points: 1, conditions: '', accept_alternatives: '', do_not_deduct_for: '' }],
71
+ })
72
+ }
73
+
74
+ const startSample = () => {
75
+ if (!examId) { alert('Create an exam first.'); return }
76
+ setEditing({ _new: true, exam_id: examId, ...SAMPLE_RUBRIC })
77
+ }
78
+
79
+ const save = async (r) => {
80
+ const { _new, ...payload } = r
81
+ await api.createRubric(payload)
82
+ setEditing(null)
83
+ await refresh()
84
+ }
85
+
86
+ if (editing) {
87
+ return <RubricEditor initial={editing} onSave={save} onCancel={() => setEditing(null)} />
88
+ }
89
+
90
+ return (
91
+ <div>
92
+ <PageHeader
93
+ title="Rubrics"
94
+ description="Define grading criteria for each question. Criteria support alternatives and explicit do-not-deduct rules."
95
+ actions={
96
+ <>
97
+ <ExamPicker exams={exams} value={examId} onChange={(id) => refresh(id)} />
98
+ <Button onClick={createExam}>New exam</Button>
99
+ <Button onClick={startSample}>Load sample</Button>
100
+ <Button variant="primary" onClick={startBlank}>New rubric</Button>
101
+ </>
102
+ }
103
+ />
104
+
105
+ {loading ? (
106
+ <Card><CardBody><p className="text-body-sm text-ink-muted">Loading…</p></CardBody></Card>
107
+ ) : exams.length === 0 ? (
108
+ <EmptyState
109
+ title="No exams yet"
110
+ description="Create an exam first, then add one or more rubrics to it. Each question on the exam needs its own rubric."
111
+ action={<Button variant="primary" onClick={createExam}>Create your first exam</Button>}
112
+ />
113
+ ) : rubrics.length === 0 ? (
114
+ <EmptyState
115
+ title="No rubrics for this exam"
116
+ description="Add a rubric to start grading. Use a blank rubric or load a sample to see the structure."
117
+ action={
118
+ <div className="flex gap-2 justify-center">
119
+ <Button onClick={startSample}>Load sample</Button>
120
+ <Button variant="primary" onClick={startBlank}>New rubric</Button>
121
+ </div>
122
+ }
123
+ />
124
+ ) : (
125
+ <div className="grid gap-4">
126
+ {rubrics.map(r => <RubricCard key={r.id} rubric={r} />)}
127
+ </div>
128
+ )}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ function ExamPicker({ exams, value, onChange }) {
134
+ return (
135
+ <Select value={value || ''} onChange={e => onChange(Number(e.target.value))} className="!w-auto min-w-[200px]">
136
+ {exams.length === 0 && <option value="">No exams</option>}
137
+ {exams.map(e => <option key={e.id} value={e.id}>{e.title}</option>)}
138
+ </Select>
139
+ )
140
+ }
141
+
142
+ function RubricCard({ rubric }) {
143
+ return (
144
+ <Card>
145
+ <div className="px-5 py-4">
146
+ <div className="flex items-start justify-between gap-4">
147
+ <div className="min-w-0">
148
+ <div className="flex items-center gap-2 mb-1">
149
+ <h3 className="text-headline-sm text-ink truncate">{rubric.title}</h3>
150
+ <Badge variant="brand">{rubric.version}</Badge>
151
+ <Badge>{rubric.max_marks} pts</Badge>
152
+ <Badge>{rubric.criteria.length} criteria</Badge>
153
+ </div>
154
+ <p className="text-body-sm text-ink-muted whitespace-pre-line">{rubric.question_text}</p>
155
+ </div>
156
+ <span className="text-label-sm text-ink-faint shrink-0">#{rubric.id}</span>
157
+ </div>
158
+
159
+ <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
160
+ {rubric.criteria.map((c, i) => (
161
+ <div key={i} className="rounded border border-border bg-surface-subtle px-3 py-2.5">
162
+ <div className="flex items-center justify-between mb-0.5">
163
+ <span className="text-label-sm uppercase tracking-wide text-ink-subtle">#{i + 1}</span>
164
+ <span className="text-label-sm font-medium text-ink">{c.points} pts</span>
165
+ </div>
166
+ <p className="text-body-sm text-ink font-medium truncate">{c.name}</p>
167
+ <p className="text-label-sm text-ink-muted line-clamp-2 mt-0.5">{c.conditions}</p>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ </Card>
173
+ )
174
+ }
175
+
176
+ // ===========================================================================
177
+ // Editor
178
+ // ===========================================================================
179
+ function RubricEditor({ initial, onSave, onCancel }) {
180
+ const [r, setR] = useState(initial)
181
+ const [submitted, setSubmitted] = useState(false)
182
+
183
+ const update = (patch) => setR({ ...r, ...patch })
184
+ const updateCrit = (i, patch) => update({
185
+ criteria: r.criteria.map((c, idx) => idx === i ? { ...c, ...patch } : c),
186
+ })
187
+ const addCrit = () => update({
188
+ criteria: [...r.criteria, { name: '', points: 1, conditions: '', accept_alternatives: '', do_not_deduct_for: '' }],
189
+ })
190
+ const removeCrit = (i) => update({ criteria: r.criteria.filter((_, idx) => idx !== i) })
191
+
192
+ const total = r.criteria.reduce((s, c) => s + (Number(c.points) || 0), 0)
193
+ const mismatch = total !== Number(r.max_marks)
194
+ const allCritsNamed = r.criteria.every(c => c.name.trim())
195
+ const canSave = r.title.trim() && r.question_text.trim() && r.criteria.length > 0 && allCritsNamed
196
+
197
+ const onSaveClick = () => {
198
+ setSubmitted(true)
199
+ if (canSave) onSave(r)
200
+ }
201
+
202
+ return (
203
+ <div>
204
+ <PageHeader
205
+ title={initial._new ? 'New rubric' : 'Edit rubric'}
206
+ description="Fine-grained criteria with explicit conditions, alternatives, and do-not-deduct rules give the grader the least room for misinterpretation."
207
+ actions={
208
+ <>
209
+ <Button onClick={onCancel}>Cancel</Button>
210
+ <Button variant="primary" onClick={onSaveClick} disabled={!canSave}>Save rubric</Button>
211
+ </>
212
+ }
213
+ />
214
+
215
+ {submitted && !canSave && (
216
+ <div className="mb-4 px-4 py-3 rounded-lg border border-danger/30 bg-danger-subtle text-body-sm text-danger-strong">
217
+ Fill in the rubric title, question text, and a name for every criterion before saving.
218
+ </div>
219
+ )}
220
+
221
+ <Card className="mb-4">
222
+ <CardHeader title="Basics" />
223
+ <CardBody>
224
+ <div className="grid grid-cols-3 gap-4 mb-4">
225
+ <div className="col-span-2">
226
+ <Field label="Title" required>
227
+ <Input
228
+ value={r.title}
229
+ onChange={e => update({ title: e.target.value })}
230
+ placeholder="Short, descriptive name"
231
+ invalid={submitted && !r.title.trim()}
232
+ />
233
+ </Field>
234
+ </div>
235
+ <Field label="Max marks">
236
+ <Input
237
+ type="number" min={0}
238
+ value={r.max_marks}
239
+ onChange={e => update({ max_marks: Number(e.target.value) || 0 })}
240
+ />
241
+ </Field>
242
+ </div>
243
+
244
+ <Field label="Question text" required hint="Exactly as the question appears on the exam.">
245
+ <TextArea
246
+ rows={3}
247
+ value={r.question_text}
248
+ onChange={e => update({ question_text: e.target.value })}
249
+ invalid={submitted && !r.question_text.trim()}
250
+ />
251
+ </Field>
252
+ </CardBody>
253
+ </Card>
254
+
255
+ <Card className="mb-4">
256
+ <CardHeader
257
+ title="Grading instructions"
258
+ description="Course-level anti-hallucination guardrails passed to the grader on every call."
259
+ />
260
+ <CardBody>
261
+ <TextArea
262
+ rows={5}
263
+ value={r.course_instructions}
264
+ onChange={e => update({ course_instructions: e.target.value })}
265
+ />
266
+ </CardBody>
267
+ </Card>
268
+
269
+ <Card>
270
+ <CardHeader
271
+ title="Criteria"
272
+ description={`${total} / ${r.max_marks} pts allocated${mismatch ? ' • totals do not match' : ''}`}
273
+ action={<Button onClick={addCrit}>Add criterion</Button>}
274
+ />
275
+ <div className="divide-y divide-border">
276
+ {r.criteria.map((c, i) => (
277
+ <CriterionRow
278
+ key={i}
279
+ index={i}
280
+ criterion={c}
281
+ showError={submitted && !c.name.trim()}
282
+ onChange={(patch) => updateCrit(i, patch)}
283
+ onRemove={() => removeCrit(i)}
284
+ canRemove={r.criteria.length > 1}
285
+ />
286
+ ))}
287
+ </div>
288
+ </Card>
289
+ </div>
290
+ )
291
+ }
292
+
293
+ function CriterionRow({ index, criterion, showError, onChange, onRemove, canRemove }) {
294
+ return (
295
+ <div className="px-5 py-4">
296
+ {/* Top row: index + name + points + remove. Uses GRID with explicit
297
+ column widths so the name field cannot get visually squashed. */}
298
+ <div className="grid items-center gap-3 mb-3"
299
+ style={{ gridTemplateColumns: '32px minmax(0,1fr) 96px auto' }}>
300
+ <span className="text-label-md font-medium text-ink-subtle">#{index + 1}</span>
301
+ <Input
302
+ value={criterion.name}
303
+ onChange={e => onChange({ name: e.target.value })}
304
+ placeholder="Criterion name"
305
+ invalid={showError}
306
+ />
307
+ <div className="flex items-center gap-1.5">
308
+ <Input
309
+ type="number" min={0} step="0.5"
310
+ value={criterion.points}
311
+ onChange={e => onChange({ points: Number(e.target.value) || 0 })}
312
+ className="text-right"
313
+ />
314
+ <span className="text-label-sm text-ink-muted">pts</span>
315
+ </div>
316
+ <Button
317
+ variant="ghost" size="icon" onClick={onRemove} disabled={!canRemove}
318
+ aria-label="Remove criterion"
319
+ >
320
+ <span aria-hidden="true">×</span>
321
+ </Button>
322
+ </div>
323
+
324
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-3 pl-11">
325
+ <Field label="Conditions">
326
+ <TextArea rows={3}
327
+ value={criterion.conditions}
328
+ onChange={e => onChange({ conditions: e.target.value })}
329
+ placeholder="What must the student show to earn these points?"
330
+ />
331
+ </Field>
332
+ <Field label="Accept also">
333
+ <TextArea rows={3}
334
+ value={criterion.accept_alternatives}
335
+ onChange={e => onChange({ accept_alternatives: e.target.value })}
336
+ placeholder="Equivalent forms or alternative paths"
337
+ />
338
+ </Field>
339
+ <Field label="Do not deduct for">
340
+ <TextArea rows={3}
341
+ value={criterion.do_not_deduct_for}
342
+ onChange={e => onChange({ do_not_deduct_for: e.target.value })}
343
+ placeholder="Surface issues to ignore"
344
+ />
345
+ </Field>
346
+ </div>
347
+ </div>
348
+ )
349
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,jsx}'],
4
+ theme: {
5
+ extend: {
6
+ fontFamily: {
7
+ sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
8
+ },
9
+ colors: {
10
+ // Surface scale (Material 3 tonal palette adapted to the spec)
11
+ background: '#F9FAFB',
12
+ surface: {
13
+ DEFAULT: '#FFFFFF',
14
+ subtle: '#F9FAFB',
15
+ muted: '#F3F4F6',
16
+ inset: '#F1F3FF',
17
+ },
18
+ // Strokes
19
+ border: {
20
+ DEFAULT: '#E5E7EB',
21
+ strong: '#D1D5DB',
22
+ subtle: '#F3F4F6',
23
+ },
24
+ // Text
25
+ ink: {
26
+ DEFAULT: '#111827',
27
+ muted: '#4B5563',
28
+ subtle: '#6B7280',
29
+ faint: '#9CA3AF',
30
+ },
31
+ // Brand (indigo)
32
+ brand: {
33
+ DEFAULT: '#4F46E5',
34
+ hover: '#4338CA',
35
+ fg: '#FFFFFF',
36
+ subtle: '#EEF2FF',
37
+ ring: 'rgba(79, 70, 229, 0.12)',
38
+ },
39
+ // Status
40
+ success: { DEFAULT: '#059669', subtle: '#ECFDF5', strong: '#047857' },
41
+ warning: { DEFAULT: '#D97706', subtle: '#FFFBEB', strong: '#B45309' },
42
+ danger: { DEFAULT: '#DC2626', subtle: '#FEF2F2', strong: '#B91C1C' },
43
+ info: { DEFAULT: '#2563EB', subtle: '#EFF6FF', strong: '#1D4ED8' },
44
+ },
45
+ fontSize: {
46
+ // Spec-defined scale
47
+ 'display-lg': ['48px', { lineHeight: '56px', letterSpacing: '-0.02em', fontWeight: '700' }],
48
+ 'headline-lg':['32px', { lineHeight: '40px', letterSpacing: '-0.02em', fontWeight: '600' }],
49
+ 'headline-md':['24px', { lineHeight: '32px', letterSpacing: '-0.01em', fontWeight: '600' }],
50
+ 'headline-sm':['20px', { lineHeight: '28px', fontWeight: '600' }],
51
+ 'body-lg': ['18px', { lineHeight: '28px', fontWeight: '400' }],
52
+ 'body-md': ['16px', { lineHeight: '24px', fontWeight: '400' }],
53
+ 'body-sm': ['14px', { lineHeight: '20px', fontWeight: '400' }],
54
+ 'label-md': ['14px', { lineHeight: '20px', fontWeight: '500' }],
55
+ 'label-sm': ['12px', { lineHeight: '16px', letterSpacing: '0.01em', fontWeight: '500' }],
56
+ },
57
+ borderRadius: {
58
+ sm: '0.125rem',
59
+ DEFAULT: '0.25rem',
60
+ md: '0.375rem',
61
+ lg: '0.5rem',
62
+ xl: '0.75rem',
63
+ '2xl': '1rem',
64
+ },
65
+ boxShadow: {
66
+ // Soft, low-opacity, large-blur — never aggressive
67
+ card: '0 1px 2px 0 rgba(17, 24, 39, 0.04)',
68
+ elevated: '0 4px 12px -2px rgba(17, 24, 39, 0.08), 0 1px 2px rgba(17, 24, 39, 0.04)',
69
+ popover: '0 8px 24px -6px rgba(17, 24, 39, 0.12), 0 2px 4px rgba(17, 24, 39, 0.04)',
70
+ 'focus-ring': '0 0 0 3px rgba(79, 70, 229, 0.18)',
71
+ },
72
+ transitionTimingFunction: {
73
+ 'standard': 'cubic-bezier(0.2, 0, 0, 1)',
74
+ },
75
+ spacing: {
76
+ '13': '3.25rem',
77
+ '15': '3.75rem',
78
+ },
79
+ },
80
+ },
81
+ plugins: [],
82
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // Proxy /api/* to the FastAPI backend at 8000 so we don't fight CORS in dev.
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ server: {
8
+ port: 5173,
9
+ proxy: {
10
+ '/api': {
11
+ target: 'http://localhost:8000',
12
+ changeOrigin: true,
13
+ rewrite: (path) => path.replace(/^\/api/, ''),
14
+ },
15
+ },
16
+ },
17
+ })
requirements.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Web layer ---
2
+ fastapi>=0.110
3
+ uvicorn[standard]>=0.27
4
+ python-multipart>=0.0.6
5
+ python-dotenv>=1.0
6
+
7
+ # --- Data layer ---
8
+ sqlalchemy>=2.0
9
+ # Use psycopg2-binary for Postgres. SQLite works out of the box for dev.
10
+ psycopg2-binary>=2.9
11
+ pydantic>=2.5
12
+ pydantic-settings>=2.1
13
+
14
+ # --- Ingestion / image ---
15
+ pymupdf>=1.23
16
+ pillow>=10.0
17
+ numpy>=1.24
18
+ opencv-python-headless>=4.9
19
+
20
+ # --- LLM stack (agentic) ---
21
+ anthropic>=0.40
22
+ langchain>=0.3
23
+ langchain-anthropic>=0.2
24
+ langchain-google-genai>=2.0
25
+ google-generativeai>=0.8
26
+ langgraph>=0.2
27
+
28
+ # --- Plagiarism / embeddings ---
29
+ sentence-transformers>=2.5
30
+ scikit-learn>=1.4
31
+
32
+ # --- Optional: Qwen-VL via HuggingFace (requires GPU) ---
33
+ # Uncomment to enable the qwen_vl adapter. Demo runs without these.
34
+ # transformers>=4.45
35
+ # torch>=2.2
36
+ # accelerate>=0.30
scripts/init_db.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initialize the database. Idempotent. Run once after install."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
6
+
7
+ from backend.db import init_db, SessionLocal, User
8
+
9
+
10
+ def main():
11
+ init_db()
12
+ db = SessionLocal()
13
+ try:
14
+ if db.query(User).count() == 0:
15
+ db.add_all([
16
+ User(email="instructor@gradeops.local", name="Instructor", role="instructor"),
17
+ User(email="ta@gradeops.local", name="TA", role="ta"),
18
+ ])
19
+ db.commit()
20
+ print("seeded default users (instructor + ta)")
21
+ else:
22
+ print(f"db already has {db.query(User).count()} user(s); skipping seed")
23
+ finally:
24
+ db.close()
25
+ print("db ready")
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
scripts/run_demo.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command-line walkthrough of the full grading pipeline.
2
+
3
+ Usage:
4
+ python scripts/run_demo.py path/to/answer.jpg
5
+ python scripts/run_demo.py path/to/exam.pdf
6
+
7
+ Prints a step-by-step trace of every phase: ingest → OCR → agentic grade
8
+ → aggregate. The trace is useful for showing the system working without
9
+ the UI in the loop.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from backend.config import settings
19
+ from backend.ingestion import (
20
+ split_pdf_to_pages, detect_answer_regions, crop_region, anonymize_crop,
21
+ )
22
+ from backend.ocr import transcribe as ocr_transcribe
23
+ from backend.grader import grade_multi_pass, aggregate_scores
24
+
25
+
26
+ SAMPLE_RUBRIC = {
27
+ "question_text": "Solve for x and show all steps:\n2x + 5 = 15",
28
+ "max_marks": 10,
29
+ "criteria": [
30
+ {"name": "Isolate the variable term", "points": 5,
31
+ "conditions": "Student writes 2x = 10.",
32
+ "accept_alternatives": "Equivalent rearrangements arriving at 2x = 10.",
33
+ "do_not_deduct_for": "Minor notation differences."},
34
+ {"name": "Solve for x", "points": 5,
35
+ "conditions": "Student writes x = 5.",
36
+ "accept_alternatives": "Equivalent forms.",
37
+ "do_not_deduct_for": "Minor notation differences."},
38
+ ],
39
+ }
40
+
41
+
42
+ def banner(text: str) -> None:
43
+ line = "─" * 70
44
+ print(f"\n{line}\n {text}\n{line}")
45
+
46
+
47
+ def run(image_or_pdf: Path) -> None:
48
+ storage = settings.storage_path
49
+ banner(f"GradeOps demo · model={settings.grader_model} · OCR={settings.ocr_backend}")
50
+ print(f"input: {image_or_pdf}")
51
+
52
+ banner("Step 1 · Ingest")
53
+ if image_or_pdf.suffix.lower() == ".pdf":
54
+ pages = split_pdf_to_pages(str(image_or_pdf), str(storage / "pages"))
55
+ print(f" split into {len(pages)} page(s)")
56
+ else:
57
+ pages = [str(image_or_pdf)]
58
+ print(" single image — no PDF split needed")
59
+
60
+ all_crops = []
61
+ for p in pages:
62
+ regions = detect_answer_regions(p)
63
+ print(f" {Path(p).name}: detected {len(regions)} answer region(s)")
64
+ for r in regions:
65
+ raw = crop_region(p, r["bbox"], str(storage / "crops"))
66
+ anon = anonymize_crop(raw, str(storage / "crops"))
67
+ all_crops.append(anon)
68
+ print(f" cropped → {Path(anon).name}")
69
+
70
+ banner("Step 2 · OCR")
71
+ for c in all_crops:
72
+ try:
73
+ t = ocr_transcribe(c)
74
+ except Exception as e:
75
+ t = f"[OCR failed: {e}]"
76
+ print(f" {Path(c).name}:")
77
+ for line in t.splitlines()[:8]:
78
+ print(f" | {line}")
79
+ if len(t.splitlines()) > 8:
80
+ print(f" | ... [{len(t.splitlines()) - 8} more lines]")
81
+
82
+ banner(f"Step 3 · Agentic grading · {settings.grader_num_passes} pass(es)")
83
+ for c in all_crops:
84
+ print(f"\n crop: {Path(c).name}")
85
+ passes = grade_multi_pass(
86
+ c,
87
+ question=SAMPLE_RUBRIC["question_text"],
88
+ max_marks=SAMPLE_RUBRIC["max_marks"],
89
+ criteria=SAMPLE_RUBRIC["criteria"],
90
+ )
91
+ for i, p in enumerate(passes, 1):
92
+ print(f" pass {i}: {p['score']:.1f}/{p['max_score']:.0f}")
93
+ agg = aggregate_scores(passes)
94
+ print(f" → median {agg['median']} · max {agg['max_score']} · min {agg['min_score']} · σ {agg['std_dev']:.2f}")
95
+ med = min(passes, key=lambda p: abs(p["score"] - agg["median"]))
96
+ print(f" justification: {med['justification']}")
97
+
98
+ banner("done")
99
+
100
+
101
+ def main():
102
+ if len(sys.argv) > 1:
103
+ target = Path(sys.argv[1]).expanduser().resolve()
104
+ if not target.exists():
105
+ print(f"file not found: {target}")
106
+ sys.exit(2)
107
+ else:
108
+ samples_dir = Path(__file__).resolve().parent.parent / "data" / "samples"
109
+ candidates = (
110
+ sorted(samples_dir.glob("*.png")) + sorted(samples_dir.glob("*.jpg")) +
111
+ sorted(samples_dir.glob("*.jpeg")) + sorted(samples_dir.glob("*.pdf"))
112
+ )
113
+ if not candidates:
114
+ print("Usage: python scripts/run_demo.py path/to/answer.{jpg,pdf}")
115
+ sys.exit(1)
116
+ target = candidates[0]
117
+
118
+ if not settings.google_api_key and not settings.anthropic_api_key:
119
+ print("ERROR: No API key configured. Set GOOGLE_API_KEY (or ANTHROPIC_API_KEY) in .env.")
120
+ sys.exit(3)
121
+
122
+ run(target)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
scripts/seed_demo.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed a sample exam and rubric so a fresh install has something to show.
2
+
3
+ Run once after init_db.py. Idempotent — skips if any exam exists.
4
+ """
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
+
10
+ from backend.db import SessionLocal, Exam, Rubric
11
+
12
+
13
+ SAMPLE_RUBRIC_CRITERIA = [
14
+ {
15
+ "name": "Isolate the variable term",
16
+ "points": 5,
17
+ "conditions": "Student subtracts 5 from both sides to obtain 2x = 10.",
18
+ "accept_alternatives": "Equivalent algebraic rearrangements that arrive at 2x = 10.",
19
+ "do_not_deduct_for": "Minor handwriting or notation differences.",
20
+ },
21
+ {
22
+ "name": "Solve for x",
23
+ "points": 5,
24
+ "conditions": "Student divides both sides by 2 and obtains x = 5.",
25
+ "accept_alternatives": "Equivalent forms such as x=5 or x = 5.0.",
26
+ "do_not_deduct_for": "Minor notation differences.",
27
+ },
28
+ ]
29
+
30
+ DEFAULT_INSTRUCTIONS = """Base all scoring strictly on evidence visible in the student's handwritten work. If evidence is missing, assign zero — do not guess, infer, or hallucinate.
31
+ Award credit for intermediate steps only if explicitly written by the student. Do not infer non-trivial reasoning from a correct final answer except for trivial algebraic simplifications.
32
+ If the student's solution does not match the question being asked, assign a score of 0."""
33
+
34
+
35
+ def main():
36
+ db = SessionLocal()
37
+ try:
38
+ if db.query(Exam).count() > 0:
39
+ print("an exam already exists; skipping seed")
40
+ return
41
+
42
+ exam = Exam(title="Sample exam")
43
+ db.add(exam); db.commit(); db.refresh(exam)
44
+
45
+ rubric = Rubric(
46
+ exam_id=exam.id,
47
+ version="v1",
48
+ title="Linear equation — sample",
49
+ question_text="Solve for x and show all steps:\n\n2x + 5 = 15",
50
+ max_marks=10,
51
+ course_instructions=DEFAULT_INSTRUCTIONS,
52
+ criteria=SAMPLE_RUBRIC_CRITERIA,
53
+ )
54
+ db.add(rubric); db.commit(); db.refresh(rubric)
55
+ print(f"seeded exam #{exam.id} '{exam.title}'")
56
+ print(f"seeded rubric #{rubric.id} '{rubric.title}' ({rubric.version})")
57
+ finally:
58
+ db.close()
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
storage/crops/.gitkeep ADDED
File without changes
storage/pages/.gitkeep ADDED
File without changes