Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4c36631
Add document OCR and exam assessment schema
NMSOfficial Aug 7, 2026
1777d95
Add secure Mistral OCR and exam grading service
NMSOfficial Aug 7, 2026
64d1f41
Expose OCR, integration and exam grading endpoints
NMSOfficial Aug 7, 2026
0fbddb9
Register document assessment API routes
NMSOfficial Aug 7, 2026
a92f593
Allow explicit custom rubric descriptions in grading
NMSOfficial Aug 7, 2026
d24f317
Add assessment hub domain types
NMSOfficial Aug 7, 2026
a6a49dc
Add assessment hub data service
NMSOfficial Aug 7, 2026
03af25f
Add safe camera, gallery and ZIP import helpers
NMSOfficial Aug 7, 2026
1688c23
Grade advanced writing criteria and hide OCR scores pending review
NMSOfficial Aug 7, 2026
61db8d7
Include advanced criterion requirements in AI prompt
NMSOfficial Aug 7, 2026
0b2d216
Refine OCR submission security and rubric metadata
NMSOfficial Aug 7, 2026
6fa0699
Build advanced shared writing assignment creator
NMSOfficial Aug 7, 2026
cf92f37
Add reusable camera gallery ZIP and cloud import controls
NMSOfficial Aug 7, 2026
cf8a4c0
Fix composite assignment selection in OCR RPC
NMSOfficial Aug 7, 2026
fe656ae
Add teacher OCR writing and general exam hub
NMSOfficial Aug 7, 2026
d0d12de
Add human review for AI graded exams
NMSOfficial Aug 7, 2026
9e6b3f7
Add student exam feedback and appeal screen
NMSOfficial Aug 7, 2026
642c830
Add secure Mistral OCR integration settings
NMSOfficial Aug 7, 2026
50e8926
Add document assessment navigation
NMSOfficial Aug 7, 2026
879ac85
Wire assessment hub and advanced assignment routes
NMSOfficial Aug 7, 2026
cb8d1a3
Style document assessment and advanced scoring workflows
NMSOfficial Aug 7, 2026
e757c10
Load document assessment styles
NMSOfficial Aug 7, 2026
ce08e73
Augment assignments with advanced scoring metadata
NMSOfficial Aug 7, 2026
8e59d05
Map advanced assignment scoring metadata
NMSOfficial Aug 7, 2026
8948d1b
Add normalized writing score scaling helper
NMSOfficial Aug 7, 2026
931f2c8
Display assignment results on configured score scale
NMSOfficial Aug 7, 2026
a214f3c
Add score-scale aware assignment PDF export
NMSOfficial Aug 7, 2026
7b6407c
Use scale-aware writing assignment PDF report
NMSOfficial Aug 7, 2026
113b284
Clean assessment hub imports
NMSOfficial Aug 7, 2026
4d3b765
Label advanced vocabulary and pattern criteria cleanly
NMSOfficial Aug 7, 2026
646e61e
Show teacher writing reviews on configured score scale
NMSOfficial Aug 7, 2026
093ff13
Keep OCR document review status synchronized
NMSOfficial Aug 7, 2026
a47355f
Add scale-aware writing result PDF export
NMSOfficial Aug 7, 2026
01e09fe
Show student writing results on configured score scale
NMSOfficial Aug 7, 2026
ae8801e
Avoid persisting cloud document share URLs
NMSOfficial Aug 7, 2026
26f2b6c
Fix assignment builder lint false positive
NMSOfficial Aug 7, 2026
59446be
Harden ZIP filename sanitization without control regex
NMSOfficial Aug 7, 2026
b22346d
Harden document assessment integrity and review flows
NMSOfficial Aug 7, 2026
4dada86
Harden exam review atomicity and permissions
NMSOfficial Aug 7, 2026
b7f6e77
Use atomic RPCs for exam assessment mutations
NMSOfficial Aug 7, 2026
f0bb6ee
Make Gemma grading robust without structured-output dependency
NMSOfficial Aug 7, 2026
9162e76
Harden OCR ingestion and Gemma exam grading
NMSOfficial Aug 7, 2026
c05b115
Harden exam grading with server-signed atomic RPCs
NMSOfficial Aug 8, 2026
2cf2bdd
Route exam AI grading through signed atomic persistence
NMSOfficial Aug 8, 2026
7cb00f0
Use secure server-signed exam grading route
NMSOfficial Aug 8, 2026
d0b3bf2
Add exam grading normalization and parser tests
NMSOfficial Aug 8, 2026
98b4623
Add teacher school scope lookup for assessment flows
NMSOfficial Aug 8, 2026
5ccc978
Make assessment hub refresh-safe and multi-school aware
NMSOfficial Aug 8, 2026
14f716f
Create advanced writing assignments atomically
NMSOfficial Aug 8, 2026
d9b7b8b
Fix PostgreSQL aliases in atomic assignment migration
NMSOfficial Aug 8, 2026
6e18c34
Use atomic RPC for advanced writing assignment creation
NMSOfficial Aug 8, 2026
f02bee1
Make advanced assignment creation atomic and school-scoped
NMSOfficial Aug 8, 2026
f99c8fb
Protect OCR provenance with server-signed persistence
NMSOfficial Aug 8, 2026
3d240da
Route OCR through signed server persistence
NMSOfficial Aug 8, 2026
30650b3
Use server-signed OCR route
NMSOfficial Aug 8, 2026
1b137bd
Guard OCR writing imports against oversized model input
NMSOfficial Aug 8, 2026
460921a
Lock assessment provenance and scoring configuration
NMSOfficial Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
753 changes: 753 additions & 0 deletions server/documentAssessment.ts

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions server/documentAssessmentRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type express from 'express';
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import { z, ZodError } from 'zod';
import {
configureMistralKey,
getIntegrationStatus,
} from './documentAssessment.ts';
import { processDocumentOcrSecure } from './secureDocumentOcr.ts';
import { gradeExamAttemptSecure } from './secureExamGrading.ts';

