diff --git a/backend/src/main.py b/backend/src/main.py index b09d416..615ff36 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -32,6 +32,14 @@ class FaceVerifyRequest(BaseModel): class ResolveAlertRequest(BaseModel): conflict_id: str approved: bool = False + reviewer_id: Optional[str] = None + reason: Optional[str] = None + +class AdminReviewRequest(BaseModel): + conflict_id: str + approved: bool + reviewer_id: str + reason: str # Initialize the main orchestrator agent main_agent = MainAgent() @@ -60,9 +68,37 @@ async def get_pending_alerts(): @app.post("/face/resolve-alert") async def resolve_alert(request: ResolveAlertRequest): - success = face_service.resolve_alert(request.conflict_id, request.approved) + success = face_service.resolve_alert( + request.conflict_id, + request.approved, + request.reviewer_id, + request.reason + ) return {"success": success} +@app.get("/face/conflict/{conflict_id}") +async def get_conflict_details(conflict_id: str): + details = face_service.get_conflict_details(conflict_id) + if details is None: + return {"error": "Conflict not found"}, 404 + return details + +@app.post("/admin/review-conflict") +async def admin_review_conflict(request: AdminReviewRequest): + success = face_service.admin_review_conflict( + request.conflict_id, + request.approved, + request.reviewer_id, + request.reason + ) + if not success: + return {"error": "Conflict not found"}, 404 + return {"success": True, "message": "Review decision recorded"} + +@app.get("/admin/override-log") +async def get_override_log(): + return face_service.get_override_log() + @app.post("/analyze") async def analyze_repo(request: AnalyzeRequest): # Legacy REST endpoint for backward compatibility diff --git a/backend/src/models/events.py b/backend/src/models/events.py index 2d00ce1..ddd6e67 100644 --- a/backend/src/models/events.py +++ b/backend/src/models/events.py @@ -21,6 +21,12 @@ class EventType(str, Enum): MANUAL_REVIEW_REQUIRED = "manual_review.required" ACCESS_DENIED_CONFLICT = "access.denied_conflict" + # Admin Review Events + ADMIN_REVIEW_STARTED = "admin_review.started" + ADMIN_REVIEW_APPROVED = "admin_review.approved" + ADMIN_REVIEW_REJECTED = "admin_review.rejected" + ADMIN_OVERRIDE_ACTION = "admin_override.action" + # ORACLE Events FILE_RECEIVED = "file_received" PDF_PARSED = "pdf_parsed" diff --git a/backend/src/services/face_detection.py b/backend/src/services/face_detection.py index 8341c46..2829513 100644 --- a/backend/src/services/face_detection.py +++ b/backend/src/services/face_detection.py @@ -26,6 +26,9 @@ class ConflictAlert: timestamp: datetime status: str = "pending_review" session_id: Optional[str] = None + reviewer_id: Optional[str] = None + review_timestamp: Optional[datetime] = None + review_reason: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return { @@ -36,6 +39,9 @@ def to_dict(self) -> Dict[str, Any]: "timestamp": self.timestamp.isoformat(), "status": self.status, "session_id": self.session_id, + "reviewer_id": self.reviewer_id, + "review_timestamp": self.review_timestamp.isoformat() if self.review_timestamp else None, + "review_reason": self.review_reason, } @@ -124,10 +130,13 @@ def get_conflicts_for_roll(self, roll_number: str) -> List[ConflictAlert]: def get_pending_alerts(self) -> List[ConflictAlert]: return [alert for alert in self.alerts if alert.status == "pending_review"] - def resolve_alert(self, conflict_id: str, approved: bool) -> bool: + def resolve_alert(self, conflict_id: str, approved: bool, reviewer_id: Optional[str] = None, reason: Optional[str] = None) -> bool: for alert in self.alerts: if alert.conflict_id == conflict_id: alert.status = "approved" if approved else "rejected" + alert.reviewer_id = reviewer_id + alert.review_timestamp = datetime.now(timezone.utc) + alert.review_reason = reason return True return False @@ -147,4 +156,42 @@ def can_grant_access(self, roll_number: str) -> Tuple[bool, Optional[str]]: for conflict in conflicts: if conflict.status == "pending_review": return False, f"Identity under review: conflict {conflict.conflict_id}" - return True, None \ No newline at end of file + return True, None + + def get_conflict_details(self, conflict_id: str) -> Optional[Dict[str, Any]]: + for alert in self.alerts: + if alert.conflict_id == conflict_id: + prior_embeddings = [] + for roll in alert.matched_roll_numbers: + if roll in self.embeddings: + emb = self.embeddings[roll] + prior_embeddings.append({ + "roll_number": emb.roll_number, + "student_name": emb.student_name, + "timestamp": emb.timestamp.isoformat(), + "session_id": emb.session_id, + }) + return { + "conflict": alert.to_dict(), + "prior_embeddings": prior_embeddings, + } + return None + + def admin_review_conflict(self, conflict_id: str, approved: bool, reviewer_id: str, reason: str) -> bool: + for alert in self.alerts: + if alert.conflict_id == conflict_id: + alert.status = "approved" if approved else "rejected" + alert.reviewer_id = reviewer_id + alert.review_timestamp = datetime.now(timezone.utc) + alert.review_reason = reason + return True + return False + + def get_override_log(self) -> List[Dict[str, Any]]: + return [alert.to_dict() for alert in self.alerts if alert.status in ("approved", "rejected")] + + def get_alert_by_id(self, conflict_id: str) -> Optional[ConflictAlert]: + for alert in self.alerts: + if alert.conflict_id == conflict_id: + return alert + return None \ No newline at end of file diff --git a/backend/tests/test_face_detection.py b/backend/tests/test_face_detection.py index 7a098d4..d392815 100644 --- a/backend/tests/test_face_detection.py +++ b/backend/tests/test_face_detection.py @@ -107,10 +107,12 @@ def test_resolve_alert(self): service.verify_identity(emb1, "R001") _, alert, _ = service.verify_identity(emb2, "R002") - result = service.resolve_alert(alert.conflict_id, approved=True) + result = service.resolve_alert(alert.conflict_id, approved=True, reviewer_id="admin-1", reason="test resolution") assert result is True assert alert.status == "approved" + assert alert.reviewer_id == "admin-1" + assert alert.review_reason == "test resolution" def test_get_suspicious_identities(self): service = FaceDetectionService() @@ -168,4 +170,94 @@ def test_multi_roll_number_conflict(self): is_valid, alert, _ = service.verify_identity(emb3, "R003") assert is_valid is False - assert len(alert.matched_roll_numbers) >= 1 \ No newline at end of file + assert len(alert.matched_roll_numbers) >= 1 + + def test_get_conflict_details(self): + service = FaceDetectionService() + emb1 = generate_embedding(base=0.5) + emb2 = create_similar_embedding(emb1, 0.92) + + service.add_embedding(emb1, "R001", "Alice") + _, alert, _ = service.verify_identity(emb2, "R002", "session-2") + + details = service.get_conflict_details(alert.conflict_id) + + assert details is not None + assert "conflict" in details + assert "prior_embeddings" in details + assert len(details["prior_embeddings"]) == 1 + assert details["prior_embeddings"][0]["roll_number"] == "R001" + assert details["prior_embeddings"][0]["student_name"] == "Alice" + + def test_admin_review_conflict(self): + service = FaceDetectionService() + emb1 = generate_embedding(base=0.5) + emb2 = create_similar_embedding(emb1, 0.92) + + service.verify_identity(emb1, "R001") + _, alert, _ = service.verify_identity(emb2, "R002") + + result = service.admin_review_conflict( + alert.conflict_id, + approved=True, + reviewer_id="admin-1", + reason="Verified as same person" + ) + + assert result is True + assert alert.status == "approved" + assert alert.reviewer_id == "admin-1" + assert alert.review_reason == "Verified as same person" + assert alert.review_timestamp is not None + + def test_admin_review_rejection(self): + service = FaceDetectionService() + emb1 = generate_embedding(base=0.5) + emb2 = create_similar_embedding(emb1, 0.92) + + service.verify_identity(emb1, "R001") + _, alert, _ = service.verify_identity(emb2, "R002") + + result = service.admin_review_conflict( + alert.conflict_id, + approved=False, + reviewer_id="admin-2", + reason="Different people, access denied" + ) + + assert result is True + assert alert.status == "rejected" + assert alert.reviewer_id == "admin-2" + + def test_get_override_log(self): + service = FaceDetectionService() + emb1 = generate_embedding(base=0.5) + emb2 = create_similar_embedding(emb1, 0.92) + emb3 = create_similar_embedding(emb1, 0.90) + + service.verify_identity(emb1, "R001") + _, alert1, _ = service.verify_identity(emb2, "R002") + _, alert2, _ = service.verify_identity(emb3, "R003") + + service.admin_review_conflict(alert1.conflict_id, True, "admin-1", "approved") + service.admin_review_conflict(alert2.conflict_id, False, "admin-2", "rejected") + + override_log = service.get_override_log() + + assert len(override_log) == 2 + assert override_log[0]["status"] in ("approved", "rejected") + assert override_log[0]["reviewer_id"] == "admin-1" + + def test_get_alert_by_id(self): + service = FaceDetectionService() + emb1 = generate_embedding(base=0.5) + emb2 = create_similar_embedding(emb1, 0.92) + + service.verify_identity(emb1, "R001") + _, alert, _ = service.verify_identity(emb2, "R002") + + found = service.get_alert_by_id(alert.conflict_id) + not_found = service.get_alert_by_id("nonexistent") + + assert found is alert + assert not_found is None \ No newline at end of file