Compare commits

...
Author SHA1 Message Date
kcar 900e2613b4 Add class markbook tab 2026-07-24 18:18:50 +00:00
kcarandClaude Opus 4.8 c346493a34 WS-2 R3: containers/preamble as background backdrops, tick-N, no false overlap flag
app-ci-deploy / test-build-deploy (push) Has been cancelled
From KC's 2nd review:
- z-order: boundaries, preamble bands and container frames drawn behind; figures,
  responses and parts on top (a figure's description tooltip is no longer blocked
  by the preamble box). Preamble + containers are pointerEvents:none.
- the 'large box around all content' is gone: a container renders as a small
  corner tab (label + total marks), not an enclosing box; intermediate part-groups
  keep a faint dashed nesting frame. Main-question extent is shown by its boundaries.
- MC 'tick N' count (select_n) surfaced next to the option boxes.
- fixed the spurious 'overlapping shapes' review flag on containers (they legitimately
  enclose their children; only genuine partial overlaps between non-containers flag now).
tsc clean; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 13:13:46 +00:00
kcarandClaude Opus 4.8 81e1b5ed25 WS-2 R2: render answer sub-structure on response boxes (components/MC boxes/units)
app-ci-deploy / test-build-deploy (push) Has been cancelled
From KC's review — the canvas now draws the detail the spike detected:
- numbered sub-answer bands ('suggest two' → dashed dividers + 1/2 labels),
- per-option MC selection boxes (drawn from meta.boxes rel coords; filled tint
  when detected marked),
- the final-answer line's expected quantity + unit pill.
Region meta (components/boxes/final_answer_line + figure name/desc) is carried
through load→shape props (metaJson)→render, and round-trips on save via
exam_response_areas.meta. tsc clean; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:57:01 +00:00
kcarandClaude Opus 4.8 e0e36225fe WS-2 items 4+5: draw structure on canvas + preserve N-deep tree on save
app-ci-deploy / test-build-deploy (push) Has been cancelled
Round-trip fix (item 5): serializeCanvasShapes no longer re-derives the question
tree from boundary pairs (which flattened 3-deep → 2-tier). The whole tree is now
carried as 'part' shapes that hold parentId + isContainer + depth; save preserves
the real parent_id chains (persisted parent wins; geometry is only a fallback for
manually-drawn shapes). Boundary-pair synthesis is kept but attaches to a covering
container box when one exists. New test locks a 3-deep Q→part→subpart round-trip.

Draw structure (item 4): shapesFromTemplate now draws containers too (were skipped),
each with nesting depth. examCanvasShapes renders container frames (transparent,
depth-hued border), PRINTS MARKS on part/container boxes (◆N pill; were never
drawn), and NAMES context figures (figure name as label + description tooltip +
'→ Q' tether pill; was an anonymous purple box). Context name/description round-trip
via exam_response_areas.meta.

Threads new shape props through the tldraw util + setup page + types. Adds meta to
ExamResponseArea + replace payload. tsc: no new errors; 7/7 model tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:13:13 +00:00
kcar dbab592ce8 Merge P4 digital-text viewer
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-03 02:09:34 +00:00
kcarandClaude Opus 4.8 0c46582ec3 P4 app: Digital text viewer + extraction audit chip on setup page
'Digital text' button opens the digital-replica markdown (stem text, parts, marks,
answer-space placeholders, spec refs) in a dialog with download. A 'Marks N/M' chip
surfaces the extraction cover-total reconciliation (green=ok / amber=under-read) from
extraction_meta.audit. examRepository.getDigitalText + DigitalTextResponse type.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 02:09:34 +00:00
kcar e6ecc723cc Merge exam-canvas form badge
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 23:55:43 +00:00
kcarandClaude Opus 4.8 0f44dc614c Exam canvas: show recognised response FORM on each ghost
The extraction service types every answer space (multiple choice, draw-on, table,
ruled lines…), but the canvas rendered them all as a generic blue 'Response' box.
Add a small form badge (bottom-right) so the recognition is legible at a glance;
context regions show their type too. Colour-by-kind is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 23:55:43 +00:00
kcar 1bb2011e58 Merge exam-bank corpus coverage UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:35:35 +00:00
kcarandClaude Opus 4.8 f4be133069 Exam bank: corpus coverage dashboard (/exam-marker/corpus)
New "Exam bank" page displaying the state of the collected past-paper corpus:
per-specification coverage of question papers / mark schemes / examiner reports,
with global + filtered rollups, board/level/subject filters, a "sciences only"
default, and an expandable per-session table (QP/MS/ER present ✓/–). Reads
GET /api/exam/corpus. Dashboard gets an "Exam bank" entry point.

examRepository.getCorpus + corpus types. tsc clean on the changed files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:35:35 +00:00
kcar d47e0205c9 Merge mode-3 bank crop thumbnails
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:19:08 +00:00
kcarandClaude Opus 4.8 11d19778d6 Mode-3: crop thumbnails on question bank cards
Render a preview per bank question, client-side, from the source PDF cropped to
the question's bounds — makes the bank visually browsable like the spike cockpit.

bankCrops.ts: renders each template's PDF once (cached) via the existing pdf.js
loader (already at the 780px coordinate space the bounds live in), then crops the
page image to (bounds.x, bounds.y - pageTop, w, h) → a small PNG data-URL; decoded
page images are cached too. QuestionBankPage: a lazy <QuestionCrop> at the top of
each card (spinner → image, or "no preview" when a question has no bounds/PDF).

No API/infra change. tsc clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:19:08 +00:00
kcar a80bdea140 Merge mode-3 question bank UI
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:09:28 +00:00
kcarandClaude Opus 4.8 905626f494 Mode-3: Question bank page + custom-paper assembly UI
New /exam-marker/bank page: browse leaf questions across your papers, filter by
spec point (the spec-planning surface) and subject via facet chips, select
questions into a basket, and "Create paper" → POST /custom-papers → opens the new
template in setup. Dashboard gets a "Question bank" entry point.

examRepository.getBank + createCustomPaper; BankQuestion/BankResponse/custom-paper
types. tsc clean. Pairs with the mode-3 API endpoints (already merged).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 22:09:28 +00:00
kcar b52f6c54ba Merge fx-5-scan-ingestion
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 21:48:32 +00:00
kcar e6a2480818 Merge fx-8-response-area-attach 2026-07-02 21:48:32 +00:00
kcar 2b33aa80eb Merge fx-4-confirm-dismiss 2026-07-02 21:48:32 +00:00
CC WorkerandClaude Opus 4.8 7a1dd4dccc FX-5: scan-ingestion UI on the marking page
The marking flow read student_submissions but there was no way to create them —
print→scan→mark could not be completed in-app. Add an "Upload scans" control to
the marking queue that posts PDFs to the existing POST /batches/{id}/scans and
refreshes the queue.

- examRepository.uploadScan(batchId, file, {studentId?, matchingMethod?}): multipart
  upload (axios sets the boundary), returns the created/updated StudentSubmission.
- ExamMarkingPage: "Upload scans" button + hidden multi-file PDF input; uploads each
  with matching_method='ordered' (fills the roster in order; unmatched scans become
  reconcilable submissions), then reloads the queue with a success/failure summary.

No API or schema change (endpoint already existed). tsc clean; behaviour is covered
by the cc-browser-tester card (FX-V).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:44:04 +00:00
CC WorkerandClaude Opus 4.8 4001772d5e FX-8: stop dropping / mis-attaching response areas on save
serializeCanvasShapes recomputed each region's owner purely from geometry,
ignoring the persisted question_id: a region with no enclosing part (or any
region when a template had no parts) was silently dropped, and one overflowing
its part by a pixel re-parented to parts[0].

Resolve the owner in order: geometric containment (a drag into a part re-attaches)
-> persisted question_id if it still points at a saved question (survives drift /
no-container) -> nearest part on the page -> first part -> first question of any
kind. exam_response_areas.question_id is NOT NULL, so a region is never dropped
while any question exists.

Tests: never drops a region with no enclosing part; persisted question wins over
geometric nearest. Full model.test.ts suite (6) passes; tsc clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:40:11 +00:00
CC WorkerandClaude Opus 4.8 f72ea7189c FX-4: confirm/dismiss UI for AI ghost suggestions
The S5-7 review layer could surface unconfirmed AI shapes but had no way to
resolve them (confirmed was never set true), so the only "dismiss" was deletion
and the unconfirmed count never dropped. Add the resolution loop:

- A floating review-actions panel (shown while unconfirmed > 0): Accept selected,
  Reject (delete) selected, and Accept all AI on the page. Accept flips
  props.confirmed = true.
- Human edit = implicit confirm: nudging/resizing an AI ghost marks it confirmed
  (R3.3 — a teacher who adjusts a suggestion has reviewed it). Geometry-only
  trigger, so the confirmed-only update can't recurse.
- Confidence-driven opacity: unconfirmed ghosts fade by confidence instead of a
  constant 0.72; confirmed AI renders solid with its kind icon (like manual).
- Live review counts read straight off the canvas so the unconfirmed chip/panel
  update immediately on accept/reject (the old count reflected only saved state).

Persists through the existing PUT (confirmed already round-trips). No schema or
API change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-02 21:08:38 +00:00
CC WorkerandClaude Opus 4.8 7bd66fbaf0 fix(exam): stop placeholder guide shapes flashing before template loads
app-ci-deploy / test-build-deploy (push) Has been cancelled
seedGuide() ran in onMount via the 'else' branch while template was still null during the async
fetch, creating 5 example shapes (Q1 start/end, part, response, context) that flashed on screen
until the real template + PDF loaded. Only seed the guide for a genuinely-empty template after load.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-08 19:19:15 +00:00
kcar 9c3a5f97cc docs(admin): clarify exam-corpus reset warning
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-08 00:58:03 +01:00
CC WorkerandClaude Opus 4.8 2ccfb9ccd6 fix(dev): route Supabase same-origin via /__supabase proxy
app-ci-deploy / test-build-deploy (push) Has been cancelled
app-dev built with --mode production was baking the PROD Supabase URL (.env)
into the bundle, so browser auth went cross-origin to supa.classroomcopilot.ai
and was CORS-blocked (and hit the wrong user store). Mirror the /__ccapi fix:
- Dockerfile: nginx /__supabase/ -> dev Supabase .94:8000 (+WS upgrade for realtime)
- supabaseClient.ts: resolve a leading-slash VITE_SUPABASE_URL against
  window.location.origin so supabase-js gets an absolute same-origin URL
- docker-compose.dev.yml: bake VITE_SUPABASE_URL=/__supabase (like VITE_API_BASE)
Browser now talks only to the app host (Tailscale or LAN), no CORS, dev .94 store.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 21:20:57 +00:00
CC Worker ef13a124dd Merge S5-7 G6 review wiring
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/pages/exam/setup/ExamTemplateSetupPage.tsx
#	src/pages/exam/setup/examCanvasShapes.tsx
#	src/utils/exam-canvas/model.ts
2026-06-07 20:06:45 +00:00
CC Worker ffa0ad85ac Merge S4-11: marking flow + class results + same-origin API proxy fix
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:04:07 +00:00
kcarandClaude Opus 4.8 92f9dfef82 S5-7: basic G6 review wiring (dashed/translucent AI shapes, confidence, flags)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 20:56:57 +01:00
kcar 824031f2c0 feat(exam): add auto-map PDF canvas refresh
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:46:41 +01:00
kcar 7db852aaff [verified] route app-dev API through same-origin proxy 2026-06-07 20:43:20 +01:00
CC Worker afc0371dd9 Merge S5-6 layout/provenance types + repository (App)
app-ci-deploy / test-build-deploy (push) Has been cancelled
# Conflicts:
#	src/utils/exam-canvas/model.ts
2026-06-07 19:22:24 +00:00
kcar 7326b9f3be [verified] fix app-dev api proxy and build env 2026-06-07 20:20:42 +01:00
kcar cd8ac38d39 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results 2026-06-07 20:13:43 +01:00
kcar 469bcc0517 [verified] align app exam layout payloads 2026-06-07 20:05:49 +01:00
kcar e899af303d Make exam boundaries page-width lines
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 20:01:54 +01:00
kcar 7a01b3e8f6 Merge remote-tracking branch 'origin/master' into agent/s4-11-marking-results
# Conflicts:
#	src/AppRoutes.tsx
#	src/pages/exam/index.ts
#	src/services/exam/examRepository.ts
#	src/types/exam.types.ts
2026-06-07 19:58:38 +01:00
kcar bff91a4b17 feat(exam): S4-11 marking flow and class results 2026-06-07 19:50:20 +01:00
CC WorkerandClaude Sonnet 4.6 66f35b8ae4 fix(exam): compact top bar, collapsible guide panel
app-ci-deploy / test-build-deploy (push) Has been cancelled
Top bar reduced to single-line height: Back becomes an icon button, caption
line removed (detail lives in the guide), Save button size=small. Guide
panel defaults collapsed and toggles via a ? icon button at bottom-right
so it doesn't occupy permanent canvas real estate.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 13:37:47 +00:00
CC WorkerandClaude Sonnet 4.6 fe5dbe7fa8 feat(exam): doc-view camera constraints and sidebar layout
app-ci-deploy / test-build-deploy (push) Has been cancelled
Replaces infinite-canvas free-pan with a constrained vertical-scroll doc
view: tldraw setCameraOptions with behavior='contain', fit-x-100, and top
origin so the PDF never drifts side-to-side. Layout restructured from
floating overlays to flex column (top bar + sidebar/canvas row). Tool
panel is now a left sidebar, guide panel overlays canvas bottom-right.

PAGE_START_X changed from 260 → 0 so pages are flush left; camera
applyDocViewConstraints() called incrementally as pages stream in and
with resetZoom() once all pages are loaded.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 12:36:21 +00:00
CC WorkerandClaude Sonnet 4.6 15a519748d fix(exam): keep region shapes overlaid on PDF in setup canvas
app-ci-deploy / test-build-deploy (push) Has been cancelled
Three z-order fixes in ExamTemplateSetupPage:

1. loadShapes: early-return before deletion when models is empty, so
   seed guide shapes aren't wiped for a fresh template that has no
   saved regions yet.

2. Incremental PDF loading (onPageReady): replace per-page sendToBack
   (unreliable when all shapes are moving — reorderToBack no-ops) with
   bringToFront on existing domain shapes after each page is added.

3. Final load sequence: call bringDomainShapesToFront after syncPdfPages
   + loadShapes to guarantee correct z-order regardless of how tldraw's
   fractional indexer placed newly created shapes. Also called from
   onMount for the same reason.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 10:12:16 +00:00
