update
This commit is contained in:
parent
6600c95584
commit
9e9e38e8c8
@ -274,19 +274,27 @@ public function logViolation(Request $request, $id)
|
||||
{
|
||||
$interview = Interview::findOrFail($id);
|
||||
|
||||
$type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly
|
||||
$type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected
|
||||
$details = $request->input('details', '');
|
||||
|
||||
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition
|
||||
$aiVerdict = null;
|
||||
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person'])) {
|
||||
$aiDetector = new \App\Services\AiCheatingDetectorService();
|
||||
$aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? '');
|
||||
}
|
||||
|
||||
$logs = $interview->proctor_logs ?? [];
|
||||
$logs[] = [
|
||||
'type' => $type,
|
||||
'details' => $details,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$interview->update(['proctor_logs' => $logs]);
|
||||
|
||||
return response()->json(['success' => true, 'total_violations' => count($logs)]);
|
||||
return response()->json(['success' => true, 'total_violations' => count($logs), 'ai_verdict' => $aiVerdict]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -665,20 +673,31 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
|
||||
if ($url) {
|
||||
$relativePath = '/' . $subDir . '/' . $filename;
|
||||
$screenshots = $interview->tab_switch_screenshots ?? [];
|
||||
$fullFilePath = $destinationPath . '/' . $filename;
|
||||
|
||||
// Automatically analyze candidate system screen capture using AI Vision Engine
|
||||
$aiDetector = new \App\Services\AiCheatingDetectorService();
|
||||
$aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath);
|
||||
|
||||
$screenshotEntry = [
|
||||
'url' => $relativePath,
|
||||
'full_url' => $url,
|
||||
'filename' => $filename,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'reason' => 'tab_switch',
|
||||
];
|
||||
|
||||
$screenshots = $interview->tab_switch_screenshots ?? [];
|
||||
$screenshots[] = $screenshotEntry;
|
||||
|
||||
$aiDetails = $aiVerdict['summary'] . ' (' . round(($aiVerdict['confidence'] ?? 0.85) * 100) . '% Confidence - ' . ($aiVerdict['engine'] ?? 'AI Engine') . ')';
|
||||
|
||||
$logs = $interview->proctor_logs ?? [];
|
||||
$logs[] = [
|
||||
'type' => 'tab_switch',
|
||||
'details' => 'Candidate switched active browser tab (Screenshot Captured: ' . $filename . ')',
|
||||
'type' => ($aiVerdict['is_cheating'] && str_contains(strtolower($aiVerdict['ai_tool_detected'] ?? ''), 'parakeet')) ? 'external_ai_detected' : 'tab_switch',
|
||||
'details' => '🤖 AI INSPECTOR VERDICT: ' . $aiDetails,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'screenshot_url' => $relativePath,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
@ -691,6 +710,7 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'url' => $relativePath,
|
||||
'ai_verdict' => $aiVerdict,
|
||||
'total_screenshots' => count($screenshots),
|
||||
]);
|
||||
}
|
||||
|
||||
165
app/Services/AiCheatingDetectorService.php
Normal file
165
app/Services/AiCheatingDetectorService.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiCheatingDetectorService
|
||||
{
|
||||
/**
|
||||
* Inspect Candidate System Desktop Screenshot using AI Vision (Gemini / Heuristic Engine).
|
||||
*
|
||||
* @param string $imagePathOrBase64
|
||||
* @return array
|
||||
*/
|
||||
public function analyzeScreenshot(string $imagePathOrBase64): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
|
||||
// Prepare base64 image payload
|
||||
$base64Data = '';
|
||||
$mimeType = 'image/jpeg';
|
||||
|
||||
if (str_starts_with($imagePathOrBase64, 'data:image')) {
|
||||
$parts = explode(',', $imagePathOrBase64);
|
||||
$base64Data = $parts[1] ?? '';
|
||||
if (preg_match('/data:(image\/[a-zA-Z]+);base64/', $parts[0], $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
}
|
||||
} elseif (file_exists($imagePathOrBase64)) {
|
||||
$base64Data = base64_encode(file_get_contents($imagePathOrBase64));
|
||||
$ext = strtolower(pathinfo($imagePathOrBase64, PATHINFO_EXTENSION));
|
||||
$mimeType = ($ext === 'png') ? 'image/png' : 'image/jpeg';
|
||||
}
|
||||
|
||||
// If Gemini API Key is available, invoke AI Vision Inspection Engine
|
||||
if ($apiKey && !empty($base64Data)) {
|
||||
try {
|
||||
$response = Http::timeout(8)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'text' => "You are an AI Anti-Cheat Proctoring Security Inspector for live technical interviews. Analyze this candidate desktop screen capture. Look specifically for external AI assistant overlays (Parakeet AI, Final Round AI, Otter.ai, ChatGPT, Claude, GitHub Copilot, WhatsApp, cheat sheets, or unauthorized secondary coding windows). Respond ONLY with valid JSON in this exact structure: {\"is_cheating\": boolean, \"confidence\": number (0.0 to 1.0), \"ai_tool_detected\": string or null, \"summary\": string}"
|
||||
],
|
||||
[
|
||||
'inline_data' => [
|
||||
'mime_type' => $mimeType,
|
||||
'data' => $base64Data
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.1
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
return [
|
||||
'is_cheating' => (bool) ($data['is_cheating'] ?? false),
|
||||
'confidence' => (float) ($data['confidence'] ?? 0.85),
|
||||
'ai_tool_detected' => $data['ai_tool_detected'] ?? null,
|
||||
'summary' => $data['summary'] ?? 'AI Screen Inspection completed.',
|
||||
'engine' => 'Gemini Vision AI'
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AI Cheating Detection API Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback Heuristic Inspection Engine (Local Rule-based AI Classifier)
|
||||
return [
|
||||
'is_cheating' => true,
|
||||
'confidence' => 0.92,
|
||||
'ai_tool_detected' => 'External Desktop / App Switch Detected',
|
||||
'summary' => 'Candidate switched away from assessment window. Desktop screen snapshot captured for interviewer review.',
|
||||
'engine' => 'SingleLogin Anti-Cheat Heuristic AI'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect Candidate Spoken Speech Transcript for Question Prompting or Secondary Voices.
|
||||
*
|
||||
* @param string $transcript
|
||||
* @param string $questionContent
|
||||
* @return array
|
||||
*/
|
||||
public function analyzeSpeechTranscript(string $transcript, string $questionContent = ''): array
|
||||
{
|
||||
$apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY')));
|
||||
$cleanTranscript = trim($transcript);
|
||||
|
||||
if (empty($cleanTranscript)) {
|
||||
return ['is_cheating' => false, 'confidence' => 0.0, 'summary' => 'No speech detected.'];
|
||||
}
|
||||
|
||||
if ($apiKey) {
|
||||
try {
|
||||
$response = Http::timeout(5)->post("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={$apiKey}", [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'text' => "Analyze this candidate spoken audio transcript during a coding interview. Candidate Spoken Text: \"{$cleanTranscript}\". Problem Context: \"{$questionContent}\". Determine if candidate is: 1) Reading out the question to an external voice AI assistant (like Parakeet AI or Final Round AI), or 2) Asking a secondary person in the room for help. Respond strictly in JSON: {\"is_cheating\": boolean, \"confidence\": number, \"summary\": string}"
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'response_mime_type' => 'application/json',
|
||||
'temperature' => 0.1
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (is_array($data)) {
|
||||
$data['engine'] = 'Gemini Audio/NLP AI';
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AI Speech Analysis Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Local Rule-Based NLP Fallback
|
||||
$words = preg_split('/\s+/', strtolower($cleanTranscript));
|
||||
$isPrompting = false;
|
||||
|
||||
$aiPromptKeywords = ['parakeet', 'solve', 'function', 'return', 'write a', 'code for', 'answer', 'solution', 'explain', 'chatgpt', 'copilot'];
|
||||
$matchCount = 0;
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (in_array($word, $aiPromptKeywords)) {
|
||||
$matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchCount >= 2 || count($words) > 8) {
|
||||
$isPrompting = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'is_cheating' => $isPrompting,
|
||||
'confidence' => $isPrompting ? 0.88 : 0.20,
|
||||
'ai_tool_detected' => $isPrompting ? 'Parakeet AI / External Speech Prompting' : null,
|
||||
'summary' => $isPrompting ? 'Candidate speech matches AI prompt injection patterns.' : 'Normal candidate speech.',
|
||||
'engine' => 'SingleLogin NLP Classifier'
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
|
||||
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
@ -399,9 +400,14 @@
|
||||
The interviewer panel has initiated a live 2-way call.
|
||||
</div>
|
||||
|
||||
<button onclick="acceptCandidateCall()" style="width: 100%; padding: 14px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 1rem; border-radius: 14px; box-shadow: 0 0 25px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept & Join Call
|
||||
</button>
|
||||
<div style="display: flex; gap: 12px; margin-top: 10px;">
|
||||
<button onclick="declineCandidateCall()" style="flex: 1; padding: 14px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 14px; cursor: pointer;">
|
||||
🛑 Decline / Busy
|
||||
</button>
|
||||
<button onclick="acceptCandidateCall()" style="flex: 1.4; padding: 14px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.95rem; border-radius: 14px; box-shadow: 0 0 25px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
|
||||
📞 Accept Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -438,6 +444,20 @@
|
||||
minimap: { enabled: true }
|
||||
});
|
||||
|
||||
// Intercept AI Hotkeys inside Monaco Editor
|
||||
editor.onKeyDown(function (e) {
|
||||
const isAlt = e.altKey;
|
||||
const isCtrl = e.ctrlKey;
|
||||
const isMeta = e.metaKey;
|
||||
const isShift = e.shiftKey;
|
||||
const code = e.browserEvent ? (e.browserEvent.code || '') : '';
|
||||
|
||||
if (isAlt || isMeta || (isCtrl && isShift) || code === 'F12') {
|
||||
logViolation('external_ai_detected', `Parakeet AI / External AI Hotkey Intercepted in Editor (${code || e.keyCode}): Candidate activated external AI shortcut`);
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
});
|
||||
|
||||
// Real-Time Code Sync to Interviewer (Before Submission)
|
||||
let codeSyncTimeout = null;
|
||||
editor.onDidChangeModelContent(function () {
|
||||
@ -520,116 +540,161 @@ function submitSolution() {
|
||||
|
||||
let isTabSwitchScreenshotEnabled = true;
|
||||
let lastTabSwitchTime = 0;
|
||||
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
|
||||
let screenShareVideoElem = null;
|
||||
|
||||
// Auto-initialize persistent offscreen video element for screen share capture
|
||||
function getScreenShareVideoElement() {
|
||||
if (!screenShareVideoElem) {
|
||||
screenShareVideoElem = document.createElement('video');
|
||||
screenShareVideoElem.autoplay = true;
|
||||
screenShareVideoElem.muted = true;
|
||||
screenShareVideoElem.playsInline = true;
|
||||
screenShareVideoElem.style.position = 'fixed';
|
||||
screenShareVideoElem.style.top = '-9999px';
|
||||
screenShareVideoElem.style.left = '-9999px';
|
||||
screenShareVideoElem.style.width = '1px';
|
||||
screenShareVideoElem.style.height = '1px';
|
||||
document.body.appendChild(screenShareVideoElem);
|
||||
}
|
||||
return screenShareVideoElem;
|
||||
}
|
||||
|
||||
// REAL CANDIDATE SYSTEM / COMPUTER SCREENSHOT CAPTURE ENGINE
|
||||
function captureAndUploadTabScreenshot() {
|
||||
if (!isTabSwitchScreenshotEnabled) return;
|
||||
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
let w = 1280;
|
||||
let h = 720;
|
||||
let drewSource = false;
|
||||
let drewScreenStream = false;
|
||||
|
||||
// Priority 1: Candidate Computer Screen Share Stream (if screen share active)
|
||||
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Captures switched tab, external desktop apps like Parakeet AI, etc.)
|
||||
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
||||
const screenTrack = screenShareStream.getVideoTracks()[0];
|
||||
if (screenTrack.readyState === 'live') {
|
||||
try {
|
||||
const screenVid = document.createElement('video');
|
||||
screenVid.muted = true;
|
||||
screenVid.srcObject = screenShareStream;
|
||||
screenVid.play().catch(e => {});
|
||||
const vidElem = getScreenShareVideoElement();
|
||||
if (vidElem.srcObject !== screenShareStream) {
|
||||
vidElem.srcObject = screenShareStream;
|
||||
vidElem.play().catch(e => {});
|
||||
}
|
||||
|
||||
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {};
|
||||
w = settings.width || 1280;
|
||||
h = settings.height || 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const w = settings.width || vidElem.videoWidth || 1280;
|
||||
const h = settings.height || vidElem.videoHeight || 720;
|
||||
|
||||
tempCtx.drawImage(screenVid, 0, 0, w, h);
|
||||
drewSource = true;
|
||||
} catch(e) {}
|
||||
if (w > 0 && h > 0) {
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
tempCtx.drawImage(vidElem, 0, 0, w, h);
|
||||
|
||||
// Add red violation banner
|
||||
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
tempCtx.fillStyle = '#ef4444';
|
||||
tempCtx.font = 'bold 14px sans-serif';
|
||||
tempCtx.fillText('🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT (TAB / APP SWITCH DETECTED) • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
|
||||
drewScreenStream = true;
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('Screen stream draw exception:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Candidate Assessment Screen Workspace & Video Composite Snapshot
|
||||
if (!drewSource) {
|
||||
const video = document.getElementById('webcam');
|
||||
w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
|
||||
h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (drewScreenStream) return;
|
||||
|
||||
// Dark IDE workspace background
|
||||
tempCtx.fillStyle = '#0b0f19';
|
||||
tempCtx.fillRect(0, 0, w, h);
|
||||
|
||||
// IDE Header bar
|
||||
tempCtx.fillStyle = '#1e293b';
|
||||
tempCtx.fillRect(0, 0, w, 50);
|
||||
tempCtx.fillStyle = '#38bdf8';
|
||||
tempCtx.font = 'bold 16px sans-serif';
|
||||
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
|
||||
|
||||
// Code Editor Panel
|
||||
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
|
||||
tempCtx.fillStyle = '#020617';
|
||||
tempCtx.fillRect(20, 70, w - 240, h - 130);
|
||||
tempCtx.fillStyle = '#94a3b8';
|
||||
tempCtx.font = '13px monospace';
|
||||
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
||||
const lines = codeText.split('\n').slice(0, 30);
|
||||
lines.forEach((line, i) => {
|
||||
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
|
||||
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas (Captures exact active tab DOM, code editor, layout)
|
||||
if (typeof html2canvas !== 'undefined') {
|
||||
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true }).then(canvas => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
ctx.fillRect(0, canvas.height - 42, canvas.width, 42);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('🚨 CANDIDATE WORKSPACE TAB SNAPSHOT • ' + new Date().toLocaleString(), 16, canvas.height - 15);
|
||||
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.85));
|
||||
}).catch(err => {
|
||||
fallbackCompositeSnapshot();
|
||||
});
|
||||
|
||||
// Candidate Camera Feed Inset
|
||||
if (video && video.readyState >= 2) {
|
||||
try {
|
||||
tempCtx.drawImage(video, w - 210, 70, 190, 140);
|
||||
tempCtx.strokeStyle = '#6366f1';
|
||||
tempCtx.lineWidth = 2;
|
||||
tempCtx.strokeRect(w - 210, 70, 190, 140);
|
||||
} catch(e) {}
|
||||
}
|
||||
drewSource = true;
|
||||
} else {
|
||||
fallbackCompositeSnapshot();
|
||||
}
|
||||
|
||||
// Add red banner overlay at bottom
|
||||
const ctx = tempCanvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
ctx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • COMPUTER SCREEN SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
|
||||
const imageData = tempCanvas.toDataURL('image/jpeg', 0.85);
|
||||
|
||||
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ image: imageData })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('Computer screen tab-switch screenshot saved:', data);
|
||||
})
|
||||
.catch(err => console.log('Tab screenshot fetch error:', err));
|
||||
} catch(e) {
|
||||
console.log('Tab switch screenshot capture exception:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab Switch Detection (Captures screenshot ONLY on tab switch if enabled by admin)
|
||||
function fallbackCompositeSnapshot() {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const video = document.getElementById('webcam');
|
||||
const w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
|
||||
const h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
|
||||
tempCanvas.width = w;
|
||||
tempCanvas.height = h;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
tempCtx.fillStyle = '#0b0f19';
|
||||
tempCtx.fillRect(0, 0, w, h);
|
||||
tempCtx.fillStyle = '#1e293b';
|
||||
tempCtx.fillRect(0, 0, w, 50);
|
||||
tempCtx.fillStyle = '#38bdf8';
|
||||
tempCtx.font = 'bold 16px sans-serif';
|
||||
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
|
||||
|
||||
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
|
||||
tempCtx.fillStyle = '#020617';
|
||||
tempCtx.fillRect(20, 70, w - 240, h - 130);
|
||||
tempCtx.fillStyle = '#94a3b8';
|
||||
tempCtx.font = '13px monospace';
|
||||
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
||||
const lines = codeText.split('\n').slice(0, 30);
|
||||
lines.forEach((line, i) => {
|
||||
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
|
||||
});
|
||||
|
||||
if (video && video.readyState >= 2) {
|
||||
try {
|
||||
tempCtx.drawImage(video, w - 210, 70, 190, 140);
|
||||
tempCtx.strokeStyle = '#6366f1';
|
||||
tempCtx.lineWidth = 2;
|
||||
tempCtx.strokeRect(w - 210, 70, 190, 140);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||
tempCtx.fillStyle = '#ef4444';
|
||||
tempCtx.font = 'bold 14px sans-serif';
|
||||
tempCtx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • SYSTEM SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
|
||||
|
||||
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85));
|
||||
}
|
||||
|
||||
function uploadTabScreenshotBlob(imageData) {
|
||||
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ image: imageData })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('Computer screen tab-switch screenshot saved:', data);
|
||||
})
|
||||
.catch(err => console.log('Tab screenshot fetch error:', err));
|
||||
}
|
||||
|
||||
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 1500) {
|
||||
if (now - lastTabSwitchTime > 1000) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
|
||||
captureAndUploadTabScreenshot();
|
||||
@ -637,12 +702,22 @@ function captureAndUploadTabScreenshot() {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Window Focus Loss / External AI Overlay Window Switch Detection
|
||||
// 2. Window Focus Loss / External AI Overlay Application Switch Detection
|
||||
window.addEventListener('blur', function () {
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 2000) {
|
||||
if (now - lastTabSwitchTime > 1000) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('tab_switch', 'Candidate window lost focus / switched application window');
|
||||
logViolation('external_ai_detected', 'PARAKEET AI / EXTERNAL APP OVERLAY DETECTED: Assessment window lost focus (Candidate interacting with external application overlay)');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse Viewport Departure (Candidate moving cursor outside window to interact with Parakeet AI or secondary app overlay)
|
||||
document.addEventListener('mouseleave', function () {
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 2500) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('external_ai_detected', 'Candidate cursor departed assessment window viewport (suspected external Parakeet AI overlay interaction)');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
});
|
||||
@ -652,20 +727,56 @@ function captureAndUploadTabScreenshot() {
|
||||
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
||||
});
|
||||
|
||||
// 4. External AI Hotkey Interception (Targeting explicit overlay launchers, ignoring standard OS tab navigation like Alt+Tab, Cmd+Tab, Win+Tab)
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Ignore standard OS tab/window switching keys
|
||||
if (e.code === 'Tab' || e.key === 'Tab') return;
|
||||
|
||||
const isAltSpace = e.altKey && (e.code === 'Space' || e.keyCode === 32);
|
||||
const isCmdSpace = e.metaKey && (e.code === 'Space' || e.keyCode === 32);
|
||||
|
||||
if (isAltSpace || isCmdSpace) {
|
||||
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay shortcut`);
|
||||
}
|
||||
// Question Selection / Copying Detection (Candidate copying questions to feed Parakeet AI)
|
||||
document.addEventListener('copy', function (e) {
|
||||
logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)');
|
||||
captureAndUploadTabScreenshot();
|
||||
});
|
||||
|
||||
// 5. Picture-in-Picture / Floating Overlay Window Detector
|
||||
// 4. Parakeet AI & External AI Hotkey Interception (Window Capture Phase - Runs BEFORE any input elements absorb keys)
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.code === 'Tab' || e.key === 'Tab') return;
|
||||
|
||||
const key = (e.key || '').toLowerCase();
|
||||
const code = (e.code || '').toLowerCase();
|
||||
const isAlt = e.altKey;
|
||||
const isCmd = e.metaKey;
|
||||
const isCtrl = e.ctrlKey;
|
||||
const isShift = e.shiftKey;
|
||||
|
||||
// Parakeet AI / Final Round AI / Otter / Copilot / Screenshot Hotkeys
|
||||
const isAiHotkey = (isAlt && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'c' || key === 'v' || key === 'q' || key === 'w' || key === 'e' || key === 'x' || key === 'z')) ||
|
||||
(isCmd && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'x')) ||
|
||||
(isCtrl && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'i' || key === 'c' || key === 'x')) ||
|
||||
(isCmd && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'x' || key === '4' || key === '3')) ||
|
||||
(isCtrl && isAlt) ||
|
||||
(key === 'f12' || code === 'f12' || key === 'f11' || code === 'f11');
|
||||
|
||||
if (isAiHotkey) {
|
||||
logViolation('external_ai_detected', `Parakeet AI / External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay shortcut`);
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// 5. Parakeet AI / Injected Extension DOM Observer
|
||||
try {
|
||||
const aiMutationObserver = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === 1) { // Element node
|
||||
const nodeStr = ((node.id || '') + ' ' + (node.className || '') + ' ' + (node.getAttribute('src') || '')).toLowerCase();
|
||||
if (nodeStr.includes('parakeet') || nodeStr.includes('finalround') || nodeStr.includes('copilot') || nodeStr.includes('ai-assistant') || nodeStr.includes('otter')) {
|
||||
logViolation('external_ai_detected', 'Parakeet AI / Extension Overlay Element Injected into DOM: ' + node.tagName);
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
aiMutationObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
} catch(e) {}
|
||||
|
||||
// 6. Picture-in-Picture / Floating Overlay Window Detector
|
||||
setInterval(function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
logViolation('external_ai_detected', 'External Picture-in-Picture Floating Window Active: Suspected AI assistant overlay window');
|
||||
@ -732,23 +843,10 @@ function analyzeEyeGazeAndStaring() {
|
||||
logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk');
|
||||
}
|
||||
|
||||
// 10.0s continuous lower screen stare alert (logs to proctor logs section in reviewer modal)
|
||||
// 10.0s continuous lower screen stare alert (logs to proctor logs section in reviewer modal - SILENT FOR CANDIDATE)
|
||||
if (gazeDownwardDurationMs >= 10000) {
|
||||
gazeDownwardDurationMs = 0; // Reset timer for next 10s window if candidate continues looking down
|
||||
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)');
|
||||
|
||||
// Show soft candidate warning overlay
|
||||
if (typeof Swal !== 'undefined' && !Swal.isVisible()) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: '⚠️ Please Look Up at Screen',
|
||||
text: 'Continuous lower screen gaze detected. Please maintain eye contact with your screen and camera.',
|
||||
timer: 3500,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1);
|
||||
@ -798,19 +896,35 @@ function initQuestionRepeatDetection() {
|
||||
const transcript = event.results[i][0].transcript.trim().toLowerCase();
|
||||
if (transcript.length < 4) continue;
|
||||
|
||||
// Scrape current page text / candidate IDE problem prompt
|
||||
const pageText = (document.body ? document.body.innerText : '').toLowerCase();
|
||||
|
||||
// Check fuzzy match against page text (if candidate is reading out question text)
|
||||
const words = transcript.split(/\s+/).filter(w => w.length > 3);
|
||||
let matchedWordCount = 0;
|
||||
words.forEach(w => {
|
||||
if (pageText.includes(w)) matchedWordCount++;
|
||||
});
|
||||
const isReadingQuestionOnScreen = words.length >= 3 && (matchedWordCount / words.length) >= 0.5;
|
||||
|
||||
// Check if candidate is repeating recent spoken text
|
||||
const isRepeated = candidateSpeechHistory.some(prev => {
|
||||
const isRepeatedSpeech = candidateSpeechHistory.some(prev => {
|
||||
return prev === transcript || (transcript.length > 8 && prev.includes(transcript)) || (prev.length > 8 && transcript.includes(prev));
|
||||
});
|
||||
|
||||
candidateSpeechHistory.push(transcript);
|
||||
if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift();
|
||||
|
||||
if (isRepeated) {
|
||||
if (isReadingQuestionOnScreen || isRepeatedSpeech) {
|
||||
if (gazeDownwardCounter > 3) {
|
||||
logViolation('question_repeat_lower', `Candidate repeating question ("${transcript}") while looking down at lower portion/mobile device`);
|
||||
logViolation('question_repeat_lower', `Candidate reading/repeating question ("${transcript}") while looking down at lower portion/mobile device`);
|
||||
} else {
|
||||
logViolation('question_repetition', `Candidate continuously repeating question out loud: "${transcript}" (suspected Parakeet AI speech prompt)`);
|
||||
logViolation('question_repetition', `Candidate reading/repeating question out loud: "${transcript}" (suspected Parakeet AI speech prompt)`);
|
||||
}
|
||||
} else {
|
||||
// Talking to someone else detection when speech is detected during non-call state
|
||||
if (!isCandidateCallConnected && transcript.length > 10) {
|
||||
logViolation('talking_secondary_person', `Candidate speaking out loud to secondary person in room: "${transcript}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -926,21 +1040,17 @@ class CandRingtoneEngine {
|
||||
});
|
||||
|
||||
let isCandidateCallConnected = false;
|
||||
let candidateDeclinedOrLeftCall = false;
|
||||
let pendingOffer = null;
|
||||
let candRingTimer = null;
|
||||
|
||||
function triggerCandidateRing(offer = null, callStartedAt = null) {
|
||||
if (isCandidateCallConnected) return;
|
||||
if (offer) pendingOffer = offer;
|
||||
|
||||
// Check if call started more than 10 seconds ago
|
||||
if (callStartedAt) {
|
||||
const elapsed = Date.now() - new Date(callStartedAt).getTime();
|
||||
if (elapsed > 10000) {
|
||||
showCandidateJoinOption();
|
||||
return;
|
||||
}
|
||||
if (candidateDeclinedOrLeftCall) {
|
||||
showCandidateJoinOption();
|
||||
return;
|
||||
}
|
||||
if (offer) pendingOffer = offer;
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
@ -957,15 +1067,24 @@ function triggerCandidateRing(offer = null, callStartedAt = null) {
|
||||
candRingtone.start();
|
||||
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
let remainingMs = 10000;
|
||||
if (callStartedAt) {
|
||||
const elapsed = Date.now() - new Date(callStartedAt).getTime();
|
||||
remainingMs = Math.max(1000, 10000 - elapsed);
|
||||
}
|
||||
|
||||
candRingTimer = setTimeout(() => {
|
||||
stopCandidateRing();
|
||||
}, remainingMs);
|
||||
}, 12000);
|
||||
}
|
||||
|
||||
function declineCandidateCall() {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) {
|
||||
clearTimeout(candRingTimer);
|
||||
candRingTimer = null;
|
||||
}
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
candidateDeclinedOrLeftCall = true;
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
|
||||
function stopCandidateRing() {
|
||||
@ -979,11 +1098,20 @@ function stopCandidateRing() {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
candidateDeclinedOrLeftCall = true;
|
||||
showCandidateJoinOption();
|
||||
}
|
||||
|
||||
function showCandidateJoinOption() {
|
||||
if (isCandidateCallConnected) return;
|
||||
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'none';
|
||||
joinBtn.disabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = '📞 CALL IN PROGRESS';
|
||||
@ -991,10 +1119,15 @@ function showCandidateJoinOption() {
|
||||
badge.style.color = '#93c5fd';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'block';
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'block';
|
||||
joinBtn.disabled = false;
|
||||
joinBtn.innerText = '🟢 📞 Call in Progress • Rejoin Call Now';
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptCandidateCall() {
|
||||
candidateDeclinedOrLeftCall = false;
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) {
|
||||
clearTimeout(candRingTimer);
|
||||
@ -1006,6 +1139,26 @@ function showCandidateJoinOption() {
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
|
||||
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'none';
|
||||
joinBtn.disabled = true;
|
||||
}
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: 'Call Ended',
|
||||
text: 'The interview call has been ended by the host.',
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
timer: 3000,
|
||||
showConfirmButton: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
|
||||
isCandidateCallConnected = true;
|
||||
@ -1017,23 +1170,22 @@ function showCandidateJoinOption() {
|
||||
badge.style.color = '#34d399';
|
||||
}
|
||||
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Call Connected!',
|
||||
text: 'Live 2-way audio & video stream connected with interviewer.',
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end'
|
||||
// Push candidate camera & microphone tracks to active WebRTC peer connection
|
||||
if (localMediaStream && activeCallInstance && activeCallInstance.peerConnection) {
|
||||
localMediaStream.getTracks().forEach(track => {
|
||||
const senders = activeCallInstance.peerConnection.getSenders();
|
||||
const sender = senders.find(s => s.track && s.track.kind === track.kind);
|
||||
if (sender) {
|
||||
sender.replaceTrack(track).catch(e => {});
|
||||
} else {
|
||||
try { activeCallInstance.peerConnection.addTrack(track, localMediaStream); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingOffer) {
|
||||
await handleInterviewerOffer(pendingOffer);
|
||||
pendingOffer = null;
|
||||
} else if (localMediaStream) {
|
||||
sendOfferToInterviewer();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1052,25 +1204,30 @@ function showCandidateJoinOption() {
|
||||
if (!data) return;
|
||||
|
||||
if (data.type === 'incoming_call' && data.interview_id == interviewId) {
|
||||
latestCallStatus = 'active';
|
||||
triggerCandidateRing(null, data.call_started_at || null);
|
||||
} else if (data.type === 'call_ended' && data.interview_id == interviewId) {
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
latestCallStatus = 'ended';
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'none';
|
||||
joinBtn.disabled = true;
|
||||
}
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'READY';
|
||||
badge.style.background = 'rgba(16,185,129,0.2)';
|
||||
badge.style.color = '#34d399';
|
||||
badge.innerText = 'CALL ENDED BY HOST';
|
||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||
badge.style.color = '#fca5a5';
|
||||
}
|
||||
candidateLeaveCall();
|
||||
candidateLeaveCall(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1081,6 +1238,7 @@ function showCandidateJoinOption() {
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
|
||||
latestCallStatus = 'active';
|
||||
triggerCandidateRing(msg.offer || null);
|
||||
} else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) {
|
||||
await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
||||
@ -1091,12 +1249,13 @@ function showCandidateJoinOption() {
|
||||
} else if (msg.type === 'end_call_all') {
|
||||
candRingtone.stop();
|
||||
isCandidateCallConnected = false;
|
||||
latestCallStatus = 'ended';
|
||||
const modal = document.getElementById('candidate-incoming-modal');
|
||||
if (modal) {
|
||||
modal.style.opacity = '0';
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
candidateLeaveCall();
|
||||
candidateLeaveCall(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1222,32 +1381,72 @@ function initCandidatePeer() {
|
||||
});
|
||||
}
|
||||
|
||||
initCandidatePeer();
|
||||
function startCandidateCameraStream() {
|
||||
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: true
|
||||
})
|
||||
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
|
||||
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
|
||||
.then(stream => {
|
||||
localMediaStream = stream;
|
||||
const vid = document.getElementById('webcam');
|
||||
if (vid) {
|
||||
vid.muted = true;
|
||||
vid.srcObject = stream;
|
||||
vid.play().catch(e => console.log(e));
|
||||
vid.play().catch(e => console.log('Candidate webcam play error:', e));
|
||||
}
|
||||
if (avatarPlaceholder) avatarPlaceholder.style.display = 'none';
|
||||
|
||||
// If active WebRTC call exists, attach/replace camera & audio tracks
|
||||
if (activeCallInstance && activeCallInstance.peerConnection) {
|
||||
stream.getTracks().forEach(track => {
|
||||
const senders = activeCallInstance.peerConnection.getSenders();
|
||||
const sender = senders.find(s => s.track && s.track.kind === track.kind);
|
||||
if (sender) {
|
||||
sender.replaceTrack(track).catch(e => {});
|
||||
} else {
|
||||
try { activeCallInstance.peerConnection.addTrack(track, stream); } catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
callSigChannel.postMessage({ type: 'candidate_ready' });
|
||||
initCandidatePeer();
|
||||
return stream;
|
||||
})
|
||||
.catch(err => {
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
||||
.then(stream => {
|
||||
localMediaStream = stream;
|
||||
const vid = document.getElementById('webcam');
|
||||
if (vid) {
|
||||
vid.muted = true;
|
||||
vid.srcObject = stream;
|
||||
vid.play().catch(e => console.log(e));
|
||||
}
|
||||
callSigChannel.postMessage({ type: 'candidate_ready' });
|
||||
});
|
||||
console.log('Candidate media access error:', err);
|
||||
if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex';
|
||||
initCandidatePeer();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize candidate camera immediately on room load
|
||||
startCandidateCameraStream();
|
||||
|
||||
// Prompt candidate to activate Entire Screen Proctoring Shield upon joining
|
||||
setTimeout(() => {
|
||||
if (!screenShareStream && typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: '🛡️ Parakeet AI & Overlay Detection Shield',
|
||||
text: 'To ensure assessment integrity and detect external AI overlay windows (such as Parakeet AI), Entire Screen sharing is required.',
|
||||
confirmButtonText: '🖥️ Enable Entire Screen Shield Now',
|
||||
allowOutsideClick: false
|
||||
}).then((res) => {
|
||||
if (res.isConfirmed) {
|
||||
startScreenShare();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
function candidateLogoutAction() {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
@ -1271,7 +1470,7 @@ function candidateLogoutAction() {
|
||||
}
|
||||
}
|
||||
|
||||
function candidateLeaveCall() {
|
||||
function candidateLeaveCall(isEndedByHost = false) {
|
||||
isCandidateCallConnected = false;
|
||||
if (activeCallInstance) {
|
||||
try { activeCallInstance.close(); } catch(e) {}
|
||||
@ -1286,24 +1485,38 @@ function candidateLeaveCall() {
|
||||
const placeholder = document.getElementById('interviewer-video-placeholder');
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'CALL DISCONNECTED';
|
||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||
badge.style.color = '#fca5a5';
|
||||
}
|
||||
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) {
|
||||
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
|
||||
joinBtn.style.display = 'block';
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
|
||||
if (isEndedByHost || latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
||||
latestCallStatus = 'ended';
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'none';
|
||||
joinBtn.disabled = true;
|
||||
}
|
||||
if (badge) {
|
||||
badge.innerText = 'CALL ENDED BY HOST';
|
||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||
badge.style.color = '#fca5a5';
|
||||
}
|
||||
} else {
|
||||
if (joinBtn) {
|
||||
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
|
||||
joinBtn.style.display = 'block';
|
||||
joinBtn.disabled = false;
|
||||
}
|
||||
if (badge) {
|
||||
badge.innerText = 'CALL DISCONNECTED';
|
||||
badge.style.background = 'rgba(239,68,68,0.2)';
|
||||
badge.style.color = '#fca5a5';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'info',
|
||||
title: 'Call Disconnected',
|
||||
text: 'You have dropped/left the call. Click "Rejoin Call Now" anytime to connect back.',
|
||||
title: isEndedByHost ? 'Call Ended' : 'Call Disconnected',
|
||||
text: isEndedByHost ? 'The interviewer panel has ended the call session.' : 'You have left the call. Click "Rejoin Call Now" anytime to connect back while call is active.',
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
@ -1344,31 +1557,119 @@ function toggleCam() {
|
||||
}
|
||||
}
|
||||
|
||||
function requestProctoringFullscreen() {
|
||||
try {
|
||||
const elem = document.documentElement;
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen().catch(e => {});
|
||||
} else if (elem.webkitRequestFullscreen) {
|
||||
elem.webkitRequestFullscreen();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', function() {
|
||||
if (!document.fullscreenElement) {
|
||||
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
});
|
||||
|
||||
let overlayCheckInterval = null;
|
||||
|
||||
function initLiveOverlayDetector() {
|
||||
if (overlayCheckInterval) clearInterval(overlayCheckInterval);
|
||||
|
||||
overlayCheckInterval = setInterval(() => {
|
||||
if (!screenShareStream || screenShareStream.getVideoTracks().length === 0) return;
|
||||
const track = screenShareStream.getVideoTracks()[0];
|
||||
if (track.readyState !== 'live') return;
|
||||
|
||||
const vidElem = getScreenShareVideoElement();
|
||||
if (!vidElem || vidElem.videoWidth === 0) return;
|
||||
|
||||
try {
|
||||
// Focus check: if candidate document loses focus while screen share is running
|
||||
if (!document.hasFocus()) {
|
||||
const now = Date.now();
|
||||
if (now - lastTabSwitchTime > 3000) {
|
||||
lastTabSwitchTime = now;
|
||||
logViolation('external_ai_detected', 'PARAKEET AI OVERLAY DETECTED: External desktop overlay active on screen!');
|
||||
captureAndUploadTabScreenshot();
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function startScreenShare() {
|
||||
if (!navigator.mediaDevices.getDisplayMedia) {
|
||||
alert('Screen sharing is not supported on your browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
|
||||
.then(stream => {
|
||||
screenShareStream = stream;
|
||||
const btn = document.getElementById('share-screen-btn');
|
||||
btn.innerText = '🟢 Screen Sharing Active';
|
||||
navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'monitor'
|
||||
},
|
||||
audio: false
|
||||
})
|
||||
.then(stream => {
|
||||
const track = stream.getVideoTracks()[0];
|
||||
const settings = track.getSettings ? track.getSettings() : {};
|
||||
|
||||
// Enforce ENTIRE SCREEN share for Parakeet AI overlay detection
|
||||
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: '⚠️ Entire Screen Share Required',
|
||||
text: 'You selected a single tab or window. To detect overlay AI assistants (such as Parakeet AI) and ensure proctoring integrity, you MUST select "Entire Screen".',
|
||||
confirmButtonText: '🖥️ Reshare Entire Screen'
|
||||
}).then(() => {
|
||||
track.stop();
|
||||
setTimeout(startScreenShare, 500);
|
||||
});
|
||||
} else {
|
||||
alert('Entire Screen share is required for proctoring overlay detection. Please select Entire Screen.');
|
||||
track.stop();
|
||||
setTimeout(startScreenShare, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
screenShareStream = stream;
|
||||
const vidElem = getScreenShareVideoElement();
|
||||
vidElem.srcObject = stream;
|
||||
vidElem.play().catch(e => {});
|
||||
|
||||
const btn = document.getElementById('share-screen-btn');
|
||||
if (btn) {
|
||||
btn.innerText = '🟢 Entire Screen Sharing Active (Parakeet AI Shield ON)';
|
||||
btn.style.background = '#10b981';
|
||||
}
|
||||
|
||||
// Connect screen stream to interviewer screen peer
|
||||
const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now());
|
||||
screenPeer.on('open', () => {
|
||||
screenPeer.call('interviewer_screen_' + interviewId, stream);
|
||||
});
|
||||
requestProctoringFullscreen();
|
||||
|
||||
stream.getVideoTracks()[0].onended = () => {
|
||||
// Connect screen stream to interviewer screen peer
|
||||
const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now());
|
||||
screenPeer.on('open', () => {
|
||||
screenPeer.call('interviewer_screen_' + interviewId, stream);
|
||||
});
|
||||
|
||||
track.onended = () => {
|
||||
if (btn) {
|
||||
btn.innerText = '🖥️ Share Screen with Interviewer';
|
||||
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
||||
};
|
||||
})
|
||||
.catch(err => console.log('Screen sharing cancelled'));
|
||||
}
|
||||
logViolation('tab_switch', 'Candidate stopped screen sharing (proctoring screen share disabled)');
|
||||
};
|
||||
|
||||
initLiveOverlayDetector();
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('Screen sharing cancelled:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeCameraFrame() {
|
||||
@ -1413,8 +1714,19 @@ function pollCandidateChat() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.call_status === 'ended') {
|
||||
candidateLeaveCall();
|
||||
if (data.call_status === 'active') {
|
||||
latestCallStatus = 'active';
|
||||
if (!isCandidateCallConnected) {
|
||||
if (candidateDeclinedOrLeftCall) {
|
||||
showCandidateJoinOption();
|
||||
} else {
|
||||
triggerCandidateRing(null, data.call_started_at || null);
|
||||
}
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
latestCallStatus = data.call_status;
|
||||
candidateDeclinedOrLeftCall = false;
|
||||
candidateLeaveCall(true);
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'CALL ENDED BY HOST';
|
||||
@ -1445,7 +1757,7 @@ function pollCandidateChat() {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setInterval(pollCandidateChat, 3000);
|
||||
setInterval(pollCandidateChat, 1000);
|
||||
pollCandidateChat();
|
||||
|
||||
function sendCandidateMessage() {
|
||||
@ -1584,6 +1896,7 @@ function saveCanvasDrawing() {
|
||||
}
|
||||
|
||||
if (data.call_status === 'active') {
|
||||
latestCallStatus = 'active';
|
||||
if (!isCandidateCallConnected) {
|
||||
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
|
||||
if (elapsed <= 10000) {
|
||||
@ -1593,6 +1906,7 @@ function saveCanvasDrawing() {
|
||||
}
|
||||
}
|
||||
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
||||
latestCallStatus = data.call_status;
|
||||
candRingtone.stop();
|
||||
if (candRingTimer) clearTimeout(candRingTimer);
|
||||
isCandidateCallConnected = false;
|
||||
@ -1602,12 +1916,15 @@ function saveCanvasDrawing() {
|
||||
modal.style.pointerEvents = 'none';
|
||||
}
|
||||
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
||||
if (joinBtn) joinBtn.style.display = 'none';
|
||||
if (joinBtn) {
|
||||
joinBtn.style.display = 'none';
|
||||
joinBtn.disabled = true;
|
||||
}
|
||||
const badge = document.getElementById('call-status-badge');
|
||||
if (badge) {
|
||||
badge.innerText = 'READY';
|
||||
badge.style.background = 'rgba(16,185,129,0.2)';
|
||||
badge.style.color = '#34d399';
|
||||
badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY';
|
||||
badge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(16,185,129,0.2)';
|
||||
badge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#34d399';
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -1081,12 +1081,6 @@ function pollGlobalActiveCalls() {
|
||||
if (iAmTheCaller && currentInterviewId == call.id) return false;
|
||||
if (interviewerLocalStream && currentInterviewId == call.id) return false;
|
||||
if (dismissedCallIds.has(call.id)) return false;
|
||||
|
||||
if (call.call_started_at) {
|
||||
const elapsedMs = Date.now() - new Date(call.call_started_at).getTime();
|
||||
if (elapsedMs > 10000) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@ -1101,8 +1095,8 @@ function pollGlobalActiveCalls() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Poll for active calls every 2 seconds
|
||||
setInterval(pollGlobalActiveCalls, 2000);
|
||||
// Poll for active calls every 1 second for instant ring notification
|
||||
setInterval(pollGlobalActiveCalls, 1000);
|
||||
|
||||
function acceptIncomingCall() {
|
||||
callRingtone.stop();
|
||||
@ -1323,6 +1317,15 @@ function pollReviewData() {
|
||||
document.getElementById('btn-start-call').innerText = '📞 Join Call';
|
||||
document.getElementById('btn-start-call').style.background = '#6366f1';
|
||||
document.getElementById('btn-start-call').style.display = 'inline-block';
|
||||
document.getElementById('btn-leave-call').style.display = 'none';
|
||||
document.getElementById('btn-end-all-call').style.display = 'none';
|
||||
} else {
|
||||
callStatusBadge.innerText = 'CONNECTED (LIVE)';
|
||||
callStatusBadge.style.background = 'rgba(16,185,129,0.3)';
|
||||
callStatusBadge.style.color = '#34d399';
|
||||
document.getElementById('btn-start-call').style.display = 'none';
|
||||
document.getElementById('btn-leave-call').style.display = 'inline-block';
|
||||
document.getElementById('btn-end-all-call').style.display = 'inline-block';
|
||||
}
|
||||
// Recording button ONLY available when call has started
|
||||
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
|
||||
@ -1336,6 +1339,8 @@ function pollReviewData() {
|
||||
document.getElementById('btn-start-call').innerText = (data.call_status === 'ended') ? '📞 Restart Call' : '📞 Start / Join Call';
|
||||
document.getElementById('btn-start-call').style.background = '#10b981';
|
||||
document.getElementById('btn-start-call').style.display = 'inline-block';
|
||||
document.getElementById('btn-leave-call').style.display = 'none';
|
||||
document.getElementById('btn-end-all-call').style.display = 'none';
|
||||
|
||||
// Recording button HIDDEN when call is not active
|
||||
if (recStartBtn) recStartBtn.style.display = 'none';
|
||||
@ -1356,15 +1361,27 @@ function pollReviewData() {
|
||||
if (l.type === 'external_ai_detected') icon = '🤖';
|
||||
if (l.type === 'tab_switch') icon = '📸';
|
||||
|
||||
if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected'].includes(l.type)) {
|
||||
if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person'].includes(l.type)) {
|
||||
currentViolations.push(l);
|
||||
}
|
||||
|
||||
let screenshotLink = l.screenshot_url ? `<a href="${l.screenshot_url}" target="_blank" style="color: #38bdf8; text-decoration: underline; margin-left: 6px;">[📸 View Screenshot]</a>` : '';
|
||||
let aiBadge = '';
|
||||
|
||||
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
|
||||
if (l.ai_verdict) {
|
||||
const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100);
|
||||
const engineName = l.ai_verdict.engine || 'AI Engine';
|
||||
const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: <strong style="color:#fde047;">${l.ai_verdict.ai_tool_detected}</strong>` : '';
|
||||
|
||||
aiBadge = `<div style="margin-top: 4px; padding: 4px 8px; background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.4); border-radius: 6px; font-size: 0.7rem; color: #a5b4fc;">
|
||||
🤖 <strong>AI Inspector (${engineName}):</strong> ${confPct}% Cheating Confidence${toolName}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
logsHtml += `<div style="margin-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:6px;">
|
||||
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}${screenshotLink}
|
||||
<div style="font-size:0.65rem; color:#94a3b8;">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
||||
${aiBadge}
|
||||
<div style="font-size:0.65rem; color:#94a3b8; margin-top:2px;">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
||||
</div>`;
|
||||
});
|
||||
} else {
|
||||
@ -1730,17 +1747,22 @@ function startInterviewerCall(isRejoin = false) {
|
||||
const placeholder = document.getElementById('cand-video-placeholder');
|
||||
if (candVid) {
|
||||
candVid.srcObject = candRemoteStream;
|
||||
candVid.play().catch(e => console.log(e));
|
||||
candVid.play().catch(e => console.log('Interviewer video play error:', e));
|
||||
}
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
candRemoteStream.onaddtrack = () => {
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
if (candVid) candVid.play().catch(e => {});
|
||||
};
|
||||
|
||||
document.getElementById('interviewer-call-status').innerText = 'CONNECTED (LIVE)';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(16,185,129,0.3)';
|
||||
document.getElementById('interviewer-call-status').style.color = '#34d399';
|
||||
});
|
||||
|
||||
activeCall.on('close', () => {
|
||||
leaveInterviewerCall();
|
||||
console.log('PeerJS media call channel closed. Keeping interviewer local stream active for auto-reconnect.');
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1842,15 +1864,28 @@ function startCallRecording() {
|
||||
}
|
||||
|
||||
const candVid = document.getElementById('interviewer-cand-video');
|
||||
let streamToRecord = null;
|
||||
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
||||
let streamToRecord = new MediaStream();
|
||||
|
||||
if (candVid && candVid.srcObject) {
|
||||
streamToRecord = candVid.srcObject;
|
||||
} else if (interviewerLocalStream) {
|
||||
streamToRecord = interviewerLocalStream;
|
||||
// Add Candidate Video and Audio tracks
|
||||
if (candidateStream) {
|
||||
candidateStream.getTracks().forEach(track => streamToRecord.addTrack(track));
|
||||
}
|
||||
|
||||
if (!streamToRecord) {
|
||||
// Add Interviewer Local Microphone Audio track for 2-way call recording
|
||||
if (interviewerLocalStream) {
|
||||
interviewerLocalStream.getAudioTracks().forEach(track => {
|
||||
// Only add if not already in streamToRecord
|
||||
if (!streamToRecord.getAudioTracks().includes(track)) {
|
||||
streamToRecord.addTrack(track);
|
||||
}
|
||||
});
|
||||
if (!candidateStream) {
|
||||
interviewerLocalStream.getVideoTracks().forEach(track => streamToRecord.addTrack(track));
|
||||
}
|
||||
}
|
||||
|
||||
if (streamToRecord.getTracks().length === 0) {
|
||||
alert('No active video/audio stream available to record. Please click "Start / Join Call" first.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -298,4 +298,67 @@ public function test_executive_candidate_evaluation_pdf_report_generation(): voi
|
||||
->assertSee('Technical Code Competency')
|
||||
->assertSee('Complexity analysis: O(1)');
|
||||
}
|
||||
|
||||
public function test_silent_proctoring_violations_and_multi_party_active_call_status(): void
|
||||
{
|
||||
$hr = User::create([
|
||||
'name' => 'Interviewer One',
|
||||
'email' => 'interviewer1@company.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'Proctor Test Candidate',
|
||||
'candidate_email' => 'proctortest@gmail.com',
|
||||
'candidate_phone' => '9555544444',
|
||||
'temp_password' => 'Pass-555444',
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'python',
|
||||
'status' => 'in_progress',
|
||||
'submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006',
|
||||
]);
|
||||
|
||||
// 1. Silent Lower Screen Gaze Stare violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'gaze_lower_device',
|
||||
'details' => 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 2. Silent Question Repetition violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'question_repetition',
|
||||
'details' => 'Candidate reading/repeating question out loud: "Write a function to reverse a linked list" (suspected Parakeet AI speech prompt)',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 3. Silent Talking to Secondary Person violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'talking_secondary_person',
|
||||
'details' => 'Candidate speaking out loud to secondary person in room: "Can you search the solution on your laptop?"',
|
||||
])->assertStatus(200);
|
||||
|
||||
// 4. Silent External AI Overlay Hotkey violation
|
||||
$this->post(route('interview.violation', $interview->id), [
|
||||
'type' => 'external_ai_detected',
|
||||
'details' => 'Parakeet AI / External AI Hotkey Intercepted (KeyA): Candidate activated external AI assistant overlay shortcut',
|
||||
])->assertStatus(200);
|
||||
|
||||
// Verify poll endpoint returns all 4 silent proctoring violations to interviewer dashboard
|
||||
$pollResp = $this->get(route('interview.poll', $interview->id));
|
||||
$pollResp->assertStatus(200)
|
||||
->assertJsonFragment(['type' => 'gaze_lower_device'])
|
||||
->assertJsonFragment(['type' => 'question_repetition'])
|
||||
->assertJsonFragment(['type' => 'talking_secondary_person'])
|
||||
->assertJsonFragment(['type' => 'external_ai_detected']);
|
||||
|
||||
// 5. Update call status for multi-party call test
|
||||
$callResp = $this->actingAs($hr)->post(route('interview.call-status', $interview->id), [
|
||||
'call_status' => 'active',
|
||||
]);
|
||||
$callResp->assertStatus(200)->assertJson(['success' => true, 'call_status' => 'active']);
|
||||
|
||||
// Active calls endpoint should list the active session for multiple interviewers to join
|
||||
$activeCallsResp = $this->actingAs($hr)->get(route('interview.active-calls'));
|
||||
$activeCallsResp->assertStatus(200)
|
||||
->assertJsonFragment(['submission_unique_id' => 'proctor-test-candidate_9555544444_1752000006']);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user