interface RouteDeps {
supabaseUrl: string;
anonKey: string;
gradingServerSecret: string;
geminiApiKey: string;
integrationEncryptionKey?: string;
}

const authenticatedKey = (req: express.Request) =>
req.headers.authorization ?? ipKeyGenerator(req.ip ?? 'unknown');

const documentLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 60,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: authenticatedKey,
message: { error: 'Too many document-processing requests. Please wait a few minutes and try again.' },
});

const integrationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: authenticatedKey,
message: { error: 'Too many integration requests. Please wait a few minutes and try again.' },
});

const ocrSchema = z.object({
itemId: z.string().uuid(),
dataBase64: z.string().max(14_500_000).optional(),
mimeType: z.string().max(100).optional(),
sourceUrl: z.string().url().max(2_000).optional(),
}).refine((value) => Boolean(value.sourceUrl || (value.dataBase64 && value.mimeType)), {
message: 'Either sourceUrl or dataBase64 + mimeType is required',
});

const mistralKeySchema = z.object({ apiKey: z.string().trim().min(16).max(500) });
const attemptIdSchema = z.string().uuid();

function errorStatus(message: string): number {
if (message === 'Unauthorized') return 401;
if (message === 'Forbidden') return 403;
if (message.includes('not_found')) return 404;
if (message.includes('not_configured') || message.includes('template_not_ready')) return 503;
if (message.includes('temporarily_unavailable')) return 503;
if (message.includes('timeout') || message.includes('network_failed')) return 503;
if (message.includes('too_large')) return 413;
if (message.includes('already_running') || message.includes('cannot_start') || message.includes('not_processing')) return 409;
if (message.includes('unsupported') || message.includes('invalid') || message.includes('must_be_https') || message.includes('not_allowed') || message.includes('scale_too_small') || message.includes('mime_mismatch')) return 400;
return 502;
}

export function registerDocumentAssessmentRoutes(app: express.Express, deps: RouteDeps): void {
app.get('/api/integrations/status', integrationLimiter, async (req, res) => {
try {
res.json(await getIntegrationStatus(req.headers.authorization, deps));
} catch (error) {
const message = error instanceof Error ? error.message : 'integration_status_failed';
res.status(errorStatus(message)).json({ error: message });
}
});

app.post('/api/admin/integrations/mistral', integrationLimiter, async (req, res) => {
try {
const body = mistralKeySchema.parse(req.body);
res.json(await configureMistralKey(req.headers.authorization, body.apiKey, deps));
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({ error: 'Invalid request body', details: error.issues });
return;
}
const message = error instanceof Error ? error.message : 'integration_config_failed';
res.status(errorStatus(message)).json({ error: message });
}
});

