import { checkRelevanceAndGetFeedback, extractQuestionAnswerFromImage, gradeWithRubric, NOT_RELATED_MESSAGE, detectAiWithScorer, scoreTraitsWithGpt, scoreWithBertService } from "../services/aiService.js"; import { getRubric } from "../services/rubricService.js"; import { saveEvaluation } from "../services/evaluationService.js"; import { answerHash, checkDuplicate, duplicateForViewer } from "../services/duplicateService.js"; import { getMyProfile } from "../services/authService.js"; import { isValidSubject } from "../config/constants.js"; /** * Every submission carries who wrote it, for what subject, and a hash of the answer. * The hash is what makes exact-duplicate detection a single indexed lookup instead of a * scan, and the identity fields are what let a teacher sort and filter later. */ const submissionContext = async (req, answerText) => { const userUid = req.user?.uid || null; let profile = null; if (userUid) { profile = await getMyProfile({ userUid }).catch(() => null); } const rawSubject = String(req.body?.subject || "").trim(); const subject = isValidSubject(rawSubject) ? rawSubject : null; return { userUid, studentName: profile?.fullName || null, department: profile?.collegeDepartment || null, subject, answerHash: answerHash(answerText), }; }; // Generous for any school essay, but stops someone pasting a book into a GPT call. const MAX_ANSWER_CHARS = Number(process.env.MAX_ANSWER_CHARS || 15000); const MAX_QUESTION_CHARS = Number(process.env.MAX_QUESTION_CHARS || 2000); const ZERO_SCORES = { Cohesion: 0, Syntax: 0, Vocabulary: 0, Phraseology: 0, Grammar: 0, Conventions: 0 }; const buildZeroScoresFromRubric = (rubric) => { const scores = {}; for (const item of rubric || []) { if (item?.name) { scores[item.name] = 0; } } return scores; }; const maxRubricTotal = (rubric) => { return (rubric || []).reduce((sum, item) => { const max = Number(item?.max); return sum + (Number.isFinite(max) ? Math.max(0, max) : 0); }, 0); }; export const gradeEssay = async (req, res) => { try { const { essay, answer, question, rubricId } = req.body; const answerText = answer || essay; if (!answerText) { return res.status(400).json({ error: "Missing answer (or essay)" }); } const questionText = String(question || "").trim(); if (!questionText) { return res.status(400).json({ error: "Missing question" }); } if (typeof answerText !== "string" || answerText.length > MAX_ANSWER_CHARS) { return res.status(413).json({ error: `Answer is too long (limit ${MAX_ANSWER_CHARS} characters)` }); } if (questionText.length > MAX_QUESTION_CHARS) { return res.status(413).json({ error: `Question is too long (limit ${MAX_QUESTION_CHARS} characters)` }); } // The role comes from the verified token's custom claim, not from anything the // browser sends, so it decides what each viewer is allowed to see in the response. const isTeacher = req.user?.role === "teacher"; const ctx = await submissionContext(req, answerText); // Run the duplicate scan alongside the relevance call rather than after it - they are // independent, and sequencing them would add a Firestore round trip to every grade. const [relevance, duplicateCheck] = await Promise.all([ checkRelevanceAndGetFeedback(questionText, answerText), checkDuplicate({ answer: answerText, subject: ctx.subject, excludeUid: ctx.userUid, }).catch((e) => ({ checked: false, reason: e.message })), ]); if (!relevance.related) { const rubricForZero = rubricId ? await getRubric(rubricId) : null; const zeroScores = rubricForZero ? buildZeroScoresFromRubric(rubricForZero) : ZERO_SCORES; const notRelatedResponse = { scores: zeroScores, average: 0, relevanceScore: 0, feedback: NOT_RELATED_MESSAGE, isRelated: false, scoredBy: "fallback-zero", rubricId: rubricId || null, subject: ctx.subject, duplicateCheck: duplicateForViewer(duplicateCheck, isTeacher) }; await saveEvaluation({ ...ctx, duplicateCheck, question: questionText, answer: answerText, rubricId: rubricId || null, rubricCriteria: rubricForZero || null, isRelated: false, relevanceScore: 0, scoredBy: "fallback-zero", scores: notRelatedResponse.scores, average: 0, feedback: notRelatedResponse.feedback }); return res.json(notRelatedResponse); } if (rubricId) { const rubric = await getRubric(rubricId); const rubricResult = await gradeWithRubric(rubric, answerText, questionText); const total = Number(rubricResult?.total || 0); const maxTotal = maxRubricTotal(rubric); const averageOutOf10 = maxTotal > 0 ? Math.round(((total / maxTotal) * 10) * 100) / 100 : 0; const responsePayload = { scores: rubricResult?.scores || buildZeroScoresFromRubric(rubric), average: averageOutOf10, rubricTotal: Math.round(total * 100) / 100, rubricMaxTotal: maxTotal, relevanceScore: Number(relevance.relevanceScore || 0), feedback: rubricResult?.feedback || relevance.feedback, isRelated: true, scoredBy: "Custom Rubric", rubricId, subject: ctx.subject, duplicateCheck: duplicateForViewer(duplicateCheck, isTeacher), // per-criterion maxima so the UI can render "18.0/25" instead of a bare "18.0" rubricCriteria: (rubric || []).map((item) => ({ name: item?.name, max: Number(item?.max) || 0 })), raw_outputs: [] }; await saveEvaluation({ ...ctx, duplicateCheck, question: questionText, answer: answerText, rubricId, rubricCriteria: rubric, isRelated: true, relevanceScore: responsePayload.relevanceScore, scoredBy: "Custom Rubric", scores: responsePayload.scores, average: responsePayload.average, rubricTotal: responsePayload.rubricTotal, rubricMaxTotal: responsePayload.rubricMaxTotal, feedback: responsePayload.feedback }); return res.json(responsePayload); } // English goes to the trained model. Filipino and Taglish go to GPT on the same six // traits and the same scale, because the trained model has never seen Filipino and // would score it confidently and meaninglessly. scoredBy records which one it was. const language = relevance.language || "en"; const isFilipino = language === "tl" || language === "taglish"; let traitResult; let scoredBy; let aiDetection; let rawOutputs = []; if (isFilipino) { // Trait scores from GPT and the Tagalog AI check from the scorer run side by side. [traitResult, aiDetection] = await Promise.all([ scoreTraitsWithGpt(questionText, answerText), detectAiWithScorer(answerText, language), ]); scoredBy = "GPT (Filipino)"; } else { const bertResult = await scoreWithBertService(answerText); traitResult = { scores: bertResult?.scores, average: bertResult?.average }; scoredBy = "BERT"; aiDetection = bertResult?.ai_detection || null; rawOutputs = Array.isArray(bertResult?.raw_outputs) ? bertResult.raw_outputs : []; } // An experimental check (the Tagalog detector) is saved for teachers to review but not // sent back to students: hiding it in the UI alone would leave it readable in the // browser's network tab. const visibleAiDetection = aiDetection?.experimental && !isTeacher ? null : aiDetection; const bertResponse = { scores: traitResult?.scores || ZERO_SCORES, average: Number(traitResult?.average || 0), relevanceScore: Number(relevance.relevanceScore || 0), feedback: relevance.feedback, isRelated: true, scoredBy, language, rubricId: null, subject: ctx.subject, duplicateCheck: duplicateForViewer(duplicateCheck, isTeacher), aiDetection: visibleAiDetection, raw_outputs: rawOutputs }; await saveEvaluation({ ...ctx, duplicateCheck, question: questionText, answer: answerText, rubricId: null, rubricCriteria: null, isRelated: true, relevanceScore: bertResponse.relevanceScore, scoredBy, language, scores: bertResponse.scores, average: bertResponse.average, feedback: bertResponse.feedback, aiDetection, rawOutputs }); res.json(bertResponse); } catch (err) { if (err.message === "Rubric not found") { return res.status(404).json({ error: err.message }); } res.status(500).json({ error: "Failed to grade essay", message: err.message }); } }; export const extractQuestionAnswer = async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded", message: "No photo was received." }); } const result = await extractQuestionAnswerFromImage(req.file); if (!result.hasAnswer) { return res.status(422).json({ error: "No answer found", message: "No writing could be read in this photo. Retake it closer, in good light, with the whole page in frame." }); } // What the reader could not be sure of is reported back, so the student checks those // spots before grading instead of being graded on a guess. const notes = []; if (!result.question) notes.push("No question was found in the photo - type it in."); if (result.illegibleWords > 0) { notes.push(`${result.illegibleWords} word(s) could not be read and are marked [?] - fix them before checking.`); } res.json({ question: result.question, answer: result.answer, notes }); } catch (err) { res.status(err.status || 500).json({ error: "Failed to read the photo", message: err.status ? err.message : "The photo could not be read. Try again." }); } };