Download backend/services/evaluationService.js from essay003/prmsu: direct link, hf CLI and curl.
- Browser
- Download file 5.94 kB
-
https://huggingface.co/spaces/essay003/prmsu/resolve/main/backend/services/evaluationService.js
- Command line
-
hf download hf://spaces/essay003/prmsu/backend/services/evaluationService.js
-
curl -L -o evaluationService.js https://huggingface.co/spaces/essay003/prmsu/resolve/main/backend/services/evaluationService.js
5.94 kB
| import { db, admin } from "../config/firebase.js"; | |
| import { isValidSubject } from "../config/constants.js"; | |
| const EVALUATIONS_COLLECTION = "evaluations"; | |
| const toIso = (value) => { | |
| if (!value) return null; | |
| if (typeof value.toDate === "function") { | |
| return value.toDate().toISOString(); | |
| } | |
| const date = new Date(value); | |
| return Number.isNaN(date.getTime()) ? null : date.toISOString(); | |
| }; | |
| /** One shape for an evaluation, used by every read path so they cannot drift. */ | |
| const shape = (doc) => { | |
| const data = doc.data(); | |
| return { | |
| evaluationId: doc.id, | |
| question: data.question || "", | |
| answer: data.answer || "", | |
| feedback: data.feedback || "", | |
| scores: data.scores || {}, | |
| average: Number(data.average || 0), | |
| relevanceScore: Number(data.relevanceScore || 0), | |
| scoredBy: data.scoredBy || "", | |
| rubricId: data.rubricId || null, | |
| rubricCriteria: Array.isArray(data.rubricCriteria) ? data.rubricCriteria : null, | |
| rubricTotal: data.rubricTotal ?? null, | |
| rubricMaxTotal: data.rubricMaxTotal ?? null, | |
| isRelated: Boolean(data.isRelated), | |
| createdAt: toIso(data.createdAt), | |
| subject: data.subject || null, | |
| department: data.department || null, | |
| studentName: data.studentName || null, | |
| userUid: data.userUid || null, | |
| aiDetection: data.aiDetection || null, | |
| duplicateCheck: data.duplicateCheck || null, | |
| // Teacher override, when one has been applied. | |
| override: data.override | |
| ? { | |
| scores: data.override.scores || {}, | |
| average: Number(data.override.average || 0), | |
| reason: data.override.reason || "", | |
| byName: data.override.byName || "", | |
| byUid: data.override.byUid || "", | |
| at: toIso(data.override.at), | |
| } | |
| : null, | |
| }; | |
| }; | |
| export const saveEvaluation = async (evaluation) => { | |
| const payload = { | |
| ...evaluation, | |
| createdAt: admin.firestore.FieldValue.serverTimestamp(), | |
| }; | |
| const doc = await db.collection(EVALUATIONS_COLLECTION).add(payload); | |
| return { evaluationId: doc.id }; | |
| }; | |
| export const getEvaluationLogsByUser = async ({ userUid, limit = 50, subject = null }) => { | |
| const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); | |
| const useSubject = Boolean(subject && isValidSubject(subject)); | |
| let q = db.collection(EVALUATIONS_COLLECTION).where("userUid", "==", userUid); | |
| if (useSubject) q = q.where("subject", "==", subject); | |
| try { | |
| const snap = await q.orderBy("createdAt", "desc").limit(safeLimit).get(); | |
| return snap.docs.map(shape); | |
| } catch (error) { | |
| // Adding the subject filter needs a composite index (userUid + subject + createdAt). | |
| // Rather than failing the student's own log view, fetch by userUid alone - which is | |
| // already indexed - and filter and sort in memory. | |
| if (!useSubject) throw error; | |
| const snap = await db | |
| .collection(EVALUATIONS_COLLECTION) | |
| .where("userUid", "==", userUid) | |
| .orderBy("createdAt", "desc") | |
| .limit(safeLimit * 3) | |
| .get(); | |
| return snap.docs | |
| .map(shape) | |
| .filter((d) => d.subject === subject) | |
| .slice(0, safeLimit); | |
| } | |
| }; | |
| /** | |
| * Teacher view: every student's submissions, optionally narrowed by subject or | |
| * department. Students never reach this - the route is role-gated. | |
| */ | |
| export const getEvaluationsForTeacher = async ({ | |
| limit = 100, | |
| subject = null, | |
| department = null, | |
| } = {}) => { | |
| const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 500); | |
| let q = db.collection(EVALUATIONS_COLLECTION); | |
| if (subject && isValidSubject(subject)) q = q.where("subject", "==", subject); | |
| if (department) q = q.where("department", "==", String(department).toUpperCase()); | |
| try { | |
| const snap = await q.orderBy("createdAt", "desc").limit(safeLimit).get(); | |
| return { logs: snap.docs.map(shape), degraded: false }; | |
| } catch (error) { | |
| // A filtered + ordered query needs a composite index. Rather than showing the | |
| // teacher an error, fall back to an unordered fetch and sort in memory. | |
| const snap = await q.limit(safeLimit).get(); | |
| const logs = snap.docs.map(shape) | |
| .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); | |
| return { logs, degraded: true, indexHint: error.message }; | |
| } | |
| }; | |
| export const getEvaluationById = async (evaluationId) => { | |
| const doc = await db.collection(EVALUATIONS_COLLECTION).doc(evaluationId).get(); | |
| if (!doc.exists) throw new Error("Evaluation not found"); | |
| return shape(doc); | |
| }; | |
| /** | |
| * Apply a teacher's score adjustment. | |
| * | |
| * The model's original scores are never overwritten - the override is stored alongside | |
| * them with who changed it and why. That keeps the audit trail intact, which matters | |
| * both for fairness to the student and for your own data if you analyse how often | |
| * teachers disagreed with the model. | |
| */ | |
| export const overrideScores = async ({ evaluationId, scores, reason, byUid, byName }) => { | |
| const ref = db.collection(EVALUATIONS_COLLECTION).doc(evaluationId); | |
| const doc = await ref.get(); | |
| if (!doc.exists) throw new Error("Evaluation not found"); | |
| const clean = {}; | |
| for (const [k, v] of Object.entries(scores || {})) { | |
| const n = Number(v); | |
| if (!Number.isFinite(n)) throw new Error(`Score for "${k}" must be a number`); | |
| if (n < 0 || n > 100) throw new Error(`Score for "${k}" is out of range`); | |
| clean[k] = Math.round(n * 100) / 100; | |
| } | |
| if (!Object.keys(clean).length) throw new Error("No scores provided"); | |
| const values = Object.values(clean); | |
| const average = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; | |
| await ref.set( | |
| { | |
| override: { | |
| scores: clean, | |
| average, | |
| reason: String(reason || "").trim().slice(0, 500), | |
| byUid, | |
| byName: String(byName || ""), | |
| at: admin.firestore.FieldValue.serverTimestamp(), | |
| }, | |
| }, | |
| { merge: true } | |
| ); | |
| return getEvaluationById(evaluationId); | |
| }; | |