app.post('/api/documents/ocr', documentLimiter, async (req, res) => {
try {
const body = ocrSchema.parse(req.body);
res.json(await processDocumentOcrSecure(req.headers.authorization, body, deps));
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({ error: 'Invalid request body', details: error.issues });
return;
}
const message = error instanceof Error ? error.message : 'ocr_failed';
console.error('[ocr] request failed:', message);
res.status(errorStatus(message)).json({ error: message });
}
});

app.post('/api/exam-attempts/:attemptId/grade', documentLimiter, async (req, res) => {
try {
const attemptId = attemptIdSchema.parse(req.params.attemptId);
res.json(await gradeExamAttemptSecure(req.headers.authorization, attemptId, deps));
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({ error: 'Invalid attempt ID' });
return;
}
const message = error instanceof Error ? error.message : 'exam_grading_failed';
console.error('[exam-grading] request failed:', message);
res.status(errorStatus(message)).json({ error: message });
}
});
}
48 changes: 41 additions & 7 deletions server/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,38 @@ function buildPrompt(input: GradeRequest, levelDescriptor?: string): { systemIns
const criteriaLines = input.criteria
.map((c) => {
const label = resolveLabel(c.nameKey);
const description = resolveDescription(c.nameKey);
return `- id="${c.id}" name="${label}"${description ? ` description="${description}"` : ''} maxScore=${c.maxScore} weight=${c.weight}`;
const description = c.description ?? resolveDescription(c.nameKey);
return `- id="${c.id}" name="${label}"${description ? ` description="${description.replace(/"/g, '\\"')}"` : ''} maxScore=${c.maxScore} weight=${c.weight}`;
})
.join('\n');

const categoryLines = ERROR_CATEGORIES.map((c) => `${c.id} (${c.group})`).join(', ');

