prmsu / backend /controllers /essayController.js
wendel latina
Deploy PRMSU Essay Grader
5b89d94 verified
Raw History Blame Contribute Delete
10.4 kB
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."
});
}
};