diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index c5c1282..5bffbb1 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -279,9 +279,9 @@ public function logViolation(Request $request, $id) $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 + // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call $aiVerdict = null; - if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person'])) { + if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { $aiDetector = new \App\Services\AiCheatingDetectorService(); $aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? ''); } diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 3e1729b..7e77c14 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -609,6 +609,8 @@ function getScreenShareVideoElement() { // REAL CANDIDATE SYSTEM / COMPUTER SCREENSHOT CAPTURE ENGINE function captureAndUploadTabScreenshot(reason = 'tab_switch') { if (!isTabSwitchScreenshotEnabled) return; + if (latestCallStatus !== 'active') return; // Screenshot ONLY when call is active + if (reason !== 'tab_switch' && reason !== 'call_start') return; // Screenshot ONLY when candidate switches tab try { const tempCanvas = document.createElement('canvas'); @@ -897,13 +899,13 @@ function analyzeEyeGazeAndStaring() { // 2.4s initial alert if (gazeDownwardCounter === 6) { - logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk'); + logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom, desk, or secondary device'); } - // 10.0s continuous lower screen stare alert (logs to proctor logs section in reviewer modal - SILENT FOR CANDIDATE) + // 10.0s continuous lower screen stare alert 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)'); + gazeDownwardDurationMs = 0; + logViolation('reading_external_device', 'Candidate continuously looking down at lower screen or desk for 10s (suspected reading from mobile device, secondary screen, or cheat sheet)'); } } else { gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1); @@ -923,7 +925,7 @@ function analyzeEyeGazeAndStaring() { if (frameDelta >= 0.1 && frameDelta < 3.8) { gazeFixedStaringCounter++; if (gazeFixedStaringCounter === 25) { // 10.0 seconds of continuous fixed stare - logViolation('gaze_fixed_staring', 'Candidate maintaining fixed unnatural gaze (possible secondary screen/AI assistant)'); + logViolation('reading_external_device', 'Candidate maintaining fixed unnatural off-center gaze for 10s (suspected reading from external device or secondary monitor)'); } } else { gazeFixedStaringCounter = Math.max(0, gazeFixedStaringCounter - 1); @@ -972,11 +974,17 @@ function initQuestionRepeatDetection() { candidateSpeechHistory.push(transcript); if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift(); - if (isReadingQuestionOnScreen || isRepeatedSpeech) { + // Phone call speech detection keywords + const phoneKeywords = ['hello', 'can you hear me', 'on phone', 'calling', 'call you back', 'hold on', 'on the line', 'speaker', 'phone', 'mobile', 'call me', 'speak later', 'hey call', 'on a call']; + const isPhoneTalk = phoneKeywords.some(kw => transcript.includes(kw)); + + if (isPhoneTalk) { + logViolation('talking_on_phone', `Candidate detected talking on phone / phone call during assessment: "${transcript}"`); + } else if (isReadingQuestionOnScreen || isRepeatedSpeech) { if (gazeDownwardCounter > 3) { - logViolation('question_repeat_lower', `Candidate reading/repeating question ("${transcript}") while looking down at lower portion/mobile device`); + logViolation('reading_external_device', `Candidate detected reading from external device or mobile screen: "${transcript}"`); } else { - logViolation('question_repetition', `Candidate reading/repeating question out loud: "${transcript}" (suspected Parakeet AI speech prompt)`); + logViolation('question_repetition', `Candidate detected reading / repeating question out loud: "${transcript}" (suspected AI prompt ingestion)`); } } else { // Talking to someone else detection when speech is detected during non-call state diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index df0aa62..7e2a503 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -1506,12 +1506,13 @@ function pollReviewData() { let icon = '🔴'; if (l.type === 'focus_lost') icon = '🟡'; if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') icon = '👁️'; - if (l.type === 'gaze_lower_device') icon = '📱'; + if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') icon = '📱'; if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') icon = '🗣️'; + if (l.type === 'talking_on_phone') icon = '📞'; 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', 'talking_secondary_person'].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', 'talking_on_phone', 'reading_external_device'].includes(l.type)) { currentViolations.push(l); } @@ -1552,9 +1553,13 @@ function pollReviewData() { sosBtn.style.animation = 'sosPulse 1.2s infinite alternate'; const hasExternalAi = currentViolations.some(v => v.type === 'external_ai_detected'); const hasTabSwitch = currentViolations.some(v => v.type === 'tab_switch'); + const hasPhoneTalk = currentViolations.some(v => v.type === 'talking_on_phone'); if (hasExternalAi) { sosBtn.innerText = '🚨 candidate might use external ai'; sosBtn.style.background = 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)'; + } else if (hasPhoneTalk) { + sosBtn.innerText = '📞 Candidate Talking on Phone'; + sosBtn.style.background = 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)'; } else if (hasTabSwitch) { sosBtn.innerText = '📸 Candidate Switched Browser Tab'; sosBtn.style.background = 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'; @@ -1580,6 +1585,14 @@ function openSosModal() { typeTitle = 'candidate might use external ai'; } else if (v.type === 'tab_switch') { typeTitle = 'Candidate Switched Browser Tab'; + } else if (v.type === 'talking_on_phone') { + typeTitle = 'Candidate Talking on Phone'; + } else if (v.type === 'reading_external_device') { + typeTitle = 'Candidate Reading from External Device'; + } else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') { + typeTitle = 'Candidate Reading / Repeating Question Out Loud'; + } else if (v.type === 'gaze_anomaly' || v.type === 'gaze_lower_device') { + typeTitle = 'Candidate Lower Eye Gaze Detected'; } html += `
🚨 ${typeTitle}: ${v.details || 'Suspicious candidate activity detected.'} diff --git a/tests/Feature/CandidateInterviewPortalTest.php b/tests/Feature/CandidateInterviewPortalTest.php index 0534429..82c6b62 100644 --- a/tests/Feature/CandidateInterviewPortalTest.php +++ b/tests/Feature/CandidateInterviewPortalTest.php @@ -393,8 +393,23 @@ public function test_tab_switch_logging_and_external_ai_detection_behavior(): vo ]); $aiResp->assertStatus(200); - $poll2 = $this->get(route('interview.poll', $interview->id)); - $poll2->assertStatus(200) - ->assertJsonFragment(['type' => 'external_ai_detected']); + // 3. Talking on phone violation + $phoneResp = $this->post(route('interview.violation', $interview->id), [ + 'type' => 'talking_on_phone', + 'details' => 'Candidate detected talking on phone / phone call during assessment: "hello hold on calling you back"', + ]); + $phoneResp->assertStatus(200); + + // 4. Reading from external device violation + $extDevResp = $this->post(route('interview.violation', $interview->id), [ + 'type' => 'reading_external_device', + 'details' => 'Candidate detected reading from external device, mobile screen, or secondary monitor', + ]); + $extDevResp->assertStatus(200); + + $poll3 = $this->get(route('interview.poll', $interview->id)); + $poll3->assertStatus(200) + ->assertJsonFragment(['type' => 'talking_on_phone']) + ->assertJsonFragment(['type' => 'reading_external_device']); } }