CC WorkerandClaude Sonnet 4.6 3389fdcb5b feat(exam): S4-11 marking flow, results table, CSV, ResultsWidget
app-ci-deploy / test-build-deploy (push) Has been cancelled
- /exam-marker/:batchId/mark — ExamMarkingPage: student queue, per-part mark entry, upsert marks
- /exam-marker/:batchId/results — ExamResultsPage: results table with absent rows, CSV download
- ResultsWidget on ClassDetailPage: last batch summary, class average, absent count, batch creation
- New types: MarkingBatch, StudentSubmission, BatchQueueResponse, BatchResultsResponse, MarkUpsertPayload
- New repo methods: createBatch, listBatches, getBatchQueue, getBatchResults, getBatchCsv, upsertMark

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:35:34 +00:00
CC WorkerandClaude Sonnet 4.6 ab35193be1 Merge S4-10: mark scheme editor + SpecPoint picker
app-ci-deploy / test-build-deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:31:02 +00:00
CC WorkerandClaude Sonnet 4.6 b3f71c5749 Merge S4-10: mark scheme editor + SpecPoint picker
- Add /exam-marker/:templateId/marks route with MarkSchemePage
- Per-Part mark scheme editor: points/levels/parts/checklist/free forms
- SpecPoint picker via GET /api/exam/specs/{spec_code}/points (falls back to manual spec_ref when endpoint 404s)
- Manual neo4j-sync button; ASSESSES edge verified in cc.public.exams
- Edit marks button on each template card in dashboard
- Merge-resolved: AppRoutes, index.ts, exam.types.ts, ExamDashboardPage (kept grouped UI + added Edit marks button)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 04:30:41 +00:00
kcar 1ab8aab43c feat(exam): add mark scheme editor 2026-06-07 00:34:56 +01:00
23 changed files with 3398 additions and 142 deletions
+58 -7
View File
@@ -7,13 +7,37 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
COPY . .
# Vite bakes VITE_* values at build time, so compose must choose the env file
# during image build, not only at container runtime.
ARG ENV_FILE=.env
COPY ${ENV_FILE} .env
# Run build with production mode
RUN npm run build -- --mode production
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
ARG VITE_API_BASE
ARG VITE_API_URL
ARG VITE_APP_NAME
ARG VITE_APP_HMR_URL
ARG VITE_DEV
ARG VITE_FRONTEND_SITE_URL
ARG VITE_SEARCH_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_SUPABASE_URL
ARG VITE_SUPER_ADMIN_EMAIL
ARG VITE_TLSYNC_URL
ARG VITE_WHISPERLIVE_URL
# Run build with production mode. Keep these as build-step environment values
# rather than final-image ENV entries; Vite still embeds the public client config
# into the static bundle, but nginx image metadata does not need them.
RUN VITE_API_BASE="${VITE_API_BASE}" \
VITE_API_URL="${VITE_API_URL}" \
VITE_APP_NAME="${VITE_APP_NAME}" \
VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
VITE_DEV="${VITE_DEV}" \
VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
npm run build -- --mode production
FROM nginx:alpine
# Copy built files
@@ -31,6 +55,33 @@ RUN echo 'server { \
expires -1; \
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
} \
location = /health { \
proxy_pass http://192.168.0.64:18000/health; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__ccapi/ { \
proxy_pass http://192.168.0.64:18000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location /__supabase/ { \
proxy_pass http://192.168.0.94:8000/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_http_version 1.1; \
} \
location /api/ { \
proxy_pass http://192.168.0.64:18000/api/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
} \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
+22 -1
View File
@@ -16,7 +16,28 @@ services:
context: .
dockerfile: Dockerfile
args:
ENV_FILE: .env.dev
# app-dev is served by nginx on the app host; browser API calls must stay
# same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
# strips that prefix before forwarding, preserving mixed backend routes
# such as /api/exam, /me/bootstrap, and /database/timetable.
# Supabase is likewise routed same-origin via Dockerfile's /__supabase
# proxy -> dev Supabase .94 (no CORS, browser-network-agnostic). The
# leading-slash URL is resolved against window.location.origin in
# supabaseClient.ts. Provide the matching .94 anon key as a build arg
# (VITE_SUPABASE_ANON_KEY) so it pairs with the .94 backend.
# .env.dev still points at the LAN API/Supabase for local Vite/dev tooling.
VITE_API_BASE: /__ccapi
VITE_API_URL: /__ccapi
VITE_SUPABASE_URL: /__supabase
VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
VITE_DEV: ${VITE_DEV:-false}
VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
env_file:
- .env.dev
ports:
+23
View File
@@ -34,6 +34,10 @@ vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplay
vi.mock('./pages/exam', () => ({
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
MarkSchemePage: () => <div>Mark Scheme editor</div>,
ExamMarkingPage: () => <div>Exam Marking</div>,
ExamResultsPage: () => <div>Exam Results</div>,
ResultsWidget: () => <div>Results Widget</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
@@ -125,3 +129,22 @@ describe('/admin route authorization', () => {
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
});
});
describe('exam-marker routes', () => {
beforeEach(() => {
mockUseAuth.mockReset();
});
it('renders the mark scheme editor route for authenticated users', () => {
mockUseAuth.mockReturnValue(authState({
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
user_role: 'email_teacher',
accessToken: 'token',
}));
renderAt('/exam-marker/template-123/marks');
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
});
});
+7 -1
View File
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
import SignupPage from './pages/auth/signupPage';
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
import MultiplayerUser from './pages/tldraw/multiplayerUser';
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
import { ExamDashboardPage, ExamMarkingPage, ExamResultsPage, ExamTemplateSetupPage, MarkSchemePage, QuestionBankPage, CorpusPage } from './pages/exam';
import { ErrorBoundary } from './components/ErrorBoundary';
import CalendarPage from './pages/user/calendarPage';
import SettingsPage from './pages/user/settingsPage';
@@ -169,6 +169,7 @@ const AppRoutes: React.FC = () => {
<Route path="/classes" element={<ClassesPage />} />
<Route path="/my-classes" element={<MyClassesPage />} />
<Route path="/classes/:classId" element={<ClassDetailPage />} />
<Route path="/timetable/classes/:classId" element={<ClassDetailPage />} />
<Route path="/student-lessons" element={<StudentLessonsPage />} />
<Route path="/lesson-plans" element={<LessonPlansPage />} />
<Route path="/lesson-plans/:planId" element={<LessonPlanDetailPage />} />
@@ -183,7 +184,12 @@ const AppRoutes: React.FC = () => {
<Route path="/search" element={<SearxngPage />} />
<Route path="/teacher-planner" element={<TeacherPlanner />} />
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
<Route path="/exam-marker/bank" element={<ErrorBoundary><QuestionBankPage /></ErrorBoundary>} />
<Route path="/exam-marker/corpus" element={<ErrorBoundary><CorpusPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
<Route path="/exam-marker/:templateId/marks" element={<ErrorBoundary><MarkSchemePage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/mark" element={<ErrorBoundary><ExamMarkingPage /></ErrorBoundary>} />
<Route path="/exam-marker/:batchId/results" element={<ErrorBoundary><ExamResultsPage /></ErrorBoundary>} />
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
<Route path="/morphic" element={<MorphicPage />} />
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
+11
View File
@@ -102,6 +102,17 @@ const PlatformAdminPage: React.FC = () => {
</Grid>
)}
<Alert severity="warning" sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Destructive reset scope: exam-corpus</Typography>
<Typography variant="body2">
The <strong>exam-corpus</strong> reset is not limited to public papers. It wipes the entire
exam-marker subsystem: public corpus/eb_* data, cc.examboards storage, templates, template
layouts, questions, boundaries, response areas, marking batches, student submissions, and
mark entries. Use it only when you intend to rebuild all exam-marker data; it does not reset
schools, users, or timetable data.
</Typography>
</Alert>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Schools ({loading ? '…' : schools.length})</Typography>
{loading ? (
+156
View File
@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, CircularProgress, Container,
Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckIcon from '@mui/icons-material/Check';
import RemoveIcon from '@mui/icons-material/Remove';
import { examRepository } from '../../services/exam/examRepository';
import type { CorpusResponse, CorpusSpec, CorpusDocType } from '../../types/exam.types';
const DOCS: CorpusDocType[] = ['QP', 'MS', 'ER'];
const DOC_LABEL: Record<CorpusDocType, string> = { QP: 'Question papers', MS: 'Mark schemes', ER: 'Examiner reports' };
const SCIENCES = new Set(['Biology', 'Chemistry', 'Physics']);
const CorpusPage: React.FC = () => {
const navigate = useNavigate();
const [corpus, setCorpus] = useState<CorpusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [board, setBoard] = useState<string | null>(null);
const [level, setLevel] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [sciencesOnly, setSciencesOnly] = useState(true);
const load = useCallback(async () => {
setLoading(true); setError(null);
try { setCorpus(await examRepository.getCorpus()); }
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setLoading(false); }
}, []);
useEffect(() => { void load(); }, [load]);
const allSpecs = useMemo<CorpusSpec[]>(
() => (corpus?.boards ?? []).flatMap((b) => b.specs),
[corpus],
);
const boards = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.board))).sort(), [allSpecs]);
const levels = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.level))).sort(), [allSpecs]);
const subjects = useMemo(() => Array.from(new Set(allSpecs.map((s) => s.subject))).sort(), [allSpecs]);
const specs = useMemo(() => allSpecs.filter((s) =>
(!board || s.board === board) && (!level || s.level === level) && (!subject || s.subject === subject)
&& (!sciencesOnly || SCIENCES.has(s.subject))
).sort((a, b) => (a.board + a.level + a.subject + a.spec_code).localeCompare(b.board + b.level + b.subject + b.spec_code)),
[allSpecs, board, level, subject, sciencesOnly]);
const filteredTotals = useMemo(() => specs.reduce((acc, s) => {
acc.specs += 1; acc.papers += s.n_papers;
DOCS.forEach((d) => { acc[d] += s.counts[d] ?? 0; });
return acc;
}, { specs: 0, papers: 0, QP: 0, MS: 0, ER: 0 } as Record<string, number>), [specs]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>Exam Marker</Button>
<Typography variant="h4" component="h1" fontWeight={700}>Exam bank</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
The collected past-paper corpus question papers, mark schemes and examiner reports per specification.
</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (
<>
{/* Summary of the current selection */}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 2 }}>
<Chip color="default" label={`${filteredTotals.specs} specifications`} />
<Chip color="default" label={`${filteredTotals.papers} papers`} />
<Chip color="primary" variant="outlined" label={`${filteredTotals.QP} question papers`} />
<Chip color="success" variant="outlined" label={`${filteredTotals.MS} mark schemes`} />
<Chip color="warning" variant="outlined" label={`${filteredTotals.ER} examiner reports`} />
</Stack>
{/* Filters */}
<Stack spacing={0.75} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip size="small" color={sciencesOnly ? 'secondary' : 'default'} variant={sciencesOnly ? 'filled' : 'outlined'} label="Sciences only" onClick={() => setSciencesOnly((v) => !v)} />
<Box sx={{ width: 12 }} />
<Typography variant="caption" color="text.secondary">Board:</Typography>
<Chip size="small" label="all" variant={board ? 'outlined' : 'filled'} color={board ? 'default' : 'primary'} onClick={() => setBoard(null)} />
{boards.map((b) => <Chip key={b} size="small" label={b} variant={board === b ? 'filled' : 'outlined'} color={board === b ? 'primary' : 'default'} onClick={() => setBoard(b)} />)}
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">Level:</Typography>
<Chip size="small" label="all" variant={level ? 'outlined' : 'filled'} color={level ? 'default' : 'primary'} onClick={() => setLevel(null)} />
{levels.map((l) => <Chip key={l} size="small" label={l} variant={level === l ? 'filled' : 'outlined'} color={level === l ? 'primary' : 'default'} onClick={() => setLevel(l)} />)}
{!sciencesOnly && (<>
<Box sx={{ width: 12 }} />
<Typography variant="caption" color="text.secondary">Subject:</Typography>
<Chip size="small" label="all" variant={subject ? 'outlined' : 'filled'} color={subject ? 'default' : 'primary'} onClick={() => setSubject(null)} />
{subjects.map((s) => <Chip key={s} size="small" label={s} variant={subject === s ? 'filled' : 'outlined'} color={subject === s ? 'primary' : 'default'} onClick={() => setSubject(s)} />)}
</>)}
</Stack>
</Stack>
{specs.length === 0 ? (
<Alert severity="info">No specifications match the current filters.</Alert>
) : specs.map((s) => (
<Accordion key={s.spec_code} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', width: '100%' }}>
<Typography sx={{ fontWeight: 700, minWidth: 260 }}>
{s.level} {s.subject} <Typography component="span" color="text.secondary" variant="body2">· {s.spec_code}</Typography>
</Typography>
<Chip size="small" label={`${s.n_papers} papers`} />
<Box sx={{ flex: 1 }} />
{DOCS.map((d) => (
<Chip key={d} size="small" variant="outlined"
color={s.counts[d] ? (d === 'QP' ? 'primary' : d === 'MS' ? 'success' : 'warning') : 'default'}
label={`${d} ${s.counts[d] ?? 0}`} />
))}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
<Box sx={{ overflowX: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Session</TableCell>
<TableCell>Paper</TableCell>
<TableCell>Tier</TableCell>
{DOCS.map((d) => <TableCell key={d} align="center">{DOC_LABEL[d]}</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
{s.papers.map((p, i) => (
<TableRow key={`${p.paper_code}-${p.session}-${i}`} hover>
<TableCell>{p.session ?? '—'}</TableCell>
<TableCell>{p.paper_code ?? '—'}</TableCell>
<TableCell>{p.tier ?? '—'}</TableCell>
{DOCS.map((d) => (
<TableCell key={d} align="center">
{p.docs[d] ? <CheckIcon fontSize="small" color="success" /> : <RemoveIcon fontSize="small" color="disabled" />}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</Box>
</AccordionDetails>
</Accordion>
))}
</>
)}
</Container>
);
};
export default CorpusPage;
+21
View File
@@ -24,6 +24,9 @@ import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import GradingIcon from '@mui/icons-material/Grading';
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
@@ -232,9 +235,17 @@ const ExamDashboardPage: React.FC = () => {
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button variant="outlined" startIcon={<LibraryBooksIcon />} onClick={() => navigate('/exam-marker/corpus')}>
Exam bank
</Button>
<Button variant="outlined" startIcon={<PlaylistAddCheckIcon />} onClick={() => navigate('/exam-marker/bank')}>
Question bank
</Button>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Stack>
</Box>
{error && (
@@ -320,6 +331,16 @@ const ExamDashboardPage: React.FC = () => {
Updated {new Date(t.updated_at).toLocaleDateString()}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
<Button
size="small"
variant="outlined"
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
startIcon={<GradingIcon fontSize="small" />}
>
Edit marks
</Button>
</Stack>
</Paper>
</Grid>
);
+280
View File
@@ -0,0 +1,280 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { v5 as uuidv5 } from 'uuid';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
List,
ListItemButton,
ListItemText,
Stack,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import TableChartIcon from '@mui/icons-material/TableChart';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchQueueResponse, ExamQuestion, ExamTemplateDetail, StudentSubmission } from '../../types/exam.types';
const MARK_NAMESPACE = '3f2dbbeb-9b15-4f99-9b71-8c535f8dc3d0';
function stableMarkId(batchId: string, submissionId: string, questionId: string) {
return uuidv5(`${batchId}:${submissionId}:${questionId}`, MARK_NAMESPACE);
}
const ExamMarkingPage: React.FC = () => {
const { batchId } = useParams<{ batchId: string }>();
const navigate = useNavigate();
const [queue, setQueue] = useState<BatchQueueResponse | null>(null);
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [marks, setMarks] = useState<Record<string, string>>({});
const [comments, setComments] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
setLoading(true);
setError(null);
try {
const nextQueue = await examRepository.getBatchQueue(batchId);
setQueue(nextQueue);
setTemplate(await examRepository.getTemplate(nextQueue.batch.template_id));
setSelectedId((current) => current ?? nextQueue.submissions[0]?.id ?? null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [batchId]);
useEffect(() => {
void load();
}, [load]);
const markableQuestions = useMemo(
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order),
[template],
);
const onScanFiles = useCallback(async (files: FileList | null) => {
if (!batchId || !files || files.length === 0) return;
setUploading(true); setError(null); setMessage(null);
let ok = 0;
const failures: string[] = [];
for (const file of Array.from(files)) {
try {
// 'ordered' assigns each scan to the next not-yet-submitted roster slot; unmatched scans
// become an unmatched submission the teacher can reconcile.
await examRepository.uploadScan(batchId, file, { matchingMethod: 'ordered' });
ok += 1;
} catch (e) {
failures.push(`${file.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
await load();
if (ok) setMessage(`Uploaded ${ok} scan${ok === 1 ? '' : 's'}${failures.length ? `, ${failures.length} failed` : ''}`);
if (failures.length) setError(failures.join(' · '));
}, [batchId, load]);
const selected = queue?.submissions.find((s) => s.id === selectedId) ?? null;
const saveSelected = async () => {
if (!batchId || !selected) return;
setSaving(true);
setError(null);
setMessage(null);
try {
const writes = markableQuestions
.map((q) => ({ q, raw: marks[q.id], comment: comments[q.id] }))
.filter(({ raw, comment }) => raw !== undefined && raw !== '' || !!comment?.trim());
for (const { q, raw, comment } of writes) {
const awarded = raw === undefined || raw === '' ? 0 : Number(raw);
if (Number.isNaN(awarded) || awarded < 0 || awarded > (q.max_marks ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(`Invalid mark for ${q.label}`);
}
await examRepository.upsertMark(stableMarkId(batchId, selected.id, q.id), {
submission_id: selected.id,
question_id: q.id,
awarded_marks: awarded,
comment: comment?.trim() || undefined,
confirmed: true,
});
}
setMessage(`Saved ${writes.length} mark${writes.length === 1 ? '' : 's'} for ${selected.student_name || selected.student_id || 'student'}.`);
setMarks({});
setComments({});
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const chooseStudent = (submission: StudentSubmission) => {
setSelectedId(submission.id);
setMarks({});
setComments({});
setMessage(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
}
if (error && !queue) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Alert severity="error">{error}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
Back to Exam Marker
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 3 }}>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Typography variant="h4" component="h1" fontWeight={700}>{queue?.batch.title || 'Mark exam'}</Typography>
<Typography variant="body2" color="text.secondary">
{template?.title || queue?.batch.template_id} · {queue?.progress.total ?? 0} students in queue
</Typography>
</Box>
<Button variant="outlined" startIcon={<TableChartIcon />} onClick={() => batchId && navigate(`/exam-marker/${batchId}/results`)}>
Results
</Button>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{message && <Alert severity="success" onClose={() => setMessage(null)}>{message}</Alert>}
{markableQuestions.length === 0 && (
<Alert severity="warning">
This template has no markable parts yet. Add parts in template setup before entering marks.
</Alert>
)}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Card variant="outlined" sx={{ width: { xs: '100%', md: 340 }, flexShrink: 0 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 1 }}>
<Typography variant="h6">Marking queue</Typography>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon fontSize="small" />}
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? 'Uploading…' : 'Upload scans'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
hidden
onChange={(e) => { void onScanFiles(e.target.files); }}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Scanned PDFs fill the roster in order; unmatched scans appear below to reconcile.
</Typography>
<Stack direction="row" spacing={1} sx={{ mb: 1 }} flexWrap="wrap" useFlexGap>
<Chip size="small" label={`${queue?.progress.total ?? 0} total`} />
<Chip size="small" label={`${queue?.progress.absent ?? 0} absent`} color="warning" variant="outlined" />
<Chip size="small" label={`${queue?.progress.complete ?? 0} complete`} color="success" variant="outlined" />
</Stack>
<List dense disablePadding>
{(queue?.submissions ?? []).map((submission) => (
<ListItemButton
key={submission.id}
selected={submission.id === selectedId}
onClick={() => chooseStudent(submission)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemText
primary={submission.student_name || submission.student_id || 'Unknown student'}
secondary={`${submission.status} · ${submission.mark_entry_count ?? 0} marks`}
/>
<Chip size="small" label={submission.status} color={submission.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
</ListItemButton>
))}
</List>
</CardContent>
</Card>
<Card variant="outlined" sx={{ flex: 1 }}>
<CardContent>
{selected ? (
<Stack spacing={2}>
<Box>
<Typography variant="h6">{selected.student_name || selected.student_id || 'Unknown student'}</Typography>
<Typography variant="body2" color="text.secondary">Status: {selected.status}</Typography>
</Box>
<Divider />
{markableQuestions.map((q: ExamQuestion) => (
<Stack key={q.id} direction={{ xs: 'column', sm: 'row' }} spacing={1.5} alignItems={{ xs: 'stretch', sm: 'center' }}>
<Box sx={{ minWidth: 150 }}>
<Typography variant="body2" fontWeight={700}>{q.label}</Typography>
<Typography variant="caption" color="text.secondary">/ {q.max_marks} marks</Typography>
</Box>
<TextField
label="Mark"
type="number"
size="small"
inputProps={{ min: 0, max: q.max_marks, step: 0.5 }}
value={marks[q.id] ?? ''}
onChange={(e) => setMarks((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ width: { xs: '100%', sm: 120 } }}
/>
<TextField
label="Comment"
size="small"
value={comments[q.id] ?? ''}
onChange={(e) => setComments((prev) => ({ ...prev, [q.id]: e.target.value }))}
sx={{ flex: 1 }}
/>
</Stack>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={() => navigate(`/exam-marker/${batchId}/results`)}>Skip to results</Button>
<Button variant="contained" startIcon={<SaveIcon />} onClick={saveSelected} disabled={saving || markableQuestions.length === 0}>
{saving ? 'Saving…' : 'Save marks'}
</Button>
</Box>
</Stack>
) : (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No submissions in this batch.</Typography>
)}
</CardContent>
</Card>
</Stack>
</Stack>
</Container>
);
};
export default ExamMarkingPage;
+177
View File
@@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DownloadIcon from '@mui/icons-material/Download';
import EditIcon from '@mui/icons-material/Edit';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchResultsResponse } from '../../types/exam.types';
function formatMark(value: number | null | undefined) {
return value === null || value === undefined ? '' : String(value);
}
const ExamResultsPage: React.FC = () => {
const { batchId } = useParams<{ batchId: string }>();
const navigate = useNavigate();
const [data, setData] = useState<BatchResultsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!batchId) return;
setLoading(true);
setError(null);
try {
setData(await examRepository.getBatchResults(batchId));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [batchId]);
useEffect(() => {
void load();
}, [load]);
const summary = useMemo(() => {
const rows = data?.results ?? [];
const presentTotals = rows
.map((r) => r.total)
.filter((v): v is number => typeof v === 'number');
const average = presentTotals.length
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
: null;
return {
total: rows.length,
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
marked: rows.filter((r) => r.total !== null).length,
average,
};
}, [data]);
const downloadCsv = async () => {
if (!batchId) return;
setDownloading(true);
setError(null);
try {
const csv = await examRepository.getBatchCsv(batchId);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `batch-${batchId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setDownloading(false);
}
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
}
if (error || !data) {
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Alert severity="error">{error || 'Results not found'}</Alert>
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
Back to Exam Marker
</Button>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Typography variant="h4" component="h1" fontWeight={700}>
{data.batch.title || 'Exam results'}
</Typography>
<Typography variant="body2" color="text.secondary">
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
Mark
</Button>
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
{downloading ? 'Preparing…' : 'Download CSV'}
</Button>
</Stack>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip label={`${summary.total} students`} />
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
</Stack>
<TableContainer component={Paper} variant="outlined">
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell>Status</TableCell>
{data.questions.map((q) => (
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
))}
<TableCell align="right">Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.results.map((row) => (
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
<TableCell>
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
</TableCell>
<TableCell>
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
</TableCell>
{data.questions.map((q) => (
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
))}
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</Container>
);
};
export default ExamResultsPage;
+501
View File
@@ -0,0 +1,501 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Autocomplete,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Container,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import SyncIcon from '@mui/icons-material/Sync';
import { logger } from '../../debugConfig';
import { examRepository } from '../../services/exam/examRepository';
import type {
ExamQuestion,
ExamTemplateDetail,
MarkScheme,
MarkSchemeType,
SpecPoint,
} from '../../types/exam.types';
type Drafts = Record<string, PartDraft>;
interface PartDraft {
max_marks: string;
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
spec_ref: string;
schemeType: MarkSchemeType;
body: string;
notes: string;
}
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
];
function deriveSpecCode(template: ExamTemplateDetail | null): string {
const examCode = template?.exam_code ?? '';
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
return match?.[1] ?? '';
}
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
if (type === 'levels' && Array.isArray(scheme?.levels)) {
return {
schemeType: 'levels',
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
notes,
};
}
if (type === 'parts' && Array.isArray(scheme?.parts)) {
return {
schemeType: 'parts',
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
notes,
};
}
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
return {
schemeType: 'checklist',
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
notes,
};
}
if (type === 'free') {
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
}
if (Array.isArray(scheme?.points)) {
return {
schemeType: 'points',
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
notes,
};
}
return { schemeType: type, body: '', notes };
}
function parseNumber(value: string, fallback = 0): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function splitLine(line: string): string[] {
return line.split('|').map((part) => part.trim());
}
function draftToScheme(draft: PartDraft): MarkScheme {
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
if (draft.schemeType === 'levels') {
return {
type: 'levels',
levels: lines.map((line, idx) => {
const [level, range, descriptor] = splitLine(line);
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
return {
level: level || `L${idx + 1}`,
min: parseNumber(minRaw, 0),
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
descriptor: descriptor || line,
};
}),
notes: draft.notes || undefined,
};
}
if (draft.schemeType === 'parts') {
return {
type: 'parts',
parts: lines.map((line, idx) => {
const [label, marks, guidance] = splitLine(line);
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
}),
notes: draft.notes || undefined,
};
}
if (draft.schemeType === 'checklist') {
return {
type: 'checklist',
checklist: lines.map((line) => {
const [marks, text] = splitLine(line);
return { marks: parseNumber(marks, 1), text: text || line };
}),
notes: draft.notes || undefined,
};
}
return {
type: 'points',
points: lines.map((line) => {
const [mark, text] = splitLine(line);
return { mark: parseNumber(mark, 1), text: text || line };
}),
notes: draft.notes || undefined,
};
}
function makeDraft(question: ExamQuestion): PartDraft {
const scheme = schemeToDraft(question.mark_scheme);
return {
max_marks: String(question.max_marks ?? 0),
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
spec_ref: question.spec_ref ?? '',
schemeType: scheme.schemeType,
body: scheme.body,
notes: scheme.notes,
};
}
const MarkSchemePage: React.FC = () => {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId: string }>();
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Drafts>({});
const [specCode, setSpecCode] = useState('');
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
const load = useCallback(async () => {
if (!templateId) return;
setLoading(true);
setError(null);
try {
const detail = await examRepository.getTemplate(templateId);
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
setTemplate(detail);
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
setSpecCode((prev) => prev || deriveSpecCode(detail));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
setError(msg);
} finally {
setLoading(false);
}
}, [templateId]);
useEffect(() => {
void load();
}, [load]);
const loadSpecPoints = useCallback(async () => {
if (!specCode.trim()) return;
setSpecEndpointMissing(false);
try {
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
setSpecEndpointMissing(true);
setSpecPoints([]);
}
}, [specCode]);
useEffect(() => {
void loadSpecPoints();
}, [loadSpecPoints]);
const parts = useMemo(
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
[template],
);
const selected = parts.find((p) => p.id === selectedId) ?? null;
const draft = selected ? drafts[selected.id] : null;
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
};
const savePart = async (question: ExamQuestion) => {
const partDraft = drafts[question.id];
if (!partDraft) return;
setSavingId(question.id);
setError(null);
setNotice(null);
try {
await examRepository.patchQuestion(question.id, {
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
answer_type: partDraft.answer_type,
mark_scheme: draftToScheme(partDraft),
spec_ref: partDraft.spec_ref.trim() || null,
});
setTemplate((prev) => {
if (!prev) return prev;
return {
...prev,
questions: prev.questions.map((q) => q.id === question.id ? {
...q,
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
answer_type: partDraft.answer_type,
mark_scheme: draftToScheme(partDraft),
spec_ref: partDraft.spec_ref.trim() || null,
} : q),
};
});
setNotice(`Saved mark scheme for Part ${question.label}.`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
setError(msg);
} finally {
setSavingId(null);
}
};
const syncGraph = async () => {
if (!templateId) return;
setSyncing(true);
setError(null);
setNotice(null);
try {
const res = await examRepository.syncTemplateToGraph(templateId);
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
setError(msg);
} finally {
setSyncing(false);
}
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
}
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Stack spacing={3}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Back to templates
</Button>
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
<Typography variant="body1" color="text.secondary">
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
{syncing ? 'Syncing…' : 'Sync to graph'}
</Button>
</Stack>
</Box>
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
{specEndpointMissing && (
<Alert severity="warning">
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextField
label="Specification code"
value={specCode}
onChange={(e) => setSpecCode(e.target.value)}
fullWidth
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
/>
</Grid>
<Grid item xs={12} md={8}>
<Typography variant="body2" color="text.secondary">
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]-&gt;(:SpecPoint).
</Typography>
</Grid>
</Grid>
</Paper>
{parts.length === 0 ? (
<Alert severity="info">
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
</Alert>
) : (
<Grid container spacing={3}>
<Grid item xs={12} md={3}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>Parts</Typography>
<Tabs
orientation="vertical"
value={selectedId ?? false}
onChange={(_, value) => setSelectedId(value)}
variant="scrollable"
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
>
{parts.map((part) => (
<Tab
key={part.id}
value={part.id}
label={
<Box sx={{ textAlign: 'left', width: '100%' }}>
<Typography variant="body2">Part {part.label}</Typography>
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
</Stack>
</Box>
}
/>
))}
</Tabs>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={9}>
{selected && draft && (
<Card variant="outlined">
<CardContent>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Typography variant="h5">Part {selected.label}</Typography>
<Typography variant="body2" color="text.secondary">
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
</Typography>
</Box>
<Button
variant="contained"
startIcon={<SaveIcon />}
disabled={savingId === selected.id}
onClick={() => savePart(selected)}
>
{savingId === selected.id ? 'Saving…' : 'Save part'}
</Button>
</Box>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
label="Max marks"
value={draft.max_marks}
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
type="number"
inputProps={{ min: 0, step: 0.5 }}
fullWidth
/>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="answer-type-label">Answer type</InputLabel>
<Select
labelId="answer-type-label"
label="Answer type"
value={draft.answer_type}
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
>
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={4}>
<FormControl fullWidth>
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
<Select
labelId="scheme-type-label"
label="Scheme form"
value={draft.schemeType}
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
>
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
</Select>
</FormControl>
</Grid>
</Grid>
<Autocomplete
options={specPoints}
value={selectedSpecPoint}
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
getOptionLabel={(option) => `${option.ref}${option.description}`}
isOptionEqualToValue={(option, value) => option.ref === value.ref}
renderInput={(params) => (
<TextField
{...params}
label="SpecPoint picker"
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
/>
)}
/>
<TextField
label="spec_ref"
value={draft.spec_ref}
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
fullWidth
/>
<Divider />
<TextField
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
value={draft.body}
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
multiline
minRows={10}
fullWidth
/>
<TextField
label="Internal marking notes"
value={draft.notes}
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
multiline
minRows={3}
fullWidth
/>
</Stack>
</CardContent>
</Card>
)}
</Grid>
</Grid>
)}
</Stack>
</Container>
);
};
export default MarkSchemePage;
+207
View File
@@ -0,0 +1,207 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert, Box, Button, Card, CardActionArea, CardContent, Chip, CircularProgress, Container,
Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PlaylistAddCheckIcon from '@mui/icons-material/PlaylistAddCheck';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import { examRepository } from '../../services/exam/examRepository';
import type { BankQuestion, BankResponse } from '../../types/exam.types';
import { cropQuestion } from './bankCrops';
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
const [src, setSrc] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let alive = true;
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
.catch(() => { if (alive) setDone(true); });
return () => { alive = false; };
}, [q.template_id, q.page, q.bounds]);
if (src) {
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
}
return (
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
{done ? 'no preview' : <CircularProgress size={16} />}
</Box>
);
};
const QuestionBankPage: React.FC = () => {
const navigate = useNavigate();
const [bank, setBank] = useState<BankResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [specRef, setSpecRef] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [title, setTitle] = useState('');
const [creating, setCreating] = useState(false);
const load = useCallback(async () => {
setLoading(true); setError(null);
try {
setBank(await examRepository.getBank({ specRef: specRef ?? undefined, subject: subject ?? undefined }));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [specRef, subject]);
useEffect(() => { void load(); }, [load]);
const toggle = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const selectedList = useMemo(() => Array.from(selected), [selected]);
const selectedQuestions = useMemo(
() => (bank?.questions ?? []).filter((q) => selected.has(q.id)),
[bank, selected],
);
const totalMarks = selectedQuestions.reduce((s, q) => s + (q.max_marks ?? 0), 0);
// facet lists (sorted) with counts
const specFacets = Object.entries(bank?.facets.spec_ref ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const subjectFacets = Object.entries(bank?.facets.subject ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : 1));
const create = async () => {
setCreating(true); setError(null);
try {
const subj = subject ?? selectedQuestions[0]?.paper.subject ?? undefined;
const res = await examRepository.createCustomPaper({
title: title.trim() || `Custom paper — ${new Date().toISOString().slice(0, 10)}`,
subject: subj ?? undefined,
question_ids: selectedList,
});
navigate(`/exam-marker/${res.id}/setup`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setCreating(false);
setDialogOpen(false);
}
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
Exam Marker
</Button>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<Box>
<Typography variant="h4" component="h1" fontWeight={700}>Question bank</Typography>
<Typography variant="body2" color="text.secondary">
Plan by spec point, then assemble a custom paper from questions across your papers.
</Typography>
</Box>
<Button
variant="contained"
startIcon={<PlaylistAddCheckIcon />}
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
Create paper ({selected.size}{totalMarks ? ` · ${totalMarks}m` : ''})
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{/* Filters */}
<Stack spacing={1} sx={{ mb: 2 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Spec point:</Typography>
<Chip size="small" label="all" color={specRef ? 'default' : 'primary'} onClick={() => setSpecRef(null)} variant={specRef ? 'outlined' : 'filled'} />
{specFacets.map(([ref, count]) => (
<Chip key={ref} size="small" label={`${ref} · ${count}`} color={specRef === ref ? 'primary' : 'default'} variant={specRef === ref ? 'filled' : 'outlined'} onClick={() => setSpecRef(ref)} />
))}
</Stack>
{subjectFacets.length > 1 && (
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>Subject:</Typography>
<Chip size="small" label="all" color={subject ? 'default' : 'secondary'} onClick={() => setSubject(null)} variant={subject ? 'outlined' : 'filled'} />
{subjectFacets.map(([s, count]) => (
<Chip key={s} size="small" label={`${s} · ${count}`} color={subject === s ? 'secondary' : 'default'} variant={subject === s ? 'filled' : 'outlined'} onClick={() => setSubject(s)} sx={{ textTransform: 'capitalize' }} />
))}
</Stack>
)}
</Stack>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : (bank?.questions.length ?? 0) === 0 ? (
<Alert severity="info">
No questions match yet. Questions appear here once your templates have parts with spec points
(set them in the mark-scheme editor). Auto-map + confirm on a paper to populate it.
</Alert>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{bank?.n} question{bank?.n === 1 ? '' : 's'} · click to add to your paper
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' }, gap: 1.5 }}>
{(bank?.questions ?? []).map((q: BankQuestion) => {
const on = selected.has(q.id);
return (
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
<CardActionArea onClick={() => toggle(q.id)}>
<QuestionCrop q={q} />
<CardContent sx={{ pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
{on ? <CheckCircleIcon color="primary" fontSize="small" /> : <RadioButtonUncheckedIcon color="disabled" fontSize="small" />}
</Box>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 0.75 }}>
<Chip size="small" label={`${q.max_marks ?? 0} marks`} />
{q.answer_type && <Chip size="small" variant="outlined" label={q.answer_type} />}
{q.spec_ref && <Chip size="small" color="info" label={q.spec_ref} />}
</Stack>
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block', textTransform: 'capitalize' }}>
{[q.paper.subject, q.paper.title].filter(Boolean).join(' · ') || 'Untitled paper'}
</Typography>
</CardContent>
</CardActionArea>
</Card>
);
})}
</Box>
</>
)}
<Dialog open={dialogOpen} onClose={() => !creating && setDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Create custom paper</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{selected.size} question{selected.size === 1 ? '' : 's'} · {totalMarks} marks. This creates a new
template you can open in setup and use for marking.
</Typography>
<TextField
autoFocus
fullWidth
label="Paper title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={`Custom paper — ${new Date().toISOString().slice(0, 10)}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} disabled={creating}>Cancel</Button>
<Button variant="contained" onClick={create} disabled={creating} startIcon={creating ? <CircularProgress size={16} color="inherit" /> : undefined}>
{creating ? 'Creating…' : 'Create & open'}
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default QuestionBankPage;
+169
View File
@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import AssessmentIcon from '@mui/icons-material/Assessment';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import TableChartIcon from '@mui/icons-material/TableChart';
import { examRepository } from '../../services/exam/examRepository';
import type { BatchResultsResponse, ExamTemplate, MarkingBatch } from '../../types/exam.types';
interface ResultsWidgetProps {
classId: string;
className?: string;
}
function averageFromResults(results: BatchResultsResponse | null) {
const totals = (results?.results ?? [])
.map((row) => row.total)
.filter((value): value is number => typeof value === 'number');
if (!totals.length) return null;
return totals.reduce((sum, value) => sum + value, 0) / totals.length;
}
const ResultsWidget: React.FC<ResultsWidgetProps> = ({ classId, className }) => {
const navigate = useNavigate();
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
const [batches, setBatches] = useState<MarkingBatch[]>([]);
const [latestResults, setLatestResults] = useState<BatchResultsResponse | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextTemplates, nextBatches] = await Promise.all([
examRepository.listTemplates(),
examRepository.listBatches(),
]);
const classBatches = nextBatches
.filter((batch) => batch.class_id === classId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setTemplates(nextTemplates);
setSelectedTemplateId((current) => current || nextTemplates[0]?.id || '');
setBatches(classBatches);
setLatestResults(classBatches[0] ? await examRepository.getBatchResults(classBatches[0].id) : null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [classId]);
useEffect(() => {
void load();
}, [load]);
const latestBatch = batches[0] ?? null;
const average = useMemo(() => averageFromResults(latestResults), [latestResults]);
const absent = latestResults?.results.filter((row) => row.status === 'absent' && row.total === null).length ?? 0;
const createBatch = async () => {
if (!selectedTemplateId) return;
setCreating(true);
setError(null);
try {
const template = templates.find((item) => item.id === selectedTemplateId);
const created = await examRepository.createBatch({
template_id: selectedTemplateId,
class_id: classId,
title: `${className || 'Class'} · ${template?.title || 'Exam'}`,
});
navigate(`/exam-marker/${created.id}/mark`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setCreating(false);
}
};
return (
<Card variant="outlined" sx={{ mb: 2 }}>
<CardContent>
<Stack spacing={2}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AssessmentIcon color="primary" />
<Box>
<Typography variant="h6">Assessment results</Typography>
<Typography variant="caption" color="text.secondary">Last exam summary for this class</Typography>
</Box>
</Box>
{loading && <CircularProgress size={22} />}
</Box>
{error && <Alert severity="warning" onClose={() => setError(null)}>{error}</Alert>}
{latestBatch ? (
<Stack spacing={1.5}>
<Box>
<Typography variant="body2" fontWeight={700}>{latestBatch.title || 'Exam batch'}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(latestBatch.created_at).toLocaleDateString('en-GB')}
</Typography>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip size="small" color="primary" label={`Average ${average === null ? '—' : average.toFixed(1)}`} />
<Chip size="small" variant="outlined" label={`${latestResults?.results.length ?? 0} students`} />
<Chip size="small" color="warning" variant="outlined" label={`${absent} absent`} />
</Stack>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" startIcon={<TableChartIcon />} onClick={() => navigate(`/exam-marker/${latestBatch.id}/results`)}>
View results
</Button>
<Button size="small" variant="outlined" onClick={() => navigate(`/exam-marker/${latestBatch.id}/mark`)}>
Continue marking
</Button>
</Stack>
</Stack>
) : !loading ? (
<Typography variant="body2" color="text.secondary">
No exam batches have been created for this class yet.
</Typography>
) : null}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ xs: 'stretch', sm: 'center' }}>
<TextField
select
size="small"
label="Template"
value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)}
disabled={!templates.length || creating}
sx={{ minWidth: 260 }}
>
{templates.map((template) => (
<MenuItem key={template.id} value={template.id}>{template.title}</MenuItem>
))}
</TextField>
<Button
variant="outlined"
startIcon={<PlayArrowIcon />}
onClick={createBatch}
disabled={!selectedTemplateId || creating}
>
{creating ? 'Creating…' : 'Create marking batch'}
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
);
};
export default ResultsWidget;
+68
View File
@@ -0,0 +1,68 @@
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
// once (cached) and reuses it for all of that template's questions. No API/infra change.
import { examRepository } from '../../services/exam/examRepository';
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
const imageCache = new Map<string, Promise<HTMLImageElement>>();
function templateRender(templateId: string): Promise<PdfPageImage[]> {
let p = renderCache.get(templateId);
if (!p) {
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
renderCache.set(templateId, p);
}
return p;
}
function loadImage(src: string): Promise<HTMLImageElement> {
let p = imageCache.get(src);
if (!p) {
p = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image decode failed'));
img.src = src;
});
imageCache.set(src, p);
}
return p;
}
export interface CropInput {
templateId: string;
page: number | null;
bounds: Record<string, number> | null;
}
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
const { templateId, page, bounds } = input;
if (!bounds || !page) return null;
const pages = await templateRender(templateId);
const pageImg = pages.find((pg) => pg.pageNumber === page);
if (!pageImg) return null;
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
let yTop = 0;
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
const sx = Math.max(0, Math.round(bounds.x ?? 0));
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
if (sw <= 1 || sh <= 1) return null;
const img = await loadImage(pageImg.src);
const scale = Math.min(1, maxWidth / sw);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sw * scale));
canvas.height = Math.max(1, Math.round(sh * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
+6
View File
@@ -1,2 +1,8 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as QuestionBankPage } from './QuestionBankPage';
export { default as CorpusPage } from './CorpusPage';
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
export { default as MarkSchemePage } from './MarkSchemePage';
export { default as ExamMarkingPage } from './ExamMarkingPage';
export { default as ExamResultsPage } from './ExamResultsPage';
export { default as ResultsWidget } from './ResultsWidget';
+353 -31
View File
@@ -1,10 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import { Alert, Box, Button, Chip, CircularProgress, Collapse, Dialog, DialogContent, DialogTitle, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
import DescriptionIcon from '@mui/icons-material/Description'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'
import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse'
import CheckIcon from '@mui/icons-material/Check'
import CloseIcon from '@mui/icons-material/Close'
import DoneAllIcon from '@mui/icons-material/DoneAll'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
@@ -12,7 +18,7 @@ import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { ExamTemplateDetail } from '../../../types/exam.types'
import type { AutoMapJobStatus, DigitalTextResponse, ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
@@ -29,10 +35,12 @@ const TOOLS = [
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
]
const PAGE_START_X = 260
const PAGE_START_X = 0
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
// S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src.
// Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights.
let y = 0
return pages.map((page) => {
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
@@ -41,6 +49,22 @@ function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
})
}
function applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) {
const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH
const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT
editor.setCameraOptions({
constraints: {
bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 },
padding: { x: 64, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
isLocked: false,
})
}
function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
@@ -50,6 +74,33 @@ function apiMessage(err: unknown): { message: string; conflict: boolean } {
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
}
function reviewSummary(template: ExamTemplateDetail | null) {
if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 }
const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])]
return rows.reduce((acc, row) => {
if (row.source === 'ai') acc.ai += 1
if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1
if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1
return acc
}, { ai: 0, unconfirmed: 0, lowConfidence: 0 })
}
// Live review counts read straight off the canvas shapes, so accept/reject update them immediately
// (the template-derived reviewSummary only reflects the last *saved* state).
function reviewFromShapes(editor: Editor | null) {
const acc = { ai: 0, unconfirmed: 0, lowConfidence: 0 }
if (!editor) return acc
for (const shape of editor.getCurrentPageShapes()) {
if (!shapeTypeToKind(shape.type)) continue
const p = (shape as ExamCanvasTLShape).props
if (p.source === 'ai') acc.ai += 1
if (p.source === 'ai' && p.confirmed === false) acc.unconfirmed += 1
if (typeof p.confidence === 'number' && p.confidence < 0.7) acc.lowConfidence += 1
}
return acc
}
function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
}
@@ -85,19 +136,46 @@ function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
contextType: s.props.contextType,
questionId: s.props.questionId ?? null,
parentId: s.props.parentId ?? null,
isContainer: s.props.isContainer ?? false,
depth: s.props.depth,
description: s.props.description,
linkLabel: s.props.linkLabel,
meta: (() => { try { return s.props.metaJson ? JSON.parse(s.props.metaJson) : undefined } catch { return undefined } })(),
source: s.props.source ?? 'manual',
confirmed: s.props.confirmed ?? s.props.source !== 'ai',
confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null,
derivation: s.props.derivation ?? null,
}
}
function bringDomainShapesToFront(editor: Editor) {
const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id)
if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ }
}
// Draw order = z-order (tldraw stacks by creation). Backdrops (boundaries, preamble bands, container frames)
// go first/behind so the figures, responses and parts on top stay hoverable and legible.
function zPriority(m: ExamCanvasShapeModel): number {
if (m.kind === 'boundary') return 0
if (m.kind === 'context' && m.contextType === 'preamble') return 1
if (m.kind === 'part' && m.isContainer) return 2
if (m.kind === 'part') return 3
if (m.kind === 'context') return 5 // figures/tables on top so their description tooltip is reachable
return 4 // responses + other regions
}
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
if (existing.length) editor.deleteShapes(existing)
if (!models.length) return
editor.createShapes(models.map((m) => ({
const ordered = [...models].sort((a, b) => zPriority(a) - zPriority(b))
editor.createShapes(ordered.map((m) => ({
id: createShapeId(m.id),
type: SHAPE_TYPES[m.kind],
x: m.x,
y: m.y,
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id },
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, parentId: m.parentId ?? undefined, isContainer: m.isContainer ?? undefined, depth: m.depth ?? undefined, description: m.description ?? undefined, linkLabel: m.linkLabel ?? undefined, metaJson: m.meta ? JSON.stringify(m.meta) : undefined, domainId: m.id, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') },
})))
}
@@ -117,8 +195,7 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
} as any
}))
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
// z-order is enforced by the caller via bringDomainShapesToFront
}
function seedGuide(editor: Editor) {
@@ -133,6 +210,20 @@ function seedGuide(editor: Editor) {
])
}
function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } {
return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string'
}
function autoMapStatusLabel(status: AutoMapJobStatus | null): string {
if (!status) return 'Auto-map running'
if (status.status === 'queued') return 'Auto-map queued'
if (status.status === 'running') return 'Auto-map running'
if (status.status === 'completed') return 'Auto-map complete'
if (status.status === 'failed') return 'Auto-map failed'
return `Auto-map ${status.status}`
}
const ExamTemplateSetupInner: React.FC = () => {
const { templateId } = useParams<{ templateId: string }>()
const navigate = useNavigate()
@@ -148,6 +239,70 @@ const ExamTemplateSetupInner: React.FC = () => {
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const [guideOpen, setGuideOpen] = useState(false)
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
const [autoMapBusy, setAutoMapBusy] = useState(false)
const [liveReview, setLiveReview] = useState({ ai: 0, unconfirmed: 0, lowConfidence: 0 })
const [digitalText, setDigitalText] = useState<DigitalTextResponse | null>(null)
const [digitalOpen, setDigitalOpen] = useState(false)
const [digitalBusy, setDigitalBusy] = useState(false)
const autoMapPollRef = useRef<number | null>(null)
const openDigitalText = useCallback(async () => {
if (!template) return
setDigitalBusy(true); setDigitalOpen(true)
try {
setDigitalText(await examRepository.getDigitalText(template.id))
} catch (e) {
setDigitalText(null)
setError(e instanceof Error ? e.message : 'No digital text yet — run auto-map first.')
setDigitalOpen(false)
} finally {
setDigitalBusy(false)
}
}, [template])
const refreshReview = useCallback(() => setLiveReview(reviewFromShapes(editorRef.current)), [])
const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => {
setTemplate(detail)
const editor = editorRef.current
if (editor) {
const shapes = shapesFromTemplate(detail, pageGeometriesRef.current)
loadShapes(editor, shapes)
if (!shapes.length) seedGuide(editor)
bringDomainShapesToFront(editor)
}
setDirty(false)
}, [])
const acceptShapes = useCallback((shapes: TLShape[]) => {
const editor = editorRef.current
if (!editor) return
const updates = shapes
.filter((s) => !!shapeTypeToKind(s.type) && (s as ExamCanvasTLShape).props.source === 'ai' && (s as ExamCanvasTLShape).props.confirmed === false)
.map((s) => ({ id: s.id, type: s.type, props: { confirmed: true } }))
if (updates.length) { editor.updateShapes(updates as any); refreshReview(); setDirty(true) }
}, [refreshReview])
const selectedDomainShapes = useCallback((): TLShape[] => {
const editor = editorRef.current
if (!editor) return []
return editor.getSelectedShapeIds().map((id) => editor.getShape(id)).filter((s): s is TLShape => !!s && !!shapeTypeToKind(s.type))
}, [])
const acceptSelected = useCallback(() => acceptShapes(selectedDomainShapes()), [acceptShapes, selectedDomainShapes])
const acceptAllAi = useCallback(() => { const ed = editorRef.current; if (ed) acceptShapes(ed.getCurrentPageShapes()) }, [acceptShapes])
const rejectSelected = useCallback(() => {
const editor = editorRef.current
if (!editor) return
const ids = selectedDomainShapes().map((s) => s.id)
if (ids.length) { editor.deleteShapes(ids); refreshReview(); setDirty(true) }
}, [refreshReview, selectedDomainShapes])
// Displayed counts come live from the canvas so accept/reject reflect immediately (falls back to the
// saved-template summary only for the brief moment before shapes have mounted).
const review = (liveReview.ai || liveReview.unconfirmed) ? liveReview : reviewSummary(template)
const load = useCallback(async () => {
if (!templateId) return
@@ -170,8 +325,9 @@ const ExamTemplateSetupInner: React.FC = () => {
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
bringDomainShapesToFront(ed)
}
applyDocViewConstraints(ed, partialPages)
}
setPdfStatus('ready')
})
@@ -188,6 +344,9 @@ const ExamTemplateSetupInner: React.FC = () => {
if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
bringDomainShapesToFront(editor)
applyDocViewConstraints(editor, pages)
editor.resetZoom()
}
setDirty(false)
} catch (e) {
@@ -201,6 +360,62 @@ const ExamTemplateSetupInner: React.FC = () => {
useEffect(() => { void load() }, [load])
useEffect(() => () => {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
}, [])
const pollAutoMapStatus = useCallback(async (jobId: string) => {
if (!templateId) return
try {
const status = await examRepository.getAutoMapStatus(templateId, jobId)
setAutoMapStatus(status)
if (status.status === 'completed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
const detail = status.template ?? await examRepository.getTemplate(templateId)
applyTemplateToCanvas(detail)
return
}
if (status.status === 'failed') {
if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current)
autoMapPollRef.current = null
setAutoMapBusy(false)
setError(status.error ?? 'Auto-map failed; existing template state was preserved.')
return
}
autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500)
} catch (e) {
const msg = apiMessage(e).message
setAutoMapBusy(false)
setError(msg)
logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg })
}
}, [applyTemplateToCanvas, templateId])
const autoMapFromPdf = useCallback(async () => {
if (!templateId || autoMapBusy) return
let queued = false
setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null)
try {
const result = await examRepository.autoMapTemplate(templateId)
if (isAutoMapAccepted(result)) {
queued = true
setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId })
await pollAutoMapStatus(result.job_id)
return
}
setAutoMapStatus(null)
applyTemplateToCanvas(result)
} catch (e) {
const msg = apiMessage(e)
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message })
} finally {
if (!queued) setAutoMapBusy(false)
}
}, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId])
const save = useCallback(async () => {
const editor = editorRef.current
if (!editor || !templateId || !template) return
@@ -222,6 +437,11 @@ const ExamTemplateSetupInner: React.FC = () => {
}
}, [template, templateId])
const layoutSummary = useMemo(() => {
const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null)
return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`)
}, [template?.layout])
const toolButtons = useMemo(() => TOOLS.map((tool) => (
<Tooltip title={tool.tip} key={tool.id} placement="right">
<Button
@@ -243,8 +463,38 @@ const ExamTemplateSetupInner: React.FC = () => {
)), [activeTool])
return (
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}>
{/* Top bar — single compact line */}
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
<Tooltip title="Back to exam marker">
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
</Tooltip>
<Divider orientation="vertical" flexItem />
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
{(() => {
const a = (template as (ExamTemplateDetail & { extraction_meta?: { audit?: { status?: string; cover_total?: number; detected_total?: number } } }) | null)?.extraction_meta?.audit
return a?.status ? <Tooltip title="Extraction cover-total reconciliation (detected vs printed maximum)"><Chip size="small" color={a.status === 'ok' ? 'success' : 'warning'} label={`Marks ${a.detected_total ?? '?'}/${a.cover_total ?? '?'}`} /></Tooltip> : null
})()}
<Button size="small" variant="outlined" startIcon={digitalBusy ? <CircularProgress size={14} /> : <DescriptionIcon fontSize="small" />} onClick={openDigitalText} disabled={digitalBusy || !template}>Digital text</Button>
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper>
{/* Body row */}
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left tool sidebar */}
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
{/* Canvas area */}
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
tools={examCanvasTools as any}
@@ -254,32 +504,71 @@ const ExamTemplateSetupInner: React.FC = () => {
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => setDirty(true), { scope: 'document' })
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
editor.store.listen(() => { setDirty(true); refreshReview() }, { scope: 'document' })
// Treat a user nudge/resize of an AI ghost as implicit confirmation (R3.3): a teacher who
// adjusts a suggestion has reviewed it. Only geometry edits count, so the confirmed-only
// update below can't recurse.
editor.store.listen((entry) => {
try {
const updates: Array<{ id: TLShape['id']; type: string; props: { confirmed: boolean } }> = []
for (const rec of Object.values(entry.changes.updated)) {
const [from, to] = rec as [any, any]
if (!to || to.typeName !== 'shape' || !shapeTypeToKind(to.type)) continue
if (to.props?.source !== 'ai' || to.props?.confirmed !== false) continue
const moved = from.x !== to.x || from.y !== to.y || from.props?.w !== to.props?.w || from.props?.h !== to.props?.h
if (moved) updates.push({ id: to.id, type: to.type, props: { confirmed: true } })
}
if (updates.length) editor.updateShapes(updates as any)
} catch { /* non-fatal: implicit-confirm is best-effort */ }
}, { scope: 'document', source: 'user' })
applyDocViewConstraints(editor, [])
editor.resetZoom()
// Only seed the example guide for a genuinely-empty template AFTER it has loaded.
// (Previously `else seedGuide` fired on mount while `template` was still null during
// the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.)
if (template) {
const s = shapesFromTemplate(template, pageGeometriesRef.current)
loadShapes(editor, s)
if (!s.length) seedGuide(editor)
}
bringDomainShapesToFront(editor)
}}
/>
</Box>
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
<Divider orientation="vertical" flexItem />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
<Typography variant="caption" color="text.secondary">Exam Marker Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography>
</Box>
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
{/* AI review actions — appears while there are unconfirmed suggestions to resolve */}
{review.unconfirmed > 0 && (
<Paper elevation={6} sx={{ position: 'absolute', left: 16, bottom: 16, zIndex: 1002, px: 1, py: 0.75, borderRadius: 2, bgcolor: 'background.paper', display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip size="small" color="warning" label={`${review.unconfirmed} AI unconfirmed`} sx={{ fontWeight: 700 }} />
<Tooltip title="Accept the selected AI suggestions (nudging one also confirms it)">
<span><Button size="small" color="success" startIcon={<CheckIcon fontSize="small" />} onClick={acceptSelected}>Accept</Button></span>
</Tooltip>
<Tooltip title="Reject (delete) the selected shapes">
<span><Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} onClick={rejectSelected}>Reject</Button></span>
</Tooltip>
<Tooltip title="Accept every AI suggestion on the page">
<span><Button size="small" variant="contained" startIcon={<DoneAllIcon fontSize="small" />} onClick={acceptAllAi}>Accept all</Button></span>
</Tooltip>
</Paper>
)}
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
<Stack spacing={1}>{toolButtons}</Stack>
</Paper>
{/* Guide toggle */}
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
</IconButton>
</Tooltip>
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
{/* Guide panel — collapsible */}
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid.
</Typography>
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI.
</Alert>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
const p = canvasShapePalette[kind]
@@ -288,15 +577,48 @@ const ExamTemplateSetupInner: React.FC = () => {
</Stack>
<Divider sx={{ my: 1 }} />
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw Q start on page N, then Q end on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use Q start/end; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
</Typography>
</Paper>
</Collapse>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
{/* Conflict alert */}
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
</Box>
</Box>
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
<Dialog open={digitalOpen} onClose={() => setDigitalOpen(false)} maxWidth="md" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionIcon fontSize="small" />
Digital text
{digitalText && <Typography component="span" variant="body2" color="text.secondary"> {digitalText.title} · {digitalText.n_questions}Q · {digitalText.total_marks} marks</Typography>}
<Box sx={{ flex: 1 }} />
{digitalText && (
<Tooltip title="Download markdown">
<IconButton size="small" onClick={() => {
const blob = new Blob([digitalText.markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob); const a = document.createElement('a')
a.href = url; a.download = `${digitalText.slug}.md`; a.click(); URL.revokeObjectURL(url)
}}><SaveIcon fontSize="small" /></IconButton>
</Tooltip>
)}
</DialogTitle>
<DialogContent dividers>
{digitalBusy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>
) : digitalText ? (
<Box component="pre" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, m: 0 }}>{digitalText.markdown}</Box>
) : (
<Typography variant="body2" color="text.secondary">No digital text available.</Typography>
)}
</DialogContent>
</Dialog>
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
</Box>
)
+242 -19
View File
@@ -1,7 +1,10 @@
import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
import type { TLHandle } from '@tldraw/tldraw'
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
import type { ExamTemplateSource } from '../../../types/exam.types'
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
@@ -32,7 +35,18 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
responseForm?: string
contextType?: string
questionId?: string | null
parentId?: string | null
isContainer?: boolean
depth?: number
description?: string
linkLabel?: string
metaJson?: string
domainId?: string
source?: 'manual' | 'ai'
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string
}
}
@@ -58,42 +72,203 @@ export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry>
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
// Human-legible names for the extraction service's response forms — so the canvas SHOWS what was
// recognised (multiple-choice, draw-on, table…), not just a generic "Response" box.
const RESPONSE_FORM_LABEL: Record<string, string> = {
lines: 'ruled lines',
'answer-box': 'answer box',
working: 'working space',
diagram: 'draw-on',
'tick-boxes': 'multiple choice',
table: 'table',
blanks: 'fill-in',
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
.exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); }
.exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
[data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; }
`
function confidenceLabel(confidence: number | null | undefined) {
return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null
}
// Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid.
function ghostOpacity(confidence: number | null | undefined) {
if (typeof confidence !== 'number') return 0.72
return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence))
}
function reviewFlags(shape: ExamCanvasTLShape): string[] {
return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean)
}
function provenanceTitle(shape: ExamCanvasTLShape, base: string) {
const bits = [base]
if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed')
const confidence = confidenceLabel(shape.props.confidence)
if (confidence) bits.push(`confidence ${confidence}`)
if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`)
const flags = reviewFlags(shape)
if (flags.length) bits.push(`review flags: ${flags.join(', ')}`)
return bits.join(' • ')
}
function renderBoundaryLine(shape: ExamCanvasTLShape) {
const p = canvasShapePalette.boundary
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
const isAi = shape.props.source === 'ai'
const isGhost = isAi && shape.props.confirmed === false
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
<style>{shapeCss}</style>
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
</svg>
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
{shape.props.label || p.label}
</span>
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
</HTMLContainer>
)
}
// Distinct look for a container box (main question / intermediate part). It frames its children rather than
// filling over them: transparent interior + a coloured border whose hue shifts by nesting depth, so
// question ⊃ part ⊃ subpart reads as nested frames on the paper.
const CONTAINER_DEPTH_STROKE = ['#4f46e5', '#0d9488', '#b45309']
function containerStroke(depth: number) { return CONTAINER_DEPTH_STROKE[Math.min(depth, CONTAINER_DEPTH_STROKE.length - 1)] }
function marksPill(maxMarks: number | undefined) {
if (typeof maxMarks !== 'number' || maxMarks <= 0) return null
return (
<span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={`${maxMarks} mark${maxMarks === 1 ? '' : 's'}`}>
<span aria-hidden="true" style={{ opacity: 0.7 }}></span>{maxMarks}
</span>
)
}
function parseMeta(shape: ExamCanvasTLShape): Record<string, any> | null {
try { return shape.props.metaJson ? JSON.parse(shape.props.metaJson) : null } catch { return null }
}
// Draw the recognised answer sub-structure INSIDE a response box, using the box-relative [0,1] coords the
// contract now carries: numbered sub-answer bands ("suggest two" → 1, 2), per-option MC selection boxes, and
// the final-answer line's expected quantity/unit.
function renderResponseSubstructure(shape: ExamCanvasTLShape): React.ReactNode {
const m = parseMeta(shape)
if (!m) return null
const pct = (v: any, d = 0) => `${Math.max(0, Math.min(1, Number(v ?? d))) * 100}%`
const els: React.ReactNode[] = []
const comps: any[] = Array.isArray(m.components) ? m.components : []
comps.forEach((c, i) => {
if (i === 0) return // first band starts at the box top; a divider there is noise
els.push(<div key={`cd${i}`} style={{ position: 'absolute', left: 0, right: 0, top: pct(c.rel_y0), borderTop: '1px dashed var(--exam-stroke)', opacity: 0.45 }} />)
})
comps.forEach((c, i) => els.push(
<span key={`cl${i}`} className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: `calc(${pct(c.rel_y0)} + 2px)`, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>{c.label ?? i + 1}</span>
))
const boxes: any[] = Array.isArray(m.boxes) ? m.boxes : []
boxes.forEach((b, i) => {
const rel = b.rel || {}
const filled = Number(b.fill ?? 0) > 0.35
els.push(<div key={`bx${i}`} title={`option ${i + 1}${filled ? ' (marked)' : ''}`} style={{ position: 'absolute', left: pct(rel.x0), top: pct(rel.y0), width: pct(Number(rel.x1) - Number(rel.x0)), height: pct(Number(rel.y1) - Number(rel.y0)), border: '1.5px solid var(--exam-stroke)', borderRadius: 3, background: filled ? 'var(--exam-stroke)' : 'transparent', opacity: filled ? 0.5 : 0.85, boxSizing: 'border-box' }} />)
})
// "tick N" instruction for multiple-choice (select_n = how many boxes to tick)
const selectN = Number(m.select_n ?? 0)
if (selectN > 0 && boxes.length) {
els.push(<span key="tickn" className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 4, top: 2, fontSize: 10, fontWeight: 900, borderRadius: 6, padding: '0 5px', opacity: 0.95 }}>tick {selectN}</span>)
}
const fal = m.final_answer_line
if (fal && (fal.unit || fal.quantity)) {
els.push(<span key="fal" className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 4, top: `calc(${pct(fal.rel_y, 0.9)} - 8px)`, maxWidth: 'calc(100% - 8px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontSize: 10, fontWeight: 800, borderRadius: 6, padding: '0 5px', opacity: 0.95 }} title="expected final answer">{[fal.quantity, fal.unit && `(${fal.unit})`].filter(Boolean).join(' ')}</span>)
}
return els.length ? <>{els}</> : null
}
// A container question is a BACKDROP, not a big box over the content: the main question (depth 0) relies on
// its start/end boundary rules for extent and shows only a small corner tab (label + total marks); an
// intermediate part-group (depth ≥ 1) also gets a faint dashed frame to convey nesting. Non-interactive so it
// never blocks a hover on the figures/responses on top.
function renderContainerTab(shape: ExamCanvasTLShape) {
const depth = shape.props.depth ?? 0
const stroke = containerStroke(depth)
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
const marks = shape.props.maxMarks
const title = provenanceTitle(shape, depth === 0 ? 'Question (container)' : 'Part group (container)')
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none', overflow: 'visible' }}>
<style>{shapeCss}</style>
{depth > 0 && <div style={{ position: 'absolute', inset: 0, border: `1px dashed ${stroke}`, borderRadius: 12, opacity: 0.3, boxSizing: 'border-box' }} />}
<span className="exam-canvas-shape__pill" title={title} style={{ position: 'absolute', left: 0, top: -3, display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, fontWeight: 900, borderRadius: 8, padding: '2px 7px', color: stroke, border: `1.5px solid ${stroke}`, background: 'rgba(255,255,255,0.92)', whiteSpace: 'nowrap' }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (depth === 0 ? '▣' : '▢')}</span>
{shape.props.label}
{typeof marks === 'number' && marks > 0 && <span style={{ opacity: 0.85 }}>· {marks}m</span>}
</span>
</HTMLContainer>
)
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary'
const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false
if (isBoundary) return renderBoundaryLine(shape)
const isContainer = shape.props.isContainer === true && kind === 'part'
if (isContainer) return renderContainerTab(shape)
const isPreamble = kind === 'context' && shape.props.contextType === 'preamble'
const depth = shape.props.depth ?? 0
const stroke = isContainer ? containerStroke(depth) : p.stroke
const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke
const confidence = confidenceLabel(shape.props.confidence)
const flags = reviewFlags(shape)
const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}`
const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)}${shape.props.description}` : provenanceTitle(shape, roleLabel)
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: isPreamble ? 'none' : 'all' }}>
<style>{shapeCss}</style>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}
style={{
'--exam-light-stroke': p.stroke,
'--exam-light-stroke': stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-stroke': darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isContainer ? 2.5 : 1.5}px ${isContainer ? 'solid' : (isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid')} var(--exam-stroke)`,
borderRadius: isContainer ? 14 : 10,
background: isContainer ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
padding: 8, boxShadow: isContainer ? 'none' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
} as React.CSSProperties}
aria-label={`${p.label}: ${p.role}`}
title={`${p.label}: ${p.role}`}
aria-label={title}
title={title}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{p.icon}</span>
{kind === 'response' && renderResponseSubstructure(shape)}
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', zIndex: 1 }}>
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (isContainer ? (depth === 0 ? '▣' : '▢') : p.icon)}</span>
{shape.props.label || p.label}
</span>
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
{marksPill(shape.props.maxMarks)}
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
{!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
</span>
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
{kind === 'response' && shape.props.responseForm && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}</span>}
{/* Context region: a visible tether to its owning question + the figure kind. */}
{kind === 'context' && shape.props.linkLabel && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.95 }}> {shape.props.linkLabel}</span>}
{kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{shape.props.contextType}</span>}
</div>
</HTMLContainer>
)
@@ -101,10 +276,10 @@ function renderShape(shape: ExamCanvasTLShape) {
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = canvasShapePalette[kind]
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true }
}
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), metaJson: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) }
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
@@ -121,7 +296,55 @@ class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
static override type = SHAPE_TYPES.boundary
static override props = sharedProps
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
override canEdit() { return false }
override canResize() { return false }
override canBind() { return false }
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override hideSelectionBoundsBg() { return true }
private pageSpanForY(y: number) {
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
if (!best) return page
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
return pageDy < bestDy ? page : best
}, null)
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
}
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
}
override getGeometry(shape: ExamCanvasTLShape) {
const y = shape.props.h / 2
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
}
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
}
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
return this.normalize({ ...current, x: initial.x })
}
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
}
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
}
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
+27 -7
View File
@@ -9,6 +9,8 @@ import {
ArrowBack, PersonAdd, PersonRemove, CheckCircle, Cancel, School,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { ResultsWidget } from '../exam';
import MarkbookPanel from './MarkbookPanel';
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
@@ -131,10 +133,20 @@ const ClassDetailPage: React.FC = () => {
setLoading(true);
setError(null);
try {
const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
if (clsRes.id) setCls(clsRes);
if (clsRes.id) {
setCls({
...clsRes,
class_code: clsRes.class_code || clsRes.code,
year_group: clsRes.year_group || clsRes.school_year,
teachers: clsRes.teachers || [],
students: clsRes.students || [],
enrollment_requests: clsRes.enrollment_requests || [],
student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
});
}
else setError(clsRes.detail || 'Class not found');
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
@@ -174,20 +186,20 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudent = async (studentId: string) => {
setActionError(null);
const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
await apiDelete(`/classes/${classId}/students/${studentId}`);
await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};
@@ -254,9 +266,12 @@ const ClassDetailPage: React.FC = () => {
</Alert>
)}
<ResultsWidget classId={cls.id} className={cls.name} />
{/* Tabs */}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tab label={`Students (${cls.student_count})`} />
<Tab label="Markbook" />
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
<Tab label={`Teachers (${cls.teachers.length})`} />
</Tabs>
@@ -319,8 +334,13 @@ const ClassDetailPage: React.FC = () => {
</Box>
)}
{/* Enrollment requests tab */}
{/* Markbook tab */}
{tab === 1 && (
<MarkbookPanel classId={cls.id} accessToken={accessToken || ''} />
)}
{/* Enrollment requests tab */}
{tab === 2 && (
<Box>
{cls.enrollment_requests.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
@@ -379,7 +399,7 @@ const ClassDetailPage: React.FC = () => {
)}
{/* Teachers tab */}
{tab === 2 && (
{tab === 3 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{cls.teachers.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
+319
View File
@@ -0,0 +1,319 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import DownloadIcon from '@mui/icons-material/Download';
import { API_BASE } from '../../config/apiConfig';
interface Assessment {
id: string;
title: string;
date?: string | null;
max_marks: number;
}
interface MarkbookStudent {
student_id: string;
student_name: string;
row_number: number;
marks: Record<string, number | null>;
total: number | null;
percentage: number | null;
}
interface AssessmentSummary {
assessment_id: string;
entered_count: number;
average_mark: number | null;
average_percentage: number | null;
}
interface MarkbookGrid {
assessments: Assessment[];
students: MarkbookStudent[];
assessment_summaries: AssessmentSummary[];
summary: {
student_count: number;
assessment_count: number;
entered_mark_count: number;
class_average_mark: number | null;
};
}
interface MarkbookPanelProps {
classId: string;
accessToken: string;
}
const MARKBOOK_BASE = `${API_BASE}/api/markbook`;
function formatNumber(value: number | null | undefined) {
return value === null || value === undefined ? '—' : Number(value).toFixed(Number.isInteger(value) ? 0 : 1);
}
function markInputValue(value: number | null | undefined) {
return value === null || value === undefined ? '' : String(value);
}
function AddAssessmentDialog({
open,
saving,
onClose,
onCreate,
}: {
open: boolean;
saving: boolean;
onClose: () => void;
onCreate: (payload: { title: string; date?: string; max_marks: number }) => Promise<void>;
}) {
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [maxMarks, setMaxMarks] = useState('100');
useEffect(() => {
if (open) {
setTitle('');
setDate('');
setMaxMarks('100');
}
}, [open]);
const handleCreate = async () => {
const parsedMax = Number(maxMarks);
if (!title.trim() || !Number.isFinite(parsedMax) || parsedMax <= 0) return;
await onCreate({ title: title.trim(), date: date || undefined, max_marks: parsedMax });
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Add assessment column</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField label="Title" size="small" value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
<TextField label="Date" type="date" size="small" value={date} onChange={(e) => setDate(e.target.value)} InputLabelProps={{ shrink: true }} />
<TextField label="Max marks" type="number" size="small" value={maxMarks} onChange={(e) => setMaxMarks(e.target.value)} inputProps={{ min: 0, step: 0.5 }} />
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleCreate} variant="contained" disabled={saving || !title.trim()} startIcon={saving ? <CircularProgress size={16} /> : <AddIcon />}>
Add
</Button>
</DialogActions>
</Dialog>
);
}
const MarkbookPanel: React.FC<MarkbookPanelProps> = ({ classId, accessToken }) => {
const [grid, setGrid] = useState<MarkbookGrid | null>(null);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [savingAssessment, setSavingAssessment] = useState(false);
const [error, setError] = useState<string | null>(null);
const headers = useMemo(() => ({ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }), [accessToken]);
const load = useCallback(async () => {
if (!classId || !accessToken) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/grid`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(body.detail || 'Failed to load markbook');
setGrid(body);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [accessToken, classId, headers]);
useEffect(() => {
void load();
}, [load]);
const createAssessment = async (payload: { title: string; date?: string; max_marks: number }) => {
setSavingAssessment(true);
setError(null);
try {
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const body = await res.json();
if (!res.ok) throw new Error(body.detail || 'Failed to create assessment');
setAddOpen(false);
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSavingAssessment(false);
}
};
const saveMark = async (studentId: string, assessment: Assessment, raw: string) => {
const key = `${studentId}:${assessment.id}`;
const trimmed = raw.trim();
const mark = trimmed === '' ? null : Number(trimmed);
if (mark !== null && (!Number.isFinite(mark) || mark < 0 || mark > Number(assessment.max_marks))) {
setError(`Mark must be between 0 and ${assessment.max_marks}`);
return;
}
setSavingKey(key);
setError(null);
try {
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments/${assessment.id}/marks/${studentId}`, {
method: 'PUT',
headers,
body: JSON.stringify({ mark }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.detail || 'Failed to save mark');
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSavingKey(null);
}
};
const downloadCsv = async () => {
setError(null);
try {
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/csv`, { headers });
const text = await res.text();
if (!res.ok) throw new Error(text || 'Failed to export CSV');
const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `markbook-${classId}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
}
if (!grid) {
return <Alert severity="error">{error || 'Could not load markbook'}</Alert>;
}
const summaryByAssessment = new Map(grid.assessment_summaries.map((s) => [s.assessment_id, s]));
return (
<Box>
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
<Stack direction="row" spacing={1} sx={{ mb: 2 }} justifyContent="space-between" alignItems="center" flexWrap="wrap" useFlexGap>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Typography variant="body2" color="text.secondary">{grid.summary.student_count} roster students</Typography>
<Typography variant="body2" color="text.secondary">{grid.summary.assessment_count} assessments</Typography>
<Typography variant="body2" color="text.secondary">Class average mark {formatNumber(grid.summary.class_average_mark)}</Typography>
</Stack>
<Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={grid.assessments.length === 0}>CSV</Button>
<Button size="small" variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add assessment</Button>
</Stack>
</Stack>
{grid.assessments.length === 0 ? (
<Paper variant="outlined" sx={{ py: 4, px: 2, textAlign: 'center' }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>No assessment columns yet.</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add first assessment</Button>
</Paper>
) : (
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 620 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ minWidth: 44 }}>#</TableCell>
<TableCell sx={{ minWidth: 180 }}>Student</TableCell>
{grid.assessments.map((assessment) => (
<TableCell key={assessment.id} align="right" sx={{ minWidth: 130 }}>
<Typography variant="body2" fontWeight={700}>{assessment.title}</Typography>
<Typography variant="caption" color="text.secondary">
{assessment.date ? `${new Date(assessment.date).toLocaleDateString('en-GB')} · ` : ''}/{assessment.max_marks}
</Typography>
</TableCell>
))}
<TableCell align="right">Total</TableCell>
<TableCell align="right">%</TableCell>
</TableRow>
</TableHead>
<TableBody>
{grid.students.map((student) => (
<TableRow key={student.student_id}>
<TableCell>{student.row_number}</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={600}>{student.student_name || student.student_id}</Typography>
<Typography variant="caption" color="text.secondary">{student.student_id}</Typography>
</TableCell>
{grid.assessments.map((assessment) => {
const key = `${student.student_id}:${assessment.id}`;
return (
<TableCell key={assessment.id} align="right">
<TextField
size="small"
type="number"
defaultValue={markInputValue(student.marks[assessment.id])}
onBlur={(e) => void saveMark(student.student_id, assessment, e.target.value)}
disabled={savingKey === key}
inputProps={{ min: 0, max: assessment.max_marks, step: 0.5, style: { textAlign: 'right' } }}
sx={{ width: 86 }}
/>
</TableCell>
);
})}
<TableCell align="right"><strong>{formatNumber(student.total)}</strong></TableCell>
<TableCell align="right">{formatNumber(student.percentage)}</TableCell>
</TableRow>
))}
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell />
<TableCell><strong>Average</strong></TableCell>
{grid.assessments.map((assessment) => {
const summary = summaryByAssessment.get(assessment.id);
return <TableCell key={assessment.id} align="right"><strong>{formatNumber(summary?.average_mark)}</strong></TableCell>;
})}
<TableCell />
<TableCell />
</TableRow>
</TableBody>
</Table>
</TableContainer>
)}
<AddAssessmentDialog open={addOpen} saving={savingAssessment} onClose={() => setAddOpen(false)} onCreate={createAssessment} />
</Box>
);
};
export default MarkbookPanel;
+162
View File
@@ -11,12 +11,29 @@ import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient';
import type {
AutoMapJobStatus,
AutoMapResponse,
BankResponse,
CorpusResponse,
DigitalTextResponse,
BatchQueueResponse,
BatchResultsResponse,
CreateBatchPayload,
CreateCustomPaperPayload,
CreateCustomPaperResult,
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate,
ExamTemplateDetail,
ExamTemplateLayout,
MarkingBatch,
MarkUpsertPayload,
Neo4jSyncResult,
PatchQuestionPayload,
SpecPoint,
StudentSubmission,
TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types';
@@ -56,6 +73,10 @@ function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
source: q.source ?? 'manual',
confirmed: q.confirmed ?? true,
confidence: q.confidence ?? null,
derivation: q.derivation ?? null,
};
}
@@ -71,6 +92,8 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, d
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
mark_subtype: r.mark_subtype ?? null,
derivation: r.derivation ?? null,
};
}
@@ -84,6 +107,26 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
confidence: b.confidence ?? null,
derivation: b.derivation ?? null,
};
}
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
return {
id: duplicate ? newUuid() : layout.id,
page_index: layout.page_index,
role: layout.role ?? null,
margin_left: layout.margin_left ?? null,
margin_right: layout.margin_right ?? null,
margin_top: layout.margin_top ?? null,
margin_bottom: layout.margin_bottom ?? null,
margins_enabled: layout.margins_enabled ?? true,
source: layout.source ?? 'manual',
confirmed: layout.confirmed ?? true,
confidence: layout.confidence ?? null,
derivation: layout.derivation ?? null,
meta: layout.meta ?? {},
};
}
@@ -105,6 +148,7 @@ async function replaceTemplate(
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
},
{ headers },
);
@@ -121,12 +165,30 @@ export const examRepository = {
return res.data.templates ?? [];
},
async getDigitalText(templateId: string): Promise<DigitalTextResponse> {
const headers = await authHeaders();
const res = await axios.get<DigitalTextResponse>(`${EXAM_BASE}/templates/${templateId}/digital-text`, { headers });
return res.data;
},
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
return res.data;
},
async autoMapTemplate(templateId: string): Promise<AutoMapResponse> {
const headers = await authHeaders();
const res = await axios.post<AutoMapResponse>(`${EXAM_BASE}/templates/${templateId}/auto-map`, {}, { headers });
return res.data;
},
async getAutoMapStatus(templateId: string, jobId: string): Promise<AutoMapJobStatus> {
const headers = await authHeaders();
const res = await axios.get<AutoMapJobStatus>(`${EXAM_BASE}/templates/${templateId}/auto-map/${jobId}/status`, { headers });
return res.data;
},
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
@@ -184,6 +246,106 @@ export const examRepository = {
const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
},
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
const headers = await authHeaders();
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
return res.data;
},
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
const headers = await authHeaders();
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
{ headers, params: search ? { q: search } : undefined },
);
if (Array.isArray(res.data)) return res.data;
return res.data.points ?? [];
},
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
const headers = await authHeaders();
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
return res.data;
},
async createBatch(payload: CreateBatchPayload): Promise<MarkingBatch> {
const headers = await authHeaders();
const res = await axios.post<MarkingBatch>(`${EXAM_BASE}/batches`, payload, { headers });
return res.data;
},
async listBatches(params: { includeArchived?: boolean; templateId?: string } = {}): Promise<MarkingBatch[]> {
const headers = await authHeaders();
const res = await axios.get<{ batches: MarkingBatch[] }>(`${EXAM_BASE}/batches`, {
headers,
params: {
include_archived: params.includeArchived ?? false,
template_id: params.templateId,
},
});
return res.data.batches ?? [];
},
async getBatchQueue(batchId: string): Promise<BatchQueueResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchQueueResponse>(`${EXAM_BASE}/batches/${batchId}/queue`, { headers });
return res.data;
},
async getBatchResults(batchId: string): Promise<BatchResultsResponse> {
const headers = await authHeaders();
const res = await axios.get<BatchResultsResponse>(`${EXAM_BASE}/batches/${batchId}/results`, { headers });
return res.data;
},
async getBatchCsv(batchId: string): Promise<string> {
const headers = await authHeaders();
const res = await axios.get<string>(`${EXAM_BASE}/batches/${batchId}/csv`, { headers, responseType: 'text' });
return res.data;
},
async upsertMark(markId: string, payload: MarkUpsertPayload): Promise<unknown> {
const headers = await authHeaders();
const res = await axios.put(`${EXAM_BASE}/marks/${markId}`, payload, { headers });
return res.data;
},
async getCorpus(): Promise<CorpusResponse> {
const headers = await authHeaders();
const res = await axios.get<CorpusResponse>(`${EXAM_BASE}/corpus`, { headers });
return res.data;
},
async getBank(params: { specRef?: string; subject?: string } = {}): Promise<BankResponse> {
const headers = await authHeaders();
const res = await axios.get<BankResponse>(`${EXAM_BASE}/bank`, {
headers,
params: { spec_ref: params.specRef, subject: params.subject },
});
return res.data;
},
async createCustomPaper(payload: CreateCustomPaperPayload): Promise<CreateCustomPaperResult> {
const headers = await authHeaders();
const res = await axios.post<CreateCustomPaperResult>(`${EXAM_BASE}/custom-papers`, payload, { headers });
return res.data;
},
async uploadScan(
batchId: string,
file: File,
opts: { studentId?: string; matchingMethod?: 'manual' | 'ordered' } = {},
): Promise<StudentSubmission> {
const headers = await authHeaders();
const form = new FormData();
form.append('file', file);
form.append('matching_method', opts.matchingMethod ?? 'ordered');
if (opts.studentId) form.append('student_id', opts.studentId);
// Let axios set the multipart Content-Type + boundary (only Authorization is forwarded).
const res = await axios.post<StudentSubmission>(`${EXAM_BASE}/batches/${batchId}/scans`, form, { headers });
return res.data;
},
};
export default examRepository;
+9 -1
View File
@@ -1,9 +1,17 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { logger } from './debugConfig';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const rawSupabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
// Same-origin proxy support: a leading-slash value (e.g. "/__supabase") is
// resolved against the current browser origin so supabase-js receives an
// absolute URL while every request stays same-origin (no CORS) and routes
// through the app host's /__supabase nginx proxy to the backend Supabase.
const supabaseUrl = rawSupabaseUrl?.startsWith('/')
? `${window.location.origin}${rawSupabaseUrl}`
: rawSupabaseUrl;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase configuration');
}
+277 -4
View File
@@ -31,6 +31,42 @@ export interface CreateTemplatePayload {
institute_id?: string;
}
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
export interface MarkSchemePoint {
mark: number;
text: string;
}
export interface MarkSchemeLevel {
level: string;
min: number;
max: number;
descriptor: string;
}
export interface MarkSchemePart {
label: string;
marks: number;
guidance: string;
}
export interface MarkSchemeChecklistItem {
text: string;
marks: number;
}
export interface MarkScheme {
type?: MarkSchemeType;
points?: MarkSchemePoint[];
levels?: MarkSchemeLevel[];
parts?: MarkSchemePart[];
checklist?: MarkSchemeChecklistItem[];
text?: string;
notes?: string;
[key: string]: unknown;
}
export interface UpdateTemplateMetaPayload {
title?: string;
subject?: string | null;
@@ -39,6 +75,8 @@ export interface UpdateTemplateMetaPayload {
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export type ExamTemplateSource = 'manual' | 'ai';
export interface ExamQuestion {
id: string;
template_id: string;
@@ -48,11 +86,19 @@ export interface ExamQuestion {
max_marks: number;
answer_type: string | null;
mcq_options: unknown | null;
mark_scheme: Record<string, unknown>;
mark_scheme: MarkScheme;
is_container: boolean;
spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
/** Analyse contract v2 (migration 78): primary command verb for a leaf part. */
command_word?: string | null;
/** Analyse contract v2 (migration 78): stem prose for the outline view. */
preamble?: Record<string, unknown> | null;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
}
export type ExamResponseAreaKind =
@@ -63,6 +109,8 @@ export type ExamResponseAreaKind =
| 'reference'
| 'furniture';
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
export interface ExamResponseArea {
id: string;
question_id: string;
@@ -72,9 +120,13 @@ export interface ExamResponseArea {
kind: ExamResponseAreaKind;
response_form: string | null;
context_type?: string | null;
source: 'manual' | 'ai';
/** Rich recognition payload (migration 75): figure name/description, OMR box geometry, unit/quantity, table… */
meta?: Record<string, unknown> | null;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
mark_subtype?: ExamMarkSubtype | null;
derivation?: string | null;
}
export interface ExamBoundary {
@@ -85,14 +137,36 @@ export interface ExamBoundary {
page_index: number;
y: number;
bounds: Record<string, number> | null;
source: 'manual' | 'ai';
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
}
export interface ExamTemplateLayout {
id: string;
template_id: string;
page_index: number;
role: string | null;
margin_left: number | null;
margin_right: number | null;
margin_top: number | null;
margin_bottom: number | null;
margins_enabled: boolean;
source: ExamTemplateSource;
confirmed: boolean;
confidence: number | null;
derivation: string | null;
meta: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface ExamTemplateDetail extends ExamTemplate {
questions: ExamQuestion[];
response_areas: ExamResponseArea[];
boundaries: ExamBoundary[];
layout: ExamTemplateLayout[];
}
@@ -116,6 +190,10 @@ export interface TemplateReplacePayload {
spec_ref?: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
}>;
response_areas: Array<{
id?: string;
@@ -125,9 +203,12 @@ export interface TemplateReplacePayload {
kind: ExamResponseArea['kind'];
response_form?: string | null;
context_type?: string | null;
meta?: Record<string, unknown> | null;
source?: 'manual' | 'ai';
confirmed?: boolean;
confidence?: number | null;
mark_subtype?: ExamMarkSubtype | null;
derivation?: string | null;
}>;
boundaries: Array<{
id?: string;
@@ -136,7 +217,199 @@ export interface TemplateReplacePayload {
page_index: number;
y: number;
bounds?: Record<string, number> | null;
source?: 'manual' | 'ai';
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
}>;
layout?: Array<{
id?: string;
page_index: number;
role?: string | null;
margin_left?: number | null;
margin_right?: number | null;
margin_top?: number | null;
margin_bottom?: number | null;
margins_enabled?: boolean;
source?: ExamTemplateSource;
confirmed?: boolean;
confidence?: number | null;
derivation?: string | null;
meta?: Record<string, unknown>;
}>;
}
export interface PatchQuestionPayload {
label?: string;
order?: number;
max_marks?: number;
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
mcq_options?: unknown;
mark_scheme?: MarkScheme;
is_container?: boolean;
spec_ref?: string | null;
}
export interface SpecPoint {
uid?: string;
uuid_string?: string;
ref: string;
description: string;
spec_code: string;
exam_board_code?: string;
}
export interface Neo4jSyncResult {
status: string;
projection?: Record<string, unknown>;
}
export interface AutoMapAcceptedResponse {
status: 'accepted';
job_id: string;
}
export interface AutoMapJobStatus {
job_id: string;
status: 'queued' | 'running' | 'completed' | 'failed' | string;
template_id: string;
updated_at?: number;
counts?: Record<string, number>;
error?: string;
template?: ExamTemplateDetail;
}
export type AutoMapResponse = ExamTemplateDetail | AutoMapAcceptedResponse;
export interface MarkingBatch {
id: string;
template_id: string;
class_id: string | null;
institute_id: string;
teacher_id: string;
title: string | null;
status: 'open' | 'closed' | 'archived' | string;
created_at: string;
updated_at?: string;
submission_count?: number;
}
export interface StudentSubmission {
id: string;
batch_id: string;
student_id: string | null;
student_name: string | null;
status: 'absent' | 'unmatched' | 'matched' | 'marking' | 'complete' | string;
storage_path?: string | null;
mark_entry_count?: number;
}
export interface BatchQueueResponse {
batch: MarkingBatch;
submissions: StudentSubmission[];
progress: {
total: number;
absent: number;
complete: number;
in_progress: number;
};
}
export interface ExamResultRow {
submission_id: string;
student_id: string | null;
student_name: string | null;
status: string | null;
marks: Record<string, number | null | undefined>;
total: number | null;
}
export interface BatchResultsResponse {
batch: MarkingBatch;
questions: Array<Pick<ExamQuestion, 'id' | 'label' | 'max_marks' | 'order'>>;
results: ExamResultRow[];
}
export interface CreateBatchPayload {
template_id: string;
class_id?: string;
title?: string;
}
export interface MarkUpsertPayload {
submission_id: string;
question_id: string;
awarded_marks: number;
mark_scheme_detail?: Record<string, unknown>;
annotation_shape_ids?: unknown;
comment?: string;
confirmed?: boolean;
}
// ── Mode-3 question bank + custom-paper assembly ──────────────────────────────
export interface BankQuestion {
id: string;
template_id: string;
label: string | null;
max_marks: number | null;
answer_type: string | null;
spec_ref: string | null;
bounds: Record<string, number> | null;
page: number | null;
paper: { id: string | null; title: string | null; subject: string | null; exam_code: string | null };
}
export interface BankResponse {
questions: BankQuestion[];
n: number;
facets: { spec_ref: Record<string, number>; subject: Record<string, number> };
}
export interface CreateCustomPaperPayload {
title: string;
subject?: string;
question_ids: string[];
}
export interface CreateCustomPaperResult {
id: string;
title: string;
subject: string | null;
n_questions: number;
n_response_areas: number;
}
// ── Exam-bank corpus coverage (state of the collected bank) ───────────────────
export type CorpusDocType = 'QP' | 'MS' | 'ER';
export interface CorpusPaper {
paper_code: string | null;
session: string | null;
tier: string | null;
docs: Partial<Record<CorpusDocType, boolean>>;
exam_codes: Partial<Record<CorpusDocType, string>>;
}
export interface CorpusSpec {
spec_code: string;
subject: string;
level: string;
board: string;
first_teach: string | null;
n_papers: number;
counts: Record<CorpusDocType, number>;
papers: CorpusPaper[];
}
export interface CorpusBoard { board: string; n_specs: number; specs: CorpusSpec[] }
export interface CorpusResponse {
totals: { specs: number; papers: number; sessions: number; QP: number; MS: number; ER: number };
boards: CorpusBoard[];
}
// ── Digital-replica markdown (P4) ─────────────────────────────────────────────
export interface DigitalTextResponse {
slug: string;
title: string;
n_questions: number;
total_marks: number;
markdown: string;
questions: { label: string; marks: number | null; markdown: string }[];
}
+91 -6
View File
@@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m
const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
}
describe('exam setup canvas serialization', () => {
@@ -43,6 +43,7 @@ describe('exam setup canvas serialization', () => {
], pages)
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
expect(payload.response_areas[0].page).toBe(2)
})
@@ -50,16 +51,100 @@ describe('exam setup canvas serialization', () => {
const shapes = shapesFromTemplate({
...template,
questions: [
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
],
response_areas: [
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
],
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
})
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
})
it('carries AI provenance into canvas models, flags cheap review issues, and preserves it on save', () => {
const detail: ExamTemplateDetail = {
...template,
questions: [
{ id: '11111111-1111-4111-8111-111111111111', template_id: 'tpl-1', parent_id: null, label: 'Q?', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q?', order: 0, max_marks: 0, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 100, y: 120, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '66666666-6666-4666-8666-666666666666', template_id: 'tpl-1', parent_id: '11111111-1111-4111-8111-111111111111', label: 'Q1(b)', order: 1, max_marks: 1, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 150, y: 150, w: 200, h: 100 }, page: 1, source: 'ai', confirmed: false, confidence: 0.8, derivation: 'g6' },
],
response_areas: [
{ id: '33333333-3333-4333-8333-333333333333', question_id: '22222222-2222-4222-8222-222222222222', template_id: 'tpl-1', page: 1, bounds: { x: 120, y: 140, w: 120, h: 40 }, kind: 'response', response_form: 'lines', source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' },
],
boundaries: [
{ id: '44444444-4444-4444-8444-444444444444', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
{ id: '55555555-5555-4555-8555-555555555555', template_id: 'tpl-1', question_id: '11111111-1111-4111-8111-111111111111', label: 'Q? end', page_index: 0, y: 500, bounds: { x: 0, y: 500, w: 700, h: 8 }, source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' },
],
}
const shapes = shapesFromTemplate(detail)
const part = shapes.find((shape) => shape.kind === 'part')
expect(part).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
expect(part?.reviewFlags).toEqual(expect.arrayContaining(['unconfirmed AI', 'low confidence', 'uncertain question label', 'missing marks', 'overlapping shapes']))
const payload = serializeCanvasShapes(template, shapes)
expect(payload.questions.find((q) => q.id === '22222222-2222-4222-8222-222222222222')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
expect(payload.response_areas.find((r) => r.id === '33333333-3333-4333-8333-333333333333')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.8, derivation: 'regions' })
expect(payload.boundaries.find((b) => b.id === '44444444-4444-4444-8444-444444444444')).toMatchObject({ source: 'ai', confirmed: false, confidence: 0.62, derivation: 'g6' })
})
it('never drops a response region that has no enclosing part — it falls back to a question', () => {
const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 120, label: 'Q1(a)', maxMarks: 3 },
{ id: 'resp-far', kind: 'response', x: 100, y: 520, w: 300, h: 90, responseForm: 'lines' }, // outside the part box
])
const part = payload.questions.find((q) => !q.is_container)
expect(payload.response_areas).toHaveLength(1) // previously dropped (no containing part)
expect(payload.response_areas[0].question_id).toBe(part?.id) // falls back to the part, not lost
})
it('preserves a 3-deep question → part → subpart chain across a round-trip save', () => {
const Q = '11111111-1111-4111-8111-111111111111' // container, depth 0
const P = '22222222-2222-4222-8222-222222222222' // container part, depth 1
const S = '33333333-3333-4333-8333-333333333333' // leaf subpart, depth 2
const detail: ExamTemplateDetail = {
...template,
questions: [
{ id: Q, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 40, y: 80, w: 700, h: 600 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
{ id: P, template_id: 'tpl-1', parent_id: Q, label: 'Q1(a)', order: 1, max_marks: 6, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, bounds: { x: 60, y: 120, w: 660, h: 500 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
{ id: S, template_id: 'tpl-1', parent_id: P, label: 'Q1(a)(i)', order: 2, max_marks: 3, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 80, y: 160, w: 620, h: 200 }, page: 1, source: 'ai', confirmed: false, confidence: 0.9, derivation: 'svc' },
],
response_areas: [],
boundaries: [],
}
const shapes = shapesFromTemplate(detail)
// all three tiers are drawn (containers are no longer skipped), with depth + container flags
expect(shapes.filter((s) => s.kind === 'part')).toHaveLength(3)
expect(shapes.find((s) => s.id === S)).toMatchObject({ isContainer: false, depth: 2, parentId: P })
expect(shapes.find((s) => s.id === Q)).toMatchObject({ isContainer: true, depth: 0 })
const payload = serializeCanvasShapes(template, shapes)
const byId = new Map(payload.questions.map((q) => [q.id, q]))
expect(payload.questions).toHaveLength(3)
expect(byId.get(Q)?.parent_id ?? null).toBeNull()
expect(byId.get(P)?.parent_id).toBe(Q) // middle tier survives — not flattened away
expect(byId.get(S)?.parent_id).toBe(P) // 3-deep chain intact
expect(byId.get(Q)?.is_container).toBe(true)
expect(byId.get(S)?.is_container).toBe(false)
expect(byId.get(S)?.max_marks).toBe(3)
})
it('respects a region persisted question over geometric nearest when no part contains it', () => {
const A = '11111111-1111-4111-8111-111111111111'
const B = '22222222-2222-4222-8222-222222222222'
const R = '33333333-3333-4333-8333-333333333333'
const payload = serializeCanvasShapes(template, [
{ id: 'pa', kind: 'part', x: 100, y: 120, w: 300, h: 80, label: 'A', questionId: A },
{ id: 'pb', kind: 'part', x: 100, y: 500, w: 300, h: 80, label: 'B', questionId: B },
{ id: R, kind: 'response', x: 120, y: 260, w: 200, h: 60, questionId: B }, // contained by neither; nearest is A
])
expect(payload.response_areas.find((r) => r.id === R)?.question_id).toBe(B) // persisted B wins over nearest A
})
})
+172 -25
View File
@@ -1,5 +1,5 @@
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
import type { ExamTemplateDetail, ExamTemplateSource, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_HEIGHT = 1100
export const PAGE_WIDTH = 780
@@ -10,7 +10,7 @@ export interface CanvasPageGeometry { pageNumber: number; x: number; y: number;
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
export interface CanvasBounds { x: number; y: number; w: number; h: number }
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
export interface ExamCanvasShapeModel {
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
@@ -26,6 +26,24 @@ export interface ExamCanvasShapeModel {
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
contextType?: string
questionId?: string | null
/** Tree parent (container question) id — preserved across a round-trip so N-deep structure survives a save. */
parentId?: string | null
/** True for a container question (main question / intermediate part) drawn as a nesting box. */
isContainer?: boolean
/** 0 = main question, 1 = part, 2 = subpart … drives nesting styling. */
depth?: number
/** Figure/table caption for a context region (shown as a tooltip). */
description?: string
/** Label of the owning question, for a context region's visible tether pill. */
linkLabel?: string
/** Rich recognition detail (exam_response_areas.meta): answer components, MC option boxes, final-answer
* line unit/quantity, figure name/description. Carried through so the canvas can draw sub-structure. */
meta?: Record<string, unknown> | null
source?: ExamTemplateSource
confirmed?: boolean
confidence?: number | null
derivation?: string | null
reviewFlags?: string[]
}
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
@@ -65,10 +83,35 @@ export function newDomainId(): string {
})
}
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
}
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
}
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
const page = pageGeometry(pageForShape(shape, pages), pages)
return { x: page.x, y: shape.y, w: page.w, h: 8 }
}
function persistedSource(shape: ExamCanvasShapeModel): ExamTemplateSource {
return shape.source ?? 'manual'
}
function persistedConfirmed(shape: ExamCanvasShapeModel): boolean {
return shape.confirmed ?? persistedSource(shape) !== 'ai'
}
function persistedConfidence(shape: ExamCanvasShapeModel): number | null {
return typeof shape.confidence === 'number' ? shape.confidence : null
}
function persistedDerivation(shape: ExamCanvasShapeModel): string | null {
return shape.derivation ?? null
}
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
const ox2 = outer.x + outer.w
const oy2 = outer.y + outer.h
@@ -88,63 +131,167 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part')
// 'part' shapes carry the whole question tree: containers (isContainer) + leaf parts/subparts. They are the
// authoritative structure — the boundary-pair synthesis below is only a fallback for the manual workflow.
const structural = shapes.filter((s) => s.kind === 'part')
const containerShapes = structural.filter((s) => s.isContainer)
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
const questions: TemplateReplacePayload['questions'] = []
const boundaries: TemplateReplacePayload['boundaries'] = []
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
// Stable question id per structural shape — prefer the persisted domain id so a re-save keeps join keys.
const idOf = new Map<string, string>()
for (const s of structural) idOf.set(s.id, isUuid(s.questionId) ? (s.questionId as string) : isUuid(s.id) ? s.id : newDomainId())
const emittedIds = new Set<string>(idOf.values())
// Boundary pairs: attach each pair to the container shape it spans (AI/structured path), else SYNTHESIZE a
// container question from the pair (pure manual authoring, no container box drawn). v1 always synthesized —
// which overwrote real containers and flattened the tree.
for (let i = 0; i < orderedBoundaries.length; i += 2) {
const top = orderedBoundaries[i]
const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break
const qNum = bands.length + 1
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${bands.length + 1}`
// A container box whose vertical extent spans this start/end pair owns the boundary (structured path);
// otherwise the pair itself defines a container (manual path).
const pairTop = Math.min(top.y, bottom.y)
const pairBottom = Math.max(top.y, bottom.y)
const cover = containerShapes.find((c) => c.y <= pairTop + 4 && c.y + c.h >= pairBottom - 4)
let questionId: string
if (cover) {
questionId = idOf.get(cover.id) as string
} else {
questionId = isUuid(top.questionId) ? (top.questionId as string) : isUuid(bottom.questionId) ? (bottom.questionId as string) : newDomainId()
questions.push({ id: questionId, label, order: questions.length, max_marks: 0, is_container: true, mark_scheme: {}, source: persistedSource(top), confirmed: persistedConfirmed(top), confidence: persistedConfidence(top), derivation: persistedDerivation(top) })
emittedIds.add(questionId)
}
bands.push({ questionId, top, bottom })
for (const b of [top, bottom]) {
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: persistedSource(b), confirmed: persistedConfirmed(b), confidence: persistedConfidence(b), derivation: persistedDerivation(b) })
}
}
// Resolve a structural shape's parent: the persisted parent (if still emitted) wins — this is what keeps a
// 3-deep subpart→part→question chain intact across a save. Manually-drawn shapes with no persisted parent
// fall back to geometry: the deepest container box that contains them, else the boundary band around them.
const resolveParent = (s: ExamCanvasShapeModel): string | null => {
if (isUuid(s.parentId) && emittedIds.has(s.parentId as string)) return s.parentId as string
const containing = containerShapes
.filter((c) => c.id !== s.id && contains(bounds(c), bounds(s)))
.sort((a, b) => (b.depth ?? 0) - (a.depth ?? 0) || (a.w * a.h) - (b.w * b.h))
if (containing.length) return idOf.get(containing[0].id) ?? null
const band = bands.find((bd) => bandContains(bd.top, bd.bottom, s))
return band?.questionId ?? null
}
const partQuestionIds = new Map<string, string>()
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
const structuralSorted = [...structural].sort((a, b) => (a.depth ?? 0) - (b.depth ?? 0) || (a.y - b.y) || (a.x - b.x))
structuralSorted.forEach((part, index) => {
const qid = idOf.get(part.id) as string
partQuestionIds.set(part.id, qid)
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
questions.push({ id: qid, parent_id: resolveParent(part), label: part.label || (part.isContainer ? `Q${index + 1}` : `Part ${index + 1}`), order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.isContainer ? null : (part.answerType ?? 'written'), mcq_options: null, mark_scheme: {}, is_container: !!part.isContainer, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: persistedSource(part), confirmed: persistedConfirmed(part), confidence: persistedConfidence(part), derivation: persistedDerivation(part) })
})
const parts = structural
// Resolve each region's owner question. Order: current geometric containment (a user who drags a
// region into a part re-attaches it) → the PERSISTED attachment if it still points at a saved
// question (survives pixel-overflow drift and regions with no enclosing part) → nearest part on the
// page → first part → first question of any kind. exam_response_areas.question_id is NOT NULL, so we
// never silently drop a region while any question exists (only a template with zero questions can).
const questionIds = new Set(questions.map((q) => q.id))
const response_areas: TemplateReplacePayload['response_areas'] = []
// Prefer the DEEPEST (smallest) containing structural box so a region lands on its leaf part, not the
// enclosing container. Leaf parts win over containers at equal containment.
const deepestContainer = (region: ExamCanvasShapeModel): ExamCanvasShapeModel | undefined =>
parts
.filter((part) => part.id !== region.id && contains(bounds(part), bounds(region)))
.sort((a, b) => Number(!!a.isContainer) - Number(!!b.isContainer) || (a.w * a.h) - (b.w * b.h))[0]
for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
const containingPart = deepestContainer(region)
const persisted = isUuid(region.questionId) && questionIds.has(region.questionId) ? region.questionId : undefined
const nearestLeaf = parts.filter((p) => !p.isContainer).find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts.find((p) => !p.isContainer) ?? parts[0]
const questionId =
(containingPart && partQuestionIds.get(containingPart.id))
|| persisted
|| (nearestLeaf && partQuestionIds.get(nearestLeaf.id))
|| questions[0]?.id
if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
// Carry rich recognition detail through the save so it survives a round-trip (the replace endpoint
// persists exam_response_areas.meta): answer components / MC boxes / final-answer for responses, and the
// figure name + caption for context regions.
const carried = (region.meta && typeof region.meta === 'object') ? region.meta as Record<string, unknown> : undefined
const meta = kind === 'context' && (region.label || region.description)
? { ...(carried ?? {}), name: region.label || carried?.name, description: region.description || carried?.description }
: carried
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, meta, source: persistedSource(region), confirmed: persistedConfirmed(region), confidence: persistedConfidence(region), mark_subtype: null, derivation: persistedDerivation(region) })
}
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
}
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) {
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
const depthOf = (id: string | null | undefined, guard = 0): number => {
const q = id ? questions.get(id) : undefined
return !q || !q.parent_id || guard > 8 ? 0 : 1 + depthOf(q.parent_id, guard + 1)
}
for (const b of detail.boundaries ?? []) {
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
// but render and save a full rendered-page-width horizontal rule.
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id, source: b.source, confirmed: b.confirmed, confidence: b.confidence, derivation: b.derivation })
}
// Draw the WHOLE tree that has geometry: containers (main questions / intermediate parts) AND leaf parts,
// each carrying its parent id + depth so nesting renders and a save preserves the N-deep chain. A question
// with no bounds still can't be drawn (legacy data) — it falls back to boundary-band synthesis on save.
for (const q of detail.questions ?? []) {
if (q.is_container || !q.bounds) continue
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
if (!q.bounds) continue
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.is_container ? undefined : ((q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written'), questionId: q.id, parentId: q.parent_id, isContainer: q.is_container, depth: depthOf(q.id), source: q.source, confirmed: q.confirmed, confidence: q.confidence, derivation: q.derivation })
}
for (const r of detail.response_areas ?? []) {
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
const q = questions.get(r.question_id)
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
const meta = ((r as { meta?: Record<string, unknown> }).meta) ?? {}
const name = typeof meta.name === 'string' ? meta.name : undefined
const description = typeof meta.description === 'string' ? meta.description : undefined
// A context region is NAMED by its figure/table (was an anonymous purple box). Response/other regions keep
// the "→ owning question" label so their link is legible.
const label = r.kind === 'context'
? (name || (r.context_type && r.context_type !== 'generic' ? r.context_type : 'Context'))
: (q ? `${q.label}` : r.kind)
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label, description, linkLabel: q?.label, meta: (r as { meta?: Record<string, unknown> }).meta ?? undefined, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id, source: r.source, confirmed: r.confirmed, confidence: r.confidence, derivation: r.derivation })
}
return shapes
return addCheapReviewFlags(shapes, pages)
}
function overlaps(a: ExamCanvasShapeModel, b: ExamCanvasShapeModel): boolean {
const ax2 = a.x + a.w
const ay2 = a.y + a.h
const bx2 = b.x + b.w
const by2 = b.y + b.h
return a.x < bx2 && ax2 > b.x && a.y < by2 && ay2 > b.y
}
function looksUncertainLabel(label: string | undefined): boolean {
return !label || /\b(unknown|uncertain|maybe|todo|tbd)\b|\?/.test(label.toLowerCase())
}
function addCheapReviewFlags(shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const markAreasByQuestion = new Set(shapes.filter((shape) => shape.kind === 'mark_area' && shape.questionId).map((shape) => shape.questionId as string))
return shapes.map((shape, index) => {
const flags: string[] = []
if (shape.source === 'ai' && shape.confirmed === false) flags.push('unconfirmed AI')
if (typeof shape.confidence === 'number' && shape.confidence < 0.7) flags.push('low confidence')
if ((shape.kind === 'part' || shape.kind === 'question_number') && looksUncertainLabel(shape.label)) flags.push('uncertain question label')
if (shape.kind === 'part' && (!shape.maxMarks || shape.maxMarks <= 0) && !markAreasByQuestion.has(shape.questionId ?? shape.id)) flags.push('missing marks')
// Containers are MEANT to enclose their children, so never flag them (or their children) for overlap;
// flag only genuine partial overlaps between non-container shapes where neither contains the other.
const samePageOverlap = !shape.isContainer && shape.kind !== 'boundary' && shapes.some((other, otherIndex) => otherIndex !== index && !other.isContainer && other.kind !== 'boundary' && pageForShape(shape, pages) === pageForShape(other, pages) && overlaps(shape, other) && !contains(bounds(shape), bounds(other)) && !contains(bounds(other), bounds(shape)))
if (samePageOverlap) flags.push('overlapping shapes')
return flags.length ? { ...shape, reviewFlags: flags } : shape
})
}