diff --git a/backend/src/modules/anomalies/classes/transformers/Anomaly.ts b/backend/src/modules/anomalies/classes/transformers/Anomaly.ts index 75f4ad665..2fb94cf39 100644 --- a/backend/src/modules/anomalies/classes/transformers/Anomaly.ts +++ b/backend/src/modules/anomalies/classes/transformers/Anomaly.ts @@ -10,6 +10,8 @@ export enum AnomalyType { FOCUS = 'FOCUS', HAND_GESTURE_DETECTION = 'HAND_GESTURE_DETECTION', FACE_RECOGNITION = 'FACE_RECOGNITION', + LIVENESS = 'LIVENESS', + LOOKING_AWAY = 'LOOKING_AWAY', } export enum FileType { @@ -18,6 +20,31 @@ export enum FileType { AUDIO = 'AUDIO', } +/** + * Structured, explainable metadata attached to every anomaly violation. + * Designed so that an instructor reviewing flagged students can understand + * what was detected, how confident the system was, and for how long — + * without needing to know the internal detector implementation. + * + * Philosophy: "trust, not surveillance" — every flag must carry a + * plain-English reason, not a black-box label. + */ +export interface ViolationMetadata { + /** Plain-English explanation an instructor can read. */ + reason: string; + /** How long (ms) the signal was continuously present before firing. */ + durationMs?: number; + /** Number of consecutive frames that triggered the condition. */ + consecutiveFrames?: number; + /** + * Confidence of the signal (0–1). Kept separate from the binary + * violation flag so reviewers can weigh borderline cases. + */ + signalStrength?: number; + /** ISO 8601 timestamp of the first frame that triggered this violation. */ + detectedAt: string; +} + export interface IValidationResult { isValid: boolean; error?: string; @@ -57,6 +84,11 @@ export class IAnomalyData { createdAt: Date; cohortId?: string | ObjectId; cohortName?: string; + /** + * Optional structured explanation of why this anomaly was flagged. + * Omitted for anomaly types that predate this schema (backward-compatible). + */ + metadata?: ViolationMetadata; constructor(data: Partial, userId: string) { this.userId = new ObjectId(userId); @@ -68,6 +100,12 @@ export class IAnomalyData { if (data.cohortId) { this.cohortId = new ObjectId(data.cohortId); } + // Copy metadata when present so it's persisted to the DB alongside the + // anomaly record. Older anomaly types that don't send metadata are + // unaffected (field remains undefined → omitted from the document). + if (data.metadata) { + this.metadata = data.metadata; + } } } @@ -130,6 +168,20 @@ export class AnomalyStats { }) FACE_RECOGNITION: number; + @IsNumber() + @JSONSchema({ + title: 'Number of liveness anomalies', + description: 'Number of liveness detection anomalies (stillness or no blink detected)', + }) + LIVENESS: number; + + @IsNumber() + @JSONSchema({ + title: 'Number of looking-away anomalies', + description: 'Number of anomalies where the student sustained a head-turn away from the camera', + }) + LOOKING_AWAY: number; + constructor() { this.VOICE_DETECTION = 0; this.NO_FACE = 0; @@ -138,6 +190,8 @@ export class AnomalyStats { this.FOCUS = 0; this.HAND_GESTURE_DETECTION = 0; this.FACE_RECOGNITION = 0; + this.LIVENESS = 0; + this.LOOKING_AWAY = 0; } } diff --git a/backend/src/modules/anomalies/classes/validators/AnomalyValidators.ts b/backend/src/modules/anomalies/classes/validators/AnomalyValidators.ts index 98f663201..4b59aa969 100644 --- a/backend/src/modules/anomalies/classes/validators/AnomalyValidators.ts +++ b/backend/src/modules/anomalies/classes/validators/AnomalyValidators.ts @@ -10,7 +10,8 @@ import {JSONSchema} from 'class-validator-jsonschema'; // Import directly from the defining transformer rather than the module barrel // to avoid a circular dependency (the barrel re-exports controllers that pull // these validators back in, leaving AnomalyType undefined at eval time). -import {AnomalyType, FileType, IAnomalyData} from '../transformers/Anomaly.js'; +import { Transform } from 'class-transformer'; +import {AnomalyType, FileType, IAnomalyData, ViolationMetadata} from '../transformers/Anomaly.js'; import {ObjectId} from 'mongodb'; import { SortOrder, @@ -61,6 +62,23 @@ export class NewAnomalyData { @IsMongoId() @IsString() cohortId?: string | ObjectId; + + @JSONSchema({ + description: 'Optional explainable metadata explaining why this anomaly was triggered', + type: 'object', + }) + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; + }) + metadata?: ViolationMetadata; } export class AnomalyData extends NewAnomalyData implements IAnomalyData { diff --git a/backend/src/modules/anomalies/controllers/AnomalyController.ts b/backend/src/modules/anomalies/controllers/AnomalyController.ts index cb3e67403..f2d6d3e20 100644 --- a/backend/src/modules/anomalies/controllers/AnomalyController.ts +++ b/backend/src/modules/anomalies/controllers/AnomalyController.ts @@ -86,6 +86,22 @@ export class AnomalyController { } } + if ( + body.type === AnomalyType.LIVENESS || + body.type === AnomalyType.LOOKING_AWAY + ) { + const courseSetting = await this.courseSettingService.readCourseSettings( + courseId.toString(), + versionId.toString(), + ); + const detector = courseSetting?.settings?.proctors?.detectors?.find( + d => d.detectorName === ProctoringComponent.LIVENESSDETECTION, + ); + if (!detector?.settings?.enabled) { + throw new ForbiddenError('Liveness detection is disabled for this course'); + } + } + return this.anomalyService.recordAnomaly(userId, body, file, FileType.IMAGE); } diff --git a/backend/src/modules/anomalies/tests/Anomaly.test.ts b/backend/src/modules/anomalies/tests/Anomaly.test.ts new file mode 100644 index 000000000..4e5543a1e --- /dev/null +++ b/backend/src/modules/anomalies/tests/Anomaly.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import type { Face } from '@tensorflow-models/face-detection'; + +// Re-declare the isLookingAway function exactly as defined in FaceDetectors.tsx to run in Node environment +const isLookingAway = (face: Face): boolean => { + if (!face || face.keypoints.length < 6) return false; + + const rightEye = face.keypoints.find((p) => p.name === 'rightEye'); + const leftEye = face.keypoints.find((p) => p.name === 'leftEye'); + const noseTip = face.keypoints.find((p) => p.name === 'noseTip'); + const rightEar = face.keypoints.find((p) => p.name === 'rightEarTragion'); + const leftEar = face.keypoints.find((p) => p.name === 'leftEarTragion'); + + if (!rightEye || !leftEye || !noseTip || !face.box) return false; + + const faceWidth = face.box.width; + const faceHeight = face.box.height; + const eyeDistance = Math.abs(leftEye.x - rightEye.x) / faceWidth / Math.pow(faceHeight, 0.1) * 1.7; + const noseToLeftEye = Math.abs(noseTip.x - leftEye.x); + const noseToRightEye = Math.abs(noseTip.x - rightEye.x); + const noseRatio = Math.min(noseToLeftEye, noseToRightEye) / Math.max(noseToLeftEye, noseToRightEye) * Math.pow(faceHeight, 0.2) / Math.pow(200, 0.2); + + let earVisibilityRatio = 0; + if (!rightEar || !leftEar) earVisibilityRatio = 1; + else { + const rightEarDist = Math.abs(rightEar.x - rightEye.x); + const leftEarDist = Math.abs(leftEar.x - leftEye.x); + earVisibilityRatio = Math.min(rightEarDist, leftEarDist) / Math.max(rightEarDist, leftEarDist) * Math.pow(faceHeight, 0.3) / Math.pow(200, 0.3); + } + + if (eyeDistance < 0.35) return true; + if (noseRatio < 0.47) return true; + if (earVisibilityRatio < 0.47) return true; + + return false; +}; + +// Re-declare stillness and blink check math as defined in LivenessDetector.tsx +const checkStillness = (trail: { x: number; y: number }[], faceWidth: number): boolean => { + if (trail.length < 5) return false; + let maxDeviation = 0; + const originX = trail[0].x; + const originY = trail[0].y; + for (const sample of trail) { + const dx = (sample.x - originX) / faceWidth; + const dy = (sample.y - originY) / faceWidth; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > maxDeviation) maxDeviation = dist; + } + return maxDeviation < 0.01; // stillness threshold +}; + +const checkFreezeFrame = (eyeCoords: number[]): boolean => { + if (eyeCoords.length < 5) return false; + const mean = eyeCoords.reduce((a, b) => a + b, 0) / eyeCoords.length; + const variance = eyeCoords.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / eyeCoords.length; + return variance < 0.000002; // freeze frame/photo threshold +}; + +describe('Liveness Detection & Head-turn Math Tests', () => { + describe('isLookingAway Geometry Check', () => { + const createBaseFace = (): Face => ({ + box: { xMin: 100, yMin: 100, width: 200, height: 200 }, + keypoints: [ + { x: 150, y: 150, name: 'leftEye' }, + { x: 250, y: 150, name: 'rightEye' }, + { x: 200, y: 200, name: 'noseTip' }, + { x: 200, y: 250, name: 'mouthCenter' }, + { x: 120, y: 150, name: 'leftEarTragion' }, + { x: 280, y: 150, name: 'rightEarTragion' }, + ], + }); + + it('should return false when student is looking straight at the camera', () => { + const face = createBaseFace(); + expect(isLookingAway(face)).toBe(false); + }); + + it('should return true when eye distance is too narrow (turned sideways)', () => { + const face = createBaseFace(); + // Move eyes closer to simulate sideways perspective + face.keypoints[0].x = 180; + face.keypoints[1].x = 220; + expect(isLookingAway(face)).toBe(true); + }); + + it('should return true when nose is shifted off-center (horizontal head turn)', () => { + const face = createBaseFace(); + // Shift nose towards the left eye + face.keypoints[2].x = 160; + expect(isLookingAway(face)).toBe(true); + }); + + it('should return true when one ear is hidden or visibility ratio is low', () => { + const face = createBaseFace(); + // Move left ear extremely close to the left eye (simulating profiles where ear overlaps) + face.keypoints[4].x = 148; + expect(isLookingAway(face)).toBe(true); + }); + }); + + describe('Stillness Detection Math', () => { + it('should flag stillness if coordinates have virtually zero deviation', () => { + const trail = [ + { x: 200, y: 200 }, + { x: 200.5, y: 200.2 }, + { x: 200.2, y: 200.1 }, + { x: 200.1, y: 200.3 }, + { x: 200.3, y: 200.1 }, + ]; + expect(checkStillness(trail, 200)).toBe(true); + }); + + it('should NOT flag stillness if coordinates drift showing natural user movement', () => { + const trail = [ + { x: 200, y: 200 }, + { x: 204, y: 203 }, + { x: 208, y: 206 }, + { x: 211, y: 210 }, + { x: 215, y: 212 }, + ]; + expect(checkStillness(trail, 200)).toBe(false); + }); + }); + + describe('Freeze-frame / Static Photo Detection Math', () => { + it('should flag freeze-frame when coordinates do not change at all', () => { + const eyeCoords = [0.25, 0.25, 0.25, 0.25, 0.25]; + expect(checkFreezeFrame(eyeCoords)).toBe(true); + }); + + it('should NOT flag freeze-frame when coordinates show minor variance/sensor noise', () => { + const eyeCoords = [0.25, 0.252, 0.248, 0.253, 0.247, 0.251, 0.249]; + expect(checkFreezeFrame(eyeCoords)).toBe(false); + }); + }); +}); diff --git a/backend/src/modules/anomalies/tests/AnomalyController.test.ts b/backend/src/modules/anomalies/tests/AnomalyController.test.ts index 7d1a6429e..504819771 100644 --- a/backend/src/modules/anomalies/tests/AnomalyController.test.ts +++ b/backend/src/modules/anomalies/tests/AnomalyController.test.ts @@ -1,15 +1,15 @@ import 'reflect-metadata'; import request from 'supertest'; import Express from 'express'; -import { useContainer, useExpressServer } from 'routing-controllers'; +import { Action, useContainer, useExpressServer } from 'routing-controllers'; import { faker } from '@faker-js/faker'; -import { describe, it, beforeEach, beforeAll, expect, vi } from 'vitest'; +import { describe, it, beforeEach, beforeAll, expect, vi, afterAll } from 'vitest'; import { AnomalyController } from '../controllers/AnomalyController.js'; import { HttpErrorHandler, ItemType } from '#shared/index.js'; +import { MongoDatabase } from '#root/shared/database/providers/mongo/MongoDatabase.js'; import { InversifyAdapter } from '#root/inversify-adapter.js'; import { Container, ContainerModule } from 'inversify'; import { AuthController } from '#root/modules/auth/controllers/AuthController.js'; -import { createCourse, createVersion, createModule, createSection } from '#root/modules/courses/tests/utils/creationFunctions.js'; import { CreateItemBody } from '#root/modules/courses/classes/index.js'; import { CourseController } from '#root/modules/courses/controllers/CourseController.js'; import { CourseVersionController } from '#root/modules/courses/controllers/CourseVersionController.js'; @@ -22,9 +22,27 @@ import { coursesContainerModule } from '#root/modules/courses/container.js'; import { notificationsContainerModule } from '#root/modules/notifications/container.js'; import { quizzesContainerModule } from '#root/modules/quizzes/container.js'; import { usersContainerModule } from '#root/modules/users/container.js'; +import { courseRegistrationContainerModule } from '#root/modules/courseRegistration/container.js'; +import { settingContainerModule } from '#root/modules/setting/container.js'; import { anomaliesContainerModule } from '../container.js'; -import { AnomalyData, NewAnomalyData } from '../classes/validators/AnomalyValidators.js'; +import { NewAnomalyData } from '../classes/validators/AnomalyValidators.js'; import { AnomalyType } from '../classes/transformers/Anomaly.js'; +import { projectsContainerModule } from '#root/modules/projects/container.js'; +import { hpSystemContainerModule } from '#root/modules/hpSystem/container.js'; +import { reportsContainerModule } from '#root/modules/reports/container.js'; +import { ejectionPolicyContainerModule } from '#root/modules/ejectionPolicy/container.js'; +import { emotionsContainerModule } from '#root/modules/emotions/container.js'; +import { genAIContainerModule } from '#root/modules/genAI/container.js'; +import { studentQuestionsContainerModule } from '#root/modules/studentQuestions/container.js'; +import { announcementsContainerModule } from '#root/modules/announcements/container.js'; +import { auditTrailsContainerModule } from '#root/modules/auditTrails/container.js'; +import { GLOBAL_TYPES } from '#root/types.js'; +import { FirebaseAuthService } from '#root/modules/auth/services/FirebaseAuthService.js'; +import { EnrollmentService } from '#root/modules/users/services/EnrollmentService.js'; +import * as Current from '#root/shared/functions/currentUserChecker.js'; +import { MongoMemoryReplSet } from 'mongodb-memory-server'; +import { CloudStorageService } from '#root/modules/anomalies/services/CloudStorageService.js'; + const ContainerModules: ContainerModule[] = [ anomaliesContainerModule, coursesContainerModule, @@ -32,35 +50,214 @@ const ContainerModules: ContainerModule[] = [ authContainerModule, notificationsContainerModule, usersContainerModule, - quizzesContainerModule + quizzesContainerModule, + courseRegistrationContainerModule, + settingContainerModule, + projectsContainerModule, + hpSystemContainerModule, + reportsContainerModule, + ejectionPolicyContainerModule, + emotionsContainerModule, + genAIContainerModule, + studentQuestionsContainerModule, + announcementsContainerModule, + auditTrailsContainerModule ] + +// A minimal valid 1x1 PNG image buffer for upload tests const validImageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', 'base64',); + +// Local helpers with auth headers +async function localCreateCourse(app: any): Promise { + const body = { + name: faker.commerce.productName(), + description: faker.commerce.productDescription(), + versionName: 'v1.0.0', + versionDescription: 'Initial test version', + }; + const response = await request(app) + .post('/courses') + .set('Authorization', 'Bearer admin') + .send(body) + .expect(201); + return response.body; +} + + +async function localCreateModule(app: any, versionId: string): Promise { + const body = { + name: faker.commerce.productName(), + description: faker.commerce.productDescription(), + }; + const response = await request(app) + .post(`/courses/versions/${versionId}/modules`) + .set('Authorization', 'Bearer admin') + .send(body) + .expect(201); + return response.body; +} + +async function localCreateSection(app: any, versionId: string, moduleId: string): Promise { + const body = { + name: faker.commerce.productName(), + description: faker.commerce.productDescription(), + }; + const response = await request(app) + .post(`/courses/versions/${versionId}/modules/${moduleId}/sections`) + .set('Authorization', 'Bearer admin') + .send(body) + .expect(201); + return response.body; +} + describe('Anomaly Controller Integration Tests', () => { const appInstance = Express(); let app; let anomalyData: NewAnomalyData; + // Mock admin user for auth + const adminUser = { + _id: faker.database.mongodbObjectId(), + firebaseUID: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roles: 'admin', + }; + + // Mock student user for auth + const studentUser = { + _id: faker.database.mongodbObjectId(), + firebaseUID: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roles: 'user', + }; + + let currentUserCheckerSpy; + let db: MongoDatabase; + let mongoServer: MongoMemoryReplSet; + beforeAll(async () => { + process.env.NODE_ENV = 'test'; + + // 1. Start an in-memory MongoDB replica set (supports transactions) + mongoServer = await MongoMemoryReplSet.create(); + const mongoUri = mongoServer.getUri(); + + // 2. Load all DI container modules (this binds GLOBAL_TYPES.uri to null + // because dbConfig.url was read at import-time before DB_URL was set) const container = new Container(); await container.load(...ContainerModules); + + // 3. Rebind uri/dbName, then rebind Database so a fresh MongoDatabase is + // constructed with the correct URI. We MUST also rebind the Database + // binding because the old MongoDatabase singleton was already created + // with the null URI during container.load(). + container.unbind(GLOBAL_TYPES.uri); + container.bind(GLOBAL_TYPES.uri).toConstantValue(mongoUri); + + container.unbind(GLOBAL_TYPES.dbName); + container.bind(GLOBAL_TYPES.dbName).toConstantValue('vibe-test'); + + container.unbind(GLOBAL_TYPES.Database); + container.bind(GLOBAL_TYPES.Database).to(MongoDatabase).inSingletonScope(); + const inversifyAdapter = new InversifyAdapter(container); useContainer(inversifyAdapter); + + // 4. Connect to the in-memory database + db = container.get(GLOBAL_TYPES.Database); + + // Override the private client with a non-TLS MongoClient for in-memory server + const { MongoClient } = await import('mongodb'); + db['client'] = new MongoClient(mongoUri, { + ssl: false, + tls: false, + retryWrites: true, + maxPoolSize: 50, + minPoolSize: 10, + maxIdleTimeMS: 60000, + connectTimeoutMS: 20000, + socketTimeoutMS: 30000, + }); + + await db.connect(); + + // Mock currentUserChecker — maps Bearer tokens to mock users + currentUserCheckerSpy = vi.spyOn(Current, 'currentUserChecker').mockImplementation( + async (action: Action) => { + if (action.request.headers.authorization) { + const token = action.request.headers.authorization.split(' ')[1]; + if (token === 'admin') { + return adminUser; + } else if (token === 'student') { + return studentUser; + } + } + return adminUser; + } + ); + + // Mock FirebaseAuthService.getCurrentUserFromToken — the @Ability decorator + // calls this directly via getFromContainer, bypassing currentUserChecker. + vi.spyOn( + FirebaseAuthService.prototype, + 'getCurrentUserFromToken', + ).mockImplementation(async (token: string) => { + if (token === 'admin') { + return adminUser as any; + } else if (token === 'student') { + return studentUser as any; + } + return adminUser as any; + }); + + // Mock EnrollmentService.getAllEnrollments — the @Ability decorator fetches + // enrollments to build CASL abilities. Return empty array so the admin user + // falls through to globalRole-based abilities. + vi.spyOn( + EnrollmentService.prototype, + 'getAllEnrollments', + ).mockResolvedValue([]); + + // Mock CloudStorageService.prototype.uploadAnomaly to prevent hitting GCS + vi.spyOn( + CloudStorageService.prototype, + 'uploadAnomaly', + ).mockImplementation(async (file, userId, anomalyType, timestamp, type) => { + const ext = type.split('/')[1]; + return `${userId}/${anomalyType}/${timestamp.toISOString()}.${ext}`; + }); + app = useExpressServer(appInstance, { controllers: [AnomalyController, AuthController, CourseController, CourseVersionController, ModuleController, SectionController, ItemController], validation: true, defaultErrorHandler: false, middlewares: [HttpErrorHandler], authorizationChecker: () => true, // Mock authorization for testing + currentUserChecker: Current.currentUserChecker, // Use the spied function }); - const course = await createCourse(app); - const version = await createVersion(app, course._id.toString()); - const module = await createModule(app, version._id.toString()); - const section = await createSection( + + console.log('Creating course...'); + // --- Set up test data using local authenticated helpers --- + const course = await localCreateCourse(app); + const versionId = course.versions[0].toString(); + + console.log('Creating module...'); + const module = await localCreateModule(app, versionId); + console.log('Module created:', module.version.modules[0].moduleId); + + console.log('Creating section...'); + const section = await localCreateSection( app, - version._id.toString(), + versionId, module.version.modules[0].moduleId.toString(), ); + console.log('Section created:', section.version.modules[0].sections[0].sectionId); + const itemPayload: CreateItemBody = { name: faker.commerce.productName(), description: faker.commerce.productDescription(), @@ -82,67 +279,142 @@ describe('Anomaly Controller Integration Tests', () => { }, }; + console.log('Creating quiz item...'); const itemResponse = await request(app) .post( - `/courses/versions/${version._id}/modules/${module.version.modules[0].moduleId}/sections/${section.version.modules[0].sections[0].sectionId}/items`, + `/courses/versions/${versionId}/modules/${module.version.modules[0].moduleId}/sections/${section.version.modules[0].sections[0].sectionId}/items`, ) + .set('Authorization', 'Bearer admin') .send(itemPayload); expect(itemResponse.status).toBe(201); expect(itemResponse.body.itemsGroup.items.length).toBe(1); - // create a valid user - const user = await request(app) - .post('/auth/signup') - .send({ - email: faker.internet.email(), - password: faker.internet.password(), - firstName: faker.person.firstName('male').replace(/[^a-zA-Z]/g, ''), - lastName: faker.person.lastName().replace(/[^a-zA-Z]/g, ''), - }) - .expect(201); - const userId = user.body.userId; - // Arrange + console.log('Quiz item created:', itemResponse.body.itemsGroup.items[0]._id); + + // Arrange anomaly data for the tests anomalyData = { courseId: course._id.toString(), - versionId: version._id.toString(), + versionId: versionId, itemId: itemResponse.body.itemsGroup.items[0]._id.toString(), type: AnomalyType.VOICE_DETECTION, }; - }, 30000); + console.log('beforeAll finished successfully'); + }, 60000); beforeEach(() => { + // Clear call counts but preserve the mock implementations + currentUserCheckerSpy.mockClear(); + }); + + afterAll(async () => { vi.restoreAllMocks(); + // Install a temporary rejection handler so the async MongoClientClosedError + // that the driver emits while draining the connection pool (after the in-memory + // mongod is stopped) does not surface as an unhandled rejection in Vitest. + const suppressMongoClose = (err: Error) => { + // Suppress all errors that are expected during replica-set shutdown: + // - MongoClientClosedError — pool drain after client.close() + // - MongoWriteConcernError — ShutdownInProgress (code 91) + // - MongoServerError — InterruptedAtShutdown (code 11600) + const name = err?.constructor?.name ?? ''; + const isMongoShutdownErr = + name === 'MongoClientClosedError' || + name === 'MongoWriteConcernError' || + (name === 'MongoServerError' && (err as any).code === 11600); + if (isMongoShutdownErr) return; + throw err; + }; + process.on('unhandledRejection', suppressMongoClose); + try { + // Stop the server first — this kills the underlying mongod process. + if (mongoServer) await mongoServer.stop(); + // Then tell the client to close; it will race the pool drain and may throw. + if (db) { try { await db.disconnect(); } catch { /* expected */ } } + } finally { + process.off('unhandledRejection', suppressMongoClose); + } }); describe('ANOMALY RECORDING', () => { describe('Success Scenario', () => { - it('should record an anomaly with a valid image and data', async () => { - // Act + it('should record an anomaly with a valid image and data (no metadata)', async () => { + // Act — note the correct route is /anomalies/record/image (not /anomalies/record) const response = await request(app) - .post('/anomalies/record') - .field('data', JSON.stringify(anomalyData)) + .post('/anomalies/record/image') + .set('Authorization', 'Bearer admin') + .field('courseId', anomalyData.courseId.toString()) + .field('versionId', anomalyData.versionId.toString()) + .field('itemId', anomalyData.itemId.toString()) + .field('type', anomalyData.type) .attach('image', validImageBuffer, 'test-image.jpg') .expect(201); // Assert - expect(response.body.success).toBe(true); - expect(response.body.hexId).toBeDefined(); - expect(response.body.message).toBe( - 'Anomaly recorded successfully with compressed & encrypted image', - ); + expect(response.body._id).toBeDefined(); + expect(response.body.userId).toBe(adminUser._id.toString()); + expect(response.body.courseId).toBe(anomalyData.courseId); + expect(response.body.versionId).toBe(anomalyData.versionId); + expect(response.body.itemId).toBe(anomalyData.itemId); + expect(response.body.type).toBe(anomalyData.type); + expect(response.body.metadata).toBeUndefined(); + }, 60000); + + it('should record an anomaly with metadata and round-trip successfully', async () => { + const metadata = { + reason: 'No eye blink detected for 15 seconds', + durationMs: 15000, + consecutiveFrames: 45, + signalStrength: 0.95, + detectedAt: new Date().toISOString(), + }; + + const response = await request(app) + .post('/anomalies/record/image') + .set('Authorization', 'Bearer admin') + .field('courseId', anomalyData.courseId.toString()) + .field('versionId', anomalyData.versionId.toString()) + .field('itemId', anomalyData.itemId.toString()) + .field('type', anomalyData.type) + .field('metadata', JSON.stringify(metadata)) + .attach('image', validImageBuffer, 'test-image.jpg') + .expect(201); + + // Assert + expect(response.body._id).toBeDefined(); + expect(response.body.metadata).toBeDefined(); + expect(response.body.metadata.reason).toBe(metadata.reason); + expect(response.body.metadata.durationMs).toBe(metadata.durationMs); + expect(response.body.metadata.consecutiveFrames).toBe(metadata.consecutiveFrames); + expect(response.body.metadata.signalStrength).toBe(metadata.signalStrength); + expect(response.body.metadata.detectedAt).toBe(metadata.detectedAt); }, 60000); }); describe('Error Scenarios', () => { - it('should return 500 for invalid image data', async () => { - // Act + it('should return 400 when required body fields are missing', async () => { + // Send an image but omit all required body fields — class-validator should + // reject the request with 400 Bad Request. + const response = await request(app) + .post('/anomalies/record/image') + .set('Authorization', 'Bearer admin') + .attach('image', validImageBuffer, 'test-image.jpg') + .expect(400); + + // Assert — routing-controllers wraps validation errors as "Invalid body" + expect(response.body.message).toMatch(/invalid body/i); + }); + + it('should return 400 when an invalid anomaly type is supplied', async () => { const response = await request(app) - .post('/anomalies/record') - .field('data', JSON.stringify(anomalyData)) - .attach('image', Buffer.from('fake-image-data'), 'test-image.jpg') - .expect(500); + .post('/anomalies/record/image') + .set('Authorization', 'Bearer admin') + .field('courseId', anomalyData.courseId.toString()) + .field('versionId', anomalyData.versionId.toString()) + .field('itemId', anomalyData.itemId.toString()) + .field('type', 'NOT_A_VALID_TYPE') + .attach('image', validImageBuffer, 'test-image.jpg') + .expect(400); - // Assert - expect(response.body.message).toContain('Input buffer contains unsupported image format'); + expect(response.body.message).toMatch(/invalid body/i); }); }); }); diff --git a/backend/src/modules/courses/services/CourseService.ts b/backend/src/modules/courses/services/CourseService.ts index c41593cd3..b90024f8c 100644 --- a/backend/src/modules/courses/services/CourseService.ts +++ b/backend/src/modules/courses/services/CourseService.ts @@ -20,10 +20,10 @@ import {InviteService} from '#root/modules/notifications/index.js'; import {NOTIFICATIONS_TYPES} from '#root/modules/notifications/types.js'; import {SETTING_TYPES} from '#root/modules/setting/types.js'; import { - CourseSetting, CourseSettingService, CreateCourseSettingBody, } from '#root/modules/setting/index.js'; +import { CourseSetting } from '#root/modules/setting/classes/transformers/CourseSetting.js'; @injectable() class CourseService extends BaseService { constructor( diff --git a/backend/src/shared/database/interfaces/ISettingRepository.ts b/backend/src/shared/database/interfaces/ISettingRepository.ts index 8bebb9cc3..9553b6f0c 100644 --- a/backend/src/shared/database/interfaces/ISettingRepository.ts +++ b/backend/src/shared/database/interfaces/ISettingRepository.ts @@ -29,6 +29,7 @@ export enum ProctoringComponent { VIRTUALBACKGROUNDDETECTION = 'virtualBackgroundDetection', // virtualBackgroundDetection RIGHTCLICKDISABLED = 'rightClickDisabled', // rightClickDisabled FACERECOGNITION = 'faceRecognition', // faceRecognition + LIVENESSDETECTION = 'livenessDetection', // livenessDetection } /** diff --git a/frontend/src/app/pages/teacher/AnomaliesList.tsx b/frontend/src/app/pages/teacher/AnomaliesList.tsx index ee18a956b..9547bef05 100644 --- a/frontend/src/app/pages/teacher/AnomaliesList.tsx +++ b/frontend/src/app/pages/teacher/AnomaliesList.tsx @@ -19,6 +19,7 @@ import { DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button" +import { ViolationExplanationBadge } from "@/components/ViolationExplanationBadge" export default function AnomaliesList() { @@ -36,7 +37,6 @@ export default function AnomaliesList() { const { data: version, isLoading: versionLoading, error: versionError } = useCourseVersionById(versionId || "") const [cohort, setCohort] = useState(null); - // Anomaly types for filter dropdown const anomalyTypes = [ { value: 'ALL', label: 'All Types' }, { value: 'MULTIPLE_FACES', label: 'Multiple Faces' }, @@ -46,6 +46,8 @@ export default function AnomaliesList() { { value: 'FACE_RECOGNITION', label: 'Face Recognition' }, { value: 'HAND_GESTURE_DETECTION', label: 'Hand Gesture' }, { value: 'BLUR_DETECTION', label: 'Blur Detection' }, + { value: 'LIVENESS', label: 'Liveness Issues' }, + { value: 'LOOKING_AWAY', label: 'Looking Away' }, ]; // Debounce search input @@ -90,7 +92,10 @@ export default function AnomaliesList() { const getTypeBadge = (type: string) => { switch (type) { - case 'MULTIPLE_FACES': + case 'LIVENESS': + return Liveness; + case 'LOOKING_AWAY': + return Looking Away; default: return {type} } @@ -250,6 +255,7 @@ export default function AnomaliesList() { Student Type + Details handleSort('createdAt')} @@ -266,7 +272,7 @@ export default function AnomaliesList() { {(isLoading || isSearching) ? ( - +
@@ -277,7 +283,7 @@ export default function AnomaliesList() { ) : error ? ( - +

{error}

@@ -294,7 +300,7 @@ export default function AnomaliesList() { ) : anomalies.length === 0 ? ( - + No anomalies found @@ -317,6 +323,9 @@ export default function AnomaliesList() {
{getTypeBadge(anomaly.type)} + + + {new Date(anomaly.createdAt).toLocaleDateString('en-GB') + ' ' + new Date(anomaly.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })} diff --git a/frontend/src/components/EditProctoringModal.tsx b/frontend/src/components/EditProctoringModal.tsx index 78e97b3d4..307523c1a 100644 --- a/frontend/src/components/EditProctoringModal.tsx +++ b/frontend/src/components/EditProctoringModal.tsx @@ -32,6 +32,7 @@ enum ProctoringComponent { VIRTUALBACKGROUNDDETECTION = 'virtualBackgroundDetection', RIGHTCLICKDISABLED = 'rightClickDisabled', FACERECOGNITION = 'faceRecognition', + LIVENESSDETECTION = 'livenessDetection', } // Throw-safe id conversion: ObjectIds may serialize as a hex string or a @@ -65,6 +66,7 @@ const labelMap: Record = { virtualBackgroundDetection: "Virtual Background Detection", rightClickDisabled: "Right Click Disabled", faceRecognition: "Face Recognition", + livenessDetection: "Liveness Detection", } export function ProctoringModal({ diff --git a/frontend/src/components/ViolationExplanationBadge.tsx b/frontend/src/components/ViolationExplanationBadge.tsx new file mode 100644 index 000000000..b77396618 --- /dev/null +++ b/frontend/src/components/ViolationExplanationBadge.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ViolationMetadata } from "@/types/reportanomaly.types"; +import { Info } from "lucide-react"; + +interface ViolationExplanationBadgeProps { + metadata?: ViolationMetadata; +} + +export const ViolationExplanationBadge: React.FC = ({ metadata }) => { + if (!metadata || !metadata.reason) { + return —; + } + + const confidence = metadata.signalStrength ?? 1.0; + + // Calculate severity color + let badgeColor = "bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100"; + let severityLabel = "Low Confidence"; + + if (confidence >= 0.85) { + badgeColor = "bg-red-100 text-red-800 border-red-200 hover:bg-red-100"; + severityLabel = "High Confidence"; + } else if (confidence >= 0.5) { + badgeColor = "bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100"; + severityLabel = "Medium Confidence"; + } + + return ( + + + +
+ + {severityLabel} + + +
+
+ +

Explainable Log Details

+

+ {metadata.reason} +

+
+ {metadata.durationMs !== undefined && ( + <> + Duration: + {(metadata.durationMs / 1000).toFixed(1)}s + + )} + {metadata.consecutiveFrames !== undefined && ( + <> + Frames: + {metadata.consecutiveFrames} frames + + )} + {metadata.signalStrength !== undefined && ( + <> + Signal Strength: + {(metadata.signalStrength * 100).toFixed(0)}% + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/ai/FaceDetectors.tsx b/frontend/src/components/ai/FaceDetectors.tsx index 36bcb37ac..606ec9e4d 100644 --- a/frontend/src/components/ai/FaceDetectors.tsx +++ b/frontend/src/components/ai/FaceDetectors.tsx @@ -1,11 +1,12 @@ import React, { useEffect } from "react"; import { Face, Keypoint } from "@tensorflow-models/face-detection"; import FaceRecognitionComponent from "./FaceRecognitionComponent"; +import LivenessDetector from "./LivenessDetector"; import type { FaceDetectorsProps, FaceRecognition, FaceRecognitionDebugInfo } from "@/types/ai.types"; import { eye } from "@tensorflow/tfjs-core"; -const isLookingAway = (face: Face): boolean => { +export const isLookingAway = (face: Face): boolean => { if (!face || face.keypoints.length < 6) return false; const rightEye = face.keypoints.find((p: Keypoint) => p.name === "rightEye"); @@ -46,7 +47,7 @@ const isLookingAway = (face: Face): boolean => { return false; }; -const FaceDetectors: React.FC = ({ setIsFocused, faces, videoRef, onRecognitionResult, onDebugInfoUpdate, onMismatchChange, onMissingEmbedding, settings }) => { +const FaceDetectors: React.FC = ({ setIsFocused, faces, videoRef, onRecognitionResult, onDebugInfoUpdate, onMismatchChange, onMissingEmbedding, onLivenessViolation, settings }) => { useEffect(() => { const isFocused = true; @@ -77,6 +78,13 @@ const FaceDetectors: React.FC = ({ setIsFocused, faces, vide enabled={settings.isFaceRecognitionEnabled} /> )} + {settings?.isLivenessDetectionEnabled && onLivenessViolation && ( + + )} ); }; diff --git a/frontend/src/components/ai/LivenessDetector.tsx b/frontend/src/components/ai/LivenessDetector.tsx new file mode 100644 index 000000000..84d35443d --- /dev/null +++ b/frontend/src/components/ai/LivenessDetector.tsx @@ -0,0 +1,172 @@ +import { useEffect, useRef } from "react"; +import type { Face } from "@tensorflow-models/face-detection"; +import type { ViolationMetadata } from "@/types/reportanomaly.types"; +import { AnomalyType } from "@/types/reportanomaly.types"; +import { isLookingAway } from "./FaceDetectors"; + +/** + * LivenessDetector + * ----------------- + * Consumes the `faces[]` array already produced by useCameraProcessor + * (via FaceDetectorWorker, ~3fps by default) and derives two liveness + * signals from it: + * + * 1. STILLNESS — tracks the noseTip keypoint's position over a rolling + * window. If it barely moves for a sustained period, that's consistent + * with a static image held up to the camera rather than a live person. + * + * 2. LOOKING_AWAY — reuses the existing (previously unused) isLookingAway() + * geometry check from FaceDetectors.tsx. If sustained, fires a + * LOOKING_AWAY violation. + * + * NOTE ON BLINK DETECTION: the model currently used + * (faceDetection.SupportedModels.MediaPipeFaceDetector, tfjs runtime) only + * returns 6 sparse keypoints (eyes, nose, mouth, ears) - it does NOT expose + * eyelid/eye-contour landmarks, so a genuine eye-openness ratio cannot be + * computed from this data. Implementing real blink detection would require + * swapping to a face-mesh model (e.g. MediaPipeFaceMesh with + * refineLandmarks) which is a heavier, separate change with its own + * performance tradeoffs. Deliberately NOT faking this here - flagged as a + * follow-up rather than shipped as a silently-broken signal. + * + * Philosophy: "trust, not surveillance" - a violation only fires after a + * sustained number of consecutive bad frames, never on a single frame, and + * every violation carries a plain-English `reason`. + */ + +const STILLNESS_WINDOW_MS = 12_000; // must be still for this long to flag +const LOOKING_AWAY_WINDOW_MS = 5_000; // must look away for this long to flag +const STILLNESS_MOVEMENT_THRESHOLD = 0.01; // normalized (fraction of face width) movement considered "no movement" +const MIN_CONSECUTIVE_FRAMES = 8; // ~2.5s at the default 3fps frame rate +const VIOLATION_COOLDOWN_MS = 60_000; // don't refire the same type more than once/minute + +interface LivenessDetectorProps { + faces: Face[]; + enabled: boolean; + onViolation: ( + type: AnomalyType.LIVENESS | AnomalyType.LOOKING_AWAY, + metadata: ViolationMetadata, + ) => void; +} + +interface PositionSample { + x: number; + y: number; + t: number; +} + +const LivenessDetector: React.FC = ({ + faces, + enabled, + onViolation, +}) => { + const noseTrailRef = useRef([]); + const lookingAwaySinceRef = useRef(null); + const lookingAwayFrameCountRef = useRef(0); + const lastLivenessFireRef = useRef(0); + const lastLookingAwayFireRef = useRef(0); + const stillnessFiredAtStartRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + const now = Date.now(); + const face = faces[0]; // liveness only evaluates the primary detected face + + if (!face) { + // No face at all is already handled by the existing NO_FACE detector - + // reset our local trackers so a re-appearing face starts clean. + noseTrailRef.current = []; + lookingAwaySinceRef.current = null; + lookingAwayFrameCountRef.current = 0; + return; + } + + // ---- Signal 1: stillness (noseTip drift over a rolling window) ---- + const noseTip = face.keypoints.find((p) => p.name === "noseTip"); + if (noseTip && face.box) { + const faceWidth = face.box.width || 1; + const trail = noseTrailRef.current; + trail.push({ x: noseTip.x, y: noseTip.y, t: now }); + + // Drop samples older than the stillness window. + while (trail.length && now - trail[0].t > STILLNESS_WINDOW_MS) { + trail.shift(); + } + + // Only evaluate once we actually have a full window of samples. + const windowSpanMs = trail.length ? now - trail[0].t : 0; + if (windowSpanMs >= STILLNESS_WINDOW_MS) { + let maxDeviation = 0; + const originX = trail[0].x; + const originY = trail[0].y; + for (const sample of trail) { + const dx = (sample.x - originX) / faceWidth; + const dy = (sample.y - originY) / faceWidth; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > maxDeviation) maxDeviation = dist; + } + + if (maxDeviation < STILLNESS_MOVEMENT_THRESHOLD) { + if (stillnessFiredAtStartRef.current === null) { + stillnessFiredAtStartRef.current = trail[0].t; + } + if (now - lastLivenessFireRef.current > VIOLATION_COOLDOWN_MS) { + lastLivenessFireRef.current = now; + onViolation(AnomalyType.LIVENESS, { + reason: `No meaningful movement detected for ${Math.round( + windowSpanMs / 1000, + )} seconds`, + durationMs: windowSpanMs, + consecutiveFrames: trail.length, + signalStrength: Math.max( + 0, + Math.min(1, 1 - maxDeviation / STILLNESS_MOVEMENT_THRESHOLD), + ), + detectedAt: new Date( + stillnessFiredAtStartRef.current ?? now, + ).toISOString(), + }); + } + } else { + stillnessFiredAtStartRef.current = null; + } + } + } + + // ---- Signal 2: sustained looking-away ---- + const lookingAway = isLookingAway(face); + if (lookingAway) { + if (lookingAwaySinceRef.current === null) { + lookingAwaySinceRef.current = now; + } + lookingAwayFrameCountRef.current += 1; + + const sustainedMs = now - lookingAwaySinceRef.current; + if ( + sustainedMs >= LOOKING_AWAY_WINDOW_MS && + lookingAwayFrameCountRef.current >= MIN_CONSECUTIVE_FRAMES && + now - lastLookingAwayFireRef.current > VIOLATION_COOLDOWN_MS + ) { + lastLookingAwayFireRef.current = now; + onViolation(AnomalyType.LOOKING_AWAY, { + reason: `Face turned away from the camera for ${Math.round( + sustainedMs / 1000, + )} seconds`, + durationMs: sustainedMs, + consecutiveFrames: lookingAwayFrameCountRef.current, + detectedAt: new Date(lookingAwaySinceRef.current).toISOString(), + }); + } + } else { + lookingAwaySinceRef.current = null; + lookingAwayFrameCountRef.current = 0; + } + }, [faces, enabled, onViolation]); + + // Purely a side-effect component - it emits violations via onViolation + // rather than rendering anything. + return null; +}; + +export default LivenessDetector; diff --git a/frontend/src/components/ai/useViolationReporter.ts b/frontend/src/components/ai/useViolationReporter.ts new file mode 100644 index 000000000..2d4f72b7c --- /dev/null +++ b/frontend/src/components/ai/useViolationReporter.ts @@ -0,0 +1,101 @@ +import { useCallback, useRef } from "react"; +import { useReportAnomalyImage } from "@/hooks/hooks"; +import { useCourseStore } from "@/store/course-store"; +import { AnomalyType, type ViolationMetadata } from "@/types/reportanomaly.types"; + +/** + * useViolationReporter + * --------------------- + * Thin adapter between a detector (e.g. LivenessDetector) and the existing + * `POST /anomalies/record/image` endpoint via useReportAnomalyImage(), the + * same mutation floating-video.tsx already uses for NO_FACE / MULTIPLE_FACES + * / BLUR_DETECTION. Because `metadata` was added to NewAnomalyData in the + * schema PR, no backend/hook changes are needed here - this only adds a + * frame-capture + submit convenience wrapper for the two new violation + * types. + * + * Cooldown/debounce logic intentionally lives in the calling detector (it + * already has the relevant timing state); this hook's only job is + * "capture a frame + send what I'm told to send". + * + * NOTE: the dataURL->File conversion below duplicates a small helper that + * currently lives inline inside floating-video.tsx. Extracting a single + * shared `frontend/src/utils/screenshot.ts` would be a good, separate, + * small cleanup PR - not bundled here to keep this change focused. + */ + +function dataUrlToFile(dataUrl: string, filename: string): File { + const [header, base64] = dataUrl.split(","); + const mimeMatch = header.match(/:(.*?);/); + const mime = mimeMatch ? mimeMatch[1] : "image/png"; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new File([bytes], filename, { type: mime }); +} + +export function useViolationReporter( + videoRef: React.RefObject, +) { + const reportImage = useReportAnomalyImage(); + const courseStore = useCourseStore(); + const inFlightRef = useRef(false); + + const reportViolation = useCallback( + async ( + type: AnomalyType.LIVENESS | AnomalyType.LOOKING_AWAY, + metadata: ViolationMetadata, + ) => { + // Simple re-entrancy guard - the detector already cools down per + // violation type, this just prevents overlapping in-flight requests + // if two different signals fire back-to-back. + if (inFlightRef.current) return; + + const video = videoRef.current; + if (!video || video.videoWidth === 0 || video.videoHeight === 0) { + console.log("Video not ready, skipping liveness violation report"); + return; + } + + const { courseId, versionId, itemId } = courseStore.currentCourse ?? {}; + if (!courseId || !versionId || !itemId) { + console.log("Missing course data, skipping liveness violation report"); + return; + } + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const snapshot = canvas.toDataURL("image/png"); + const imageFile = dataUrlToFile(snapshot, `${type.toLowerCase()}.png`); + + inFlightRef.current = true; + try { + await reportImage.mutateAsync({ + body: { + type, + courseId, + versionId, + itemId, + metadata, + }, + file: imageFile, + }); + } catch (error) { + console.log(`Error while reporting ${type} violation:`, error); + } finally { + inFlightRef.current = false; + } + }, + [videoRef, courseStore, reportImage], + ); + + return { reportViolation }; +} + +export default useViolationReporter; diff --git a/frontend/src/components/floating-video.tsx b/frontend/src/components/floating-video.tsx index 268338143..5d102198f 100644 --- a/frontend/src/components/floating-video.tsx +++ b/frontend/src/components/floating-video.tsx @@ -19,6 +19,7 @@ import { runProctoringChecks } from "@/utils/proctoring/proctoringGuard"; import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { FaceRegistrationModal } from './ai/FaceRegistrationModal'; +import { useViolationReporter } from './ai/useViolationReporter'; // let flag = 0; function FloatingVideo({ @@ -127,6 +128,7 @@ function FloatingVideo({ const isFaceRecognitionEnabled = isComponentEnabled('faceRecognition'); const isRighClickDisabled = isComponentEnabled("rightClickDisabled"); const isFocusEnabled = false; //isComponentEnabled('focus'); + const isLivenessDetectionEnabled = isComponentEnabled('livenessDetection'); // Log enabled components for debugging // useEffect(() => { @@ -224,6 +226,7 @@ function FloatingVideo({ // Image record anomaly effect const reportImage = useReportAnomalyImage(); +const { reportViolation: reportLivenessViolation } = useViolationReporter(videoRef); const lastCalledRef = useRef(0); useEffect(() => { const handleImageAnomaly = async () => { @@ -1454,7 +1457,7 @@ const lastCalledRef = useRef(0); trigger={true} /> )} - {(isFaceCountDetectionEnabled || isFaceRecognitionEnabled || isFocusEnabled) && ( + {(isFaceCountDetectionEnabled || isFaceRecognitionEnabled || isFocusEnabled || isLivenessDetectionEnabled) && ( (0); setReadyToDetect(false); setShowFaceRegisterModal(true); }} + onLivenessViolation={reportLivenessViolation} settings={{ isFaceCountDetectionEnabled, isFaceRecognitionEnabled, - isFocusEnabled + isFocusEnabled, + isLivenessDetectionEnabled }} /> diff --git a/frontend/src/types/ai.types.ts b/frontend/src/types/ai.types.ts index 70a9e641d..4fc472b61 100644 --- a/frontend/src/types/ai.types.ts +++ b/frontend/src/types/ai.types.ts @@ -1,4 +1,5 @@ import { Face, Keypoint } from "@tensorflow-models/face-detection"; +import { AnomalyType, type ViolationMetadata } from "@/types/reportanomaly.types"; export interface BlurDetectionProps { videoRef: React.RefObject; @@ -13,10 +14,12 @@ export interface FaceDetectorsProps { onDebugInfoUpdate?: (debugInfo: FaceRecognitionDebugInfo) => void; onMismatchChange?: (hasMismatch: boolean) => void; onMissingEmbedding?: () => void; + onLivenessViolation?: (type: AnomalyType.LIVENESS | AnomalyType.LOOKING_AWAY, metadata: ViolationMetadata) => void; settings:{ isFaceCountDetectionEnabled:boolean, isFaceRecognitionEnabled:boolean, - isFocusEnabled: boolean + isFocusEnabled: boolean, + isLivenessDetectionEnabled?: boolean, } } diff --git a/frontend/src/types/reportanomaly.types.ts b/frontend/src/types/reportanomaly.types.ts index 7c5373bb3..3ff97a6ea 100644 --- a/frontend/src/types/reportanomaly.types.ts +++ b/frontend/src/types/reportanomaly.types.ts @@ -8,8 +8,9 @@ export enum AnomalyType { FOCUS = 'focus', HAND_GESTURE_DETECTION = 'handGestureDetection', FACE_RECOGNITION = 'faceRecognition', - VIRTUAL_CAMERA = 'VIRTUAL_CAMERA', + LIVENESS = 'LIVENESS', + LOOKING_AWAY = 'LOOKING_AWAY', } export enum FileType { @@ -17,12 +18,21 @@ export enum FileType { AUDIO = 'AUDIO', } +export interface ViolationMetadata { + reason: string; + durationMs?: number; + consecutiveFrames?: number; + signalStrength?: number; + detectedAt: string; +} + export interface NewAnomalyData { type: AnomalyType; courseId: string; versionId: string; itemId: string; cohortId?: string; + metadata?: ViolationMetadata; } export interface AnomalyData extends NewAnomalyData { @@ -32,6 +42,7 @@ export interface AnomalyData extends NewAnomalyData { fileType?: FileType; createdAt: string; cohortName?: string; + metadata?: ViolationMetadata; } export interface GetCourseAnomalyParams {