Spaces:
Running
Running
Commit ·
b5bd684
0
Parent(s):
initial commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +49 -0
- .gitignore +28 -0
- README.md +223 -0
- backend/__init__.py +0 -0
- backend/audit.py +41 -0
- backend/config.py +89 -0
- backend/db.py +180 -0
- backend/grader/__init__.py +2 -0
- backend/grader/aggregate.py +32 -0
- backend/grader/graph.py +99 -0
- backend/grader/llm.py +88 -0
- backend/grader/nodes.py +166 -0
- backend/grader/prompts.py +168 -0
- backend/grader/rate_limit.py +35 -0
- backend/grader/state.py +35 -0
- backend/ingestion/__init__.py +3 -0
- backend/ingestion/anonymize.py +43 -0
- backend/ingestion/crop.py +120 -0
- backend/ingestion/pdf_split.py +39 -0
- backend/main.py +490 -0
- backend/ocr/__init__.py +1 -0
- backend/ocr/hosted_adapter.py +45 -0
- backend/ocr/qwen_vl_adapter.py +75 -0
- backend/ocr/router.py +21 -0
- backend/plagiarism.py +62 -0
- backend/schemas.py +164 -0
- data/samples/.gitkeep +0 -0
- debug_llm.py +95 -0
- docker-compose.yml +23 -0
- frontend/index.html +18 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +24 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.jsx +118 -0
- frontend/src/api.js +43 -0
- frontend/src/components/ui.jsx +203 -0
- frontend/src/index.css +28 -0
- frontend/src/main.jsx +13 -0
- frontend/src/pages/Audit.jsx +149 -0
- frontend/src/pages/Grading.jsx +169 -0
- frontend/src/pages/Review.jsx +302 -0
- frontend/src/pages/Rubrics.jsx +349 -0
- frontend/tailwind.config.js +82 -0
- frontend/vite.config.js +17 -0
- requirements.txt +36 -0
- scripts/init_db.py +29 -0
- scripts/run_demo.py +126 -0
- scripts/seed_demo.py +62 -0
- storage/crops/.gitkeep +0 -0
- 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
|