const systemInstruction = `You are an expert CEFR-aligned English writing assessor for an EFL education platform. You grade a student's essay against a weighted rubric and flag concrete errors.

Return a single JSON object matching the supplied response schema.
Return ONLY one JSON object. Do not wrap it in Markdown or code fences. The exact logical shape is:
{
"criterionScores": [
{
"criterionId": "criterion id from the list below",
"score": 0,
"explanation": "English explanation",
"evidenceQuote": "optional exact quote from the essay",
"strongAspects": ["English strength"],
"developmentAreas": ["English improvement area"]
}
],
"annotations": [
{
"quotedText": "exact text copied from the essay",
"severity": "critical|mistake|inaccuracy|info",
"categoryId": "one allowed category id",
"explanation": "English explanation",
"hint": "optional English hint",
"suggestedCorrection": "optional correction"
}
]
}

Rules:
- Include exactly one entry in criterionScores for every criterion id listed below, no more, no fewer.
Expand Down Expand Up @@ -175,14 +197,19 @@ async function callGemini(systemInstruction: string, userContent: string, apiKey
const combinedUserContent = IS_GEMMA_MODEL
? `${systemInstruction}\n\n${userContent}`
: userContent;

const requestBody = {
contents: [{ role: 'user', parts: [{ text: combinedUserContent }] }],
...(IS_GEMMA_MODEL ? {} : { systemInstruction: { parts: [{ text: systemInstruction }] } }),
generationConfig: {
maxOutputTokens: 4096,
...(IS_GEMMA_MODEL ? {} : { thinkingConfig: { thinkingLevel: 'minimal' } }),
responseMimeType: 'application/json',
responseSchema: MODEL_OUTPUT_SCHEMA,
thinkingConfig: { thinkingLevel: 'minimal' },
...(IS_GEMMA_MODEL
? {}
: {
responseMimeType: 'application/json',
responseSchema: MODEL_OUTPUT_SCHEMA,
}),
},
};

Expand Down Expand Up @@ -239,18 +266,25 @@ function validateModelOutput(output: ModelOutput, criteria: CriterionInput[]): v
true,
);
}

const seenCriteria = new Set<string>();
for (const cs of output.criterionScores) {
const criterion = criteriaById.get(cs.criterionId);
if (!criterion) {
throw new GradingError(`Unknown criterionId "${cs.criterionId}" in model output`, true);
}
if (seenCriteria.has(cs.criterionId)) {
throw new GradingError(`Duplicate criterionId "${cs.criterionId}" in model output`, true);
}
seenCriteria.add(cs.criterionId);
if (cs.score < 0 || cs.score > criterion.maxScore) {
throw new GradingError(
`Score ${cs.score} out of range for criterion "${cs.criterionId}" (max ${criterion.maxScore})`,
true,
);
}
}

for (const a of output.annotations) {
if (!CATEGORY_IDS.has(a.categoryId)) {
throw new GradingError(`Unknown categoryId "${a.categoryId}" in model output`, true);
Expand Down Expand Up @@ -327,7 +361,7 @@ async function requestGrading(input: GradeRequest, apiKey: string, attempt: numb
const effectiveSystemInstruction =
attempt === 0
? systemInstruction
: `${systemInstruction}\n\nIMPORTANT: your previous response did not satisfy the required schema. Return only a schema-compliant result.`;
: `${systemInstruction}\n\nIMPORTANT: your previous response was invalid. Return only valid JSON matching the exact shape above.`;

const raw = await callGemini(effectiveSystemInstruction, userContent, apiKey);

Expand Down
3 changes: 2 additions & 1 deletion server/gradingSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const criterionInputSchema = z.object({
id: z.string(),
key: z.string(),
nameKey: z.string(),
description: z.string().optional(),
weight: z.number(),
maxScore: z.number().positive(),
});
Expand Down Expand Up @@ -48,4 +49,4 @@ export const modelOutputSchema = z.object({

export type ModelOutput = z.infer<typeof modelOutputSchema>;
export type ModelCriterionScore = z.infer<typeof modelCriterionScoreSchema>;
export type ModelAnnotation = z.infer<typeof modelAnnotationSchema>;
export type ModelAnnotation = z.infer<typeof modelAnnotationSchema>;
17 changes: 15 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z, ZodError } from 'zod';
import { requestPasswordReset } from './passwordReset.ts';
import { deleteUserAccount } from './adminActions.ts';
import { gradeAndPersistSubmission, SubmissionGradingError } from './submissionGrading.ts';
import { registerDocumentAssessmentRoutes } from './documentAssessmentRoutes.ts';

try {
process.loadEnvFile();
Expand All @@ -17,6 +18,7 @@ const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
const GRADING_SERVER_SECRET = process.env.GRADING_SERVER_SECRET;
const INTEGRATION_ENCRYPTION_KEY = process.env.INTEGRATION_ENCRYPTION_KEY;
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_FROM_EMAIL = process.env.RESEND_FROM_EMAIL;
const APP_ORIGIN = process.env.APP_ORIGIN;
Expand All @@ -36,13 +38,14 @@ const PASIFIC_VERCEL_ORIGIN = /^https:\/\/pasific(?:-[a-z0-9-]+)?\.vercel\.app$/
if (!GEMINI_API_KEY) console.warn('[server] GEMINI_API_KEY is not configured');
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) console.warn('[server] Supabase public configuration is incomplete');
if (!GRADING_SERVER_SECRET) console.warn('[server] GRADING_SERVER_SECRET is not configured');
if (!INTEGRATION_ENCRYPTION_KEY) console.warn('[server] INTEGRATION_ENCRYPTION_KEY is not configured; grading secret will be used as the integration encryption seed');
if (!SUPABASE_SERVICE_ROLE_KEY) console.warn('[server] SUPABASE_SERVICE_ROLE_KEY is not configured');
if (!RESEND_API_KEY || !RESEND_FROM_EMAIL) console.warn('[server] Resend configuration is incomplete');

const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '16mb' }));
app.use((req, res, next) => {
req.url = req.url.replace(/\/{2,}/g, '/');
res.setHeader('X-Content-Type-Options', 'nosniff');
Expand Down Expand Up @@ -174,6 +177,16 @@ app.post('/api/submissions/:submissionId/grade', gradeLimiter, async (req, res)
}
});

if (GEMINI_API_KEY && SUPABASE_URL && SUPABASE_ANON_KEY && GRADING_SERVER_SECRET) {
registerDocumentAssessmentRoutes(app, {
supabaseUrl: SUPABASE_URL,
anonKey: SUPABASE_ANON_KEY,
gradingServerSecret: GRADING_SERVER_SECRET,
geminiApiKey: GEMINI_API_KEY,
integrationEncryptionKey: INTEGRATION_ENCRYPTION_KEY,
});
}

const passwordResetRequestSchema = z.object({
username: z.string().trim().min(1).max(100),
method: z.enum(['email', 'phone']),
Expand Down Expand Up @@ -269,4 +282,4 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex

app.listen(PORT, () => {
console.log(`[server] listening on http://localhost:${PORT}`);
});
});
Loading
Loading