Download backend/controllers/essayController.js from essay003/prmsu: direct link, hf CLI and curl.
- Browser
- Download file 10.4 kB
-
https://huggingface.co/spaces/essay003/prmsu/resolve/main/backend/controllers/essayController.js
- Command line
-
hf download hf://spaces/essay003/prmsu/backend/controllers/essayController.js
-
curl -L -o essayController.js https://huggingface.co/spaces/essay003/prmsu/resolve/main/backend/controllers/essayController.js
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." | |
| }); | |
| } | |
| }; | |