prmsu / backend /services /evaluationService.js
wendel latina
Deploy PRMSU Essay Grader
f853e83 verified
Raw History Blame Contribute Delete
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);
};