implement new feature

This commit is contained in:
subhajit 2026-07-23 17:23:32 +05:30
parent d13d8c61b3
commit 838684affc
10 changed files with 538 additions and 53 deletions

View File

@ -411,6 +411,8 @@ public function getPollData($id)
'temp_password' => $interview->temp_password,
'blocked_ip' => $interview->blocked_ip,
'proctor_logs' => $interview->proctor_logs ?? [],
'recording_path' => $interview->recording_path,
'recordings' => $interview->recordings ?? [],
'warnings' => $interview->warnings,
'is_expired' => $interview->isExpired(),
]);
@ -436,16 +438,65 @@ public function uploadRecording(Request $request, $id)
if ($request->hasFile('video')) {
$file = $request->file('video');
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm';
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4';
$path = $file->storeAs('public/recordings', $filename);
$url = Storage::url($path);
$interview->update(['recording_path' => Storage::url($path)]);
return response()->json(['success' => true, 'path' => Storage::url($path)]);
$recordings = $interview->recordings ?? [];
if (!is_array($recordings)) {
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
}
$recordings[] = [
'url' => $url,
'filename' => $filename,
'created_at' => now()->toIso8601String(),
];
$interview->update([
'recording_path' => $url,
'recordings' => $recordings,
]);
return response()->json(['success' => true, 'path' => $url, 'recordings' => $recordings]);
}
return response()->json(['success' => false, 'message' => 'No video payload received']);
}
/**
* Download Candidate Video Recording as MP4 File.
*/
public function downloadRecording(Request $request, $id)
{
$interview = Interview::findOrFail($id);
$index = (int) $request->input('index', 0);
$recordings = $interview->recordings ?? [];
$targetUrl = null;
if (!empty($recordings) && isset($recordings[$index])) {
$rec = $recordings[$index];
$targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec;
}
if (!$targetUrl) {
$targetUrl = $interview->recording_path;
}
if (!$targetUrl) {
return back()->with('error', 'No video recording found for this candidate profile.');
}
$relativePath = str_replace('/storage/', 'public/', $targetUrl);
if (Storage::exists($relativePath)) {
$downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4';
return Storage::download($relativePath, $downloadFilename, [
'Content-Type' => 'video/mp4',
]);
}
return redirect($targetUrl);
}
/**
* Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View).
*/
@ -453,18 +504,22 @@ public function generateReport($id)
{
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
$logs = $interview->proctor_logs ?? [];
$logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : [];
$tabSwitches = 0;
$focusLosses = 0;
$gazeAnomalies = 0;
$pasteEvents = 0;
$lowerGazeViolations = 0;
$questionRepeatViolations = 0;
foreach ($logs as $l) {
$type = $l['type'] ?? '';
if ($type === 'tab_switch') $tabSwitches++;
if ($type === 'focus_lost') $focusLosses++;
if ($type === 'gaze_anomaly') $gazeAnomalies++;
if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++;
if ($type === 'paste_event') $pasteEvents++;
if ($type === 'gaze_lower_device') $lowerGazeViolations++;
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++;
}
$totalViolations = count($logs);
@ -474,7 +529,7 @@ public function generateReport($id)
$integrityScore = 100;
$integrityLabel = 'Exceptional Integrity';
$integrityColor = '#10b981';
} elseif ($totalViolations <= 2) {
} elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) {
$integrityScore = 85;
$integrityLabel = 'Low Behavioral Risk';
$integrityColor = '#06b6d4';
@ -488,7 +543,7 @@ public function generateReport($id)
$integrityColor = '#ef4444';
}
// Calculate Technical Code Score
// Technical Code Score
$codeScore = 0;
if (!empty($interview->submitted_code)) {
$codeScore += 50;
@ -510,6 +565,8 @@ public function generateReport($id)
'focusLosses',
'gazeAnomalies',
'pasteEvents',
'lowerGazeViolations',
'questionRepeatViolations',
'integrityScore',
'integrityLabel',
'integrityColor',

View File

@ -24,6 +24,7 @@ class Interview extends Model
'submitted_language',
'code_output',
'recording_path',
'recordings',
'submission_unique_id',
'candidate_notes',
'candidate_drawing',
@ -37,6 +38,7 @@ class Interview extends Model
'expires_at' => 'datetime',
'assigned_interviewers' => 'array',
'proctor_logs' => 'array',
'recordings' => 'array',
];
public function creator()

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('interviews', function (Blueprint $table) {
if (!Schema::hasColumn('interviews', 'interviewer_notes')) {
$table->text('interviewer_notes')->nullable()->after('candidate_notes');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
if (Schema::hasColumn('interviews', 'interviewer_notes')) {
$table->dropColumn('interviewer_notes');
}
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->json('recordings')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->dropColumn('recordings');
});
}
};

BIN
public/Screenshot_13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

BIN
public/Screenshot_14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -188,11 +188,6 @@
</head>
<body>
<!-- Silent Warning Banner -->
<div id="warning-banner">
🚨 ATTENTION: <span id="warning-text">Please keep your eyes on the screen during the assessment.</span>
</div>
<!-- IDE Header Bar -->
<header class="ide-header">
<div style="display: flex; align-items: center; gap: 12px;">
@ -384,17 +379,17 @@
let recordedChunks = [];
let previousFrameData = null;
// Default Code Snippets for Languages
const defaultCode = {
python: `# Python 3 Assessment Solution\ndef main():\n print("Hello from SingleLogin Assessment!")\n num = 42\n print(f"Computed Result: {num * 2}")\n\nif __name__ == "__main__":\n main()`,
cpp: `// C++ Assessment Solution\n#include <iostream>\nusing namespace std;\n\nint main() {\n cout << "Hello from SingleLogin Assessment!" << endl;\n return 0;\n}`,
c: `// C Assessment Solution\n#include <stdio.h>\n\nint main() {\n printf("Hello from SingleLogin Assessment!\\n");\n return 0;\n}`,
java: `// Java Assessment Solution\npublic class Solution {\n public static void main(String[] args) {\n System.out.println("Hello from SingleLogin Assessment!");\n }\n}`,
php: `<?php\n// PHP / Laravel Assessment Solution\necho "Hello from SingleLogin Assessment!\\n";\n$data = [1, 2, 3, 4, 5];\necho "Sum: " . array_sum($data);`,
javascript: `// JavaScript (Node.js) Solution\nconsole.log("Hello from SingleLogin Assessment!");\nconst nums = [10, 20, 30];\nconsole.log("Total:", nums.reduce((a, b) => a + b, 0));`
python: "# Python 3 Assessment Solution\ndef main():\n print(\"Hello from SingleLogin Assessment!\")\n num = 42\n print(f\"Computed Result: {num * 2}\")\n\nif __name__ == \"__main__\":\n main()",
cpp: "// C++ Assessment Solution\n#include <iostream>\nusing namespace std;\n\nint main() {\n cout << \"Hello from SingleLogin Assessment!\" << endl;\n return 0;\n}",
c: "// C Assessment Solution\n#include <stdio.h>\n\nint main() {\n printf(\"Hello from SingleLogin Assessment!\\n\");\n return 0;\n}",
java: "// Java Assessment Solution\npublic class Solution {\n public static void main(String[] args) {\n System.out.println(\"Hello from SingleLogin Assessment!\");\n }\n}",
php: "<\x3fphp\n// PHP / Laravel Assessment Solution\necho \"Hello from SingleLogin Assessment!\\n\";\n$data = [1, 2, 3, 4, 5];\necho \"Sum: \" . array_sum($data);",
javascript: "// JavaScript (Node.js) Solution\nconsole.log(\"Hello from SingleLogin Assessment!\");\nconst nums = [10, 20, 30];\nconsole.log(\"Total:\", nums.reduce((a, b) => a + b, 0));"
};
// Initialize Monaco Editor
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
require(['vs/editor/editor.main'], function () {
const initialLang = '{{ strtolower($interview->language) }}';
@ -499,16 +494,166 @@ function submitSolution() {
}
});
// 2. Window Focus Loss / External Screen Overlap Detection
// 2. Window Focus Loss / External AI Overlay Window Switch Detection (Parakeet AI)
window.addEventListener('blur', function () {
logViolation('focus_lost', 'Window lost focus (possible external AI overlay or screen overlap)');
logViolation('external_ai_detected', 'External AI Assistant Suspicion: Candidate window lost focus (interacting with floating overlay window or Parakeet AI widget)');
});
// 3. Code Copy-Paste Detection
// 3. Code Copy-Paste & Ingestion Detection
document.addEventListener('paste', function (e) {
logViolation('paste_event', 'Candidate pasted content into editor window');
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
});
// 4. Parakeet AI Global Hotkey Interception (Alt+Space, Cmd+Space, Ctrl+Shift+A/C/X, Win Key)
document.addEventListener('keydown', function (e) {
const isAltSpace = e.altKey && (e.code === 'Space' || e.keyCode === 32);
const isCmdSpace = e.metaKey && (e.code === 'Space' || e.keyCode === 32);
const isCtrlShift = e.ctrlKey && e.shiftKey && ['KeyA', 'KeyC', 'KeyX', 'KeyV'].includes(e.code);
const isWinKey = e.key === 'Meta' || e.key === 'Win';
if (isAltSpace || isCmdSpace || isCtrlShift || isWinKey) {
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay (Parakeet AI / Hotkey shortcut)`);
}
});
// 5. 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');
}
}, 6000);
// --- ACCURATE EYE GAZE, 10-SECOND LOWER SCREEN STARE & QUESTION REPETITION DETECTION ---
let gazeDownwardCounter = 0;
let gazeDownwardDurationMs = 0;
let hasLogged10sLowerGaze = false;
let gazeFixedStaringCounter = 0;
let previousFramePixels = null;
function analyzeEyeGazeAndStaring() {
const video = document.getElementById('webcam');
const canvas = document.getElementById('proctor-canvas');
if (!video || !canvas || video.readyState !== 4) return;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imgData.data;
let totalDarkPixels = 0;
let lowerQuarterDarkPixels = 0;
const quarterY = Math.floor(canvas.height * 0.70);
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const idx = (y * canvas.width + x) * 4;
const gray = (pixels[idx] + pixels[idx+1] + pixels[idx+2]) / 3;
if (gray < 45) {
totalDarkPixels++;
if (y > quarterY) lowerQuarterDarkPixels++;
}
}
}
const lowerRatio = totalDarkPixels > 0 ? (lowerQuarterDarkPixels / totalDarkPixels) : 0;
// Lowered Gaze Check (looking down at desk/screen bottom)
if (lowerRatio > 0.40) {
gazeDownwardCounter++;
gazeDownwardDurationMs += 400;
if (gazeDownwardCounter === 6) {
logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk');
}
// Fix: 10.0 seconds of accumulated/continuous lower screen gaze (triggers log + SOS alert)
if (gazeDownwardDurationMs >= 10000 && !hasLogged10sLowerGaze) {
hasLogged10sLowerGaze = true;
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for more than 10s (suspected reading from mobile or external device)');
}
} else {
gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1);
if (gazeDownwardCounter === 0) {
gazeDownwardDurationMs = 0;
hasLogged10sLowerGaze = false;
}
}
// Fixed Screen Focus / Staring (10 seconds = 25 cycles * 400ms) Check
if (previousFramePixels) {
let frameDeltaSum = 0;
for (let i = 0; i < pixels.length; i += 16) {
frameDeltaSum += Math.abs(pixels[i] - previousFramePixels[i]);
}
const frameDelta = frameDeltaSum / (pixels.length / 16);
if (frameDelta >= 0.1 && frameDelta < 3.8) {
gazeFixedStaringCounter++;
if (gazeFixedStaringCounter === 25) { // 10.0 seconds of continuous fixed stare
logViolation('gaze_fixed_staring', 'candidate might see other screen');
}
} else {
gazeFixedStaringCounter = Math.max(0, gazeFixedStaringCounter - 1);
}
}
previousFramePixels = new Uint8Array(pixels);
}
setInterval(analyzeEyeGazeAndStaring, 400);
// --- WEB SPEECH RECOGNITION FOR QUESTION REPETITION & AI SPEECH PROMPTING ---
let candidateSpeechHistory = [];
function initQuestionRepeatDetection() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
try {
const speechRec = new SpeechRecognition();
speechRec.continuous = true;
speechRec.interimResults = false;
speechRec.lang = 'en-US';
speechRec.onresult = function(event) {
for (let i = event.resultIndex; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
const transcript = event.results[i][0].transcript.trim().toLowerCase();
if (transcript.length < 4) continue;
// Check if candidate is repeating recent spoken text
const isRepeated = 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 (gazeDownwardCounter > 3) {
logViolation('question_repeat_lower', `Candidate 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)`);
}
}
}
}
};
speechRec.onerror = function() {
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000);
};
speechRec.onend = function() {
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500);
};
speechRec.start();
} catch(e) {}
}
initQuestionRepeatDetection();
function logViolation(type, details) {
fetch(`{{ route('interview.violation', $interview->id) }}`, {
method: 'POST',
@ -518,6 +663,11 @@ function logViolation(type, details) {
},
body: JSON.stringify({ type: type, details: details })
});
// Post message over BroadcastChannel to alert interviewer dashboard real-time
if (typeof callSigChannel !== 'undefined' && callSigChannel) {
callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details });
}
}
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)

View File

@ -246,7 +246,10 @@
$logs = $inv->proctor_logs ?? [];
$tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch'));
$focusLost = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost'));
$gazeAlerts = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_anomaly'));
$gazeAlerts = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring'])));
$lowerGaze = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_lower_device'));
$questionRepeat = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition'])));
$externalAi = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'external_ai_detected'));
@endphp
<tr>
<td>
@ -306,6 +309,21 @@
<span style="color: {{ $gazeAlerts > 0 ? '#f43f5e' : 'var(--text-muted)' }};">
👁️ Eye Gaze Anomaly: {{ $gazeAlerts }}
</span>
@if($lowerGaze > 0)
<span style="color: #ef4444; font-weight: 700;">
📱 Lower Gaze (>10s): {{ $lowerGaze }}
</span>
@endif
@if($questionRepeat > 0)
<span style="color: #f43f5e; font-weight: 700;">
🗣️ Question Repetition: {{ $questionRepeat }}
</span>
@endif
@if($externalAi > 0)
<span style="color: #ef4444; font-weight: 800; background: rgba(239,68,68,0.2); padding: 2px 6px; border-radius: 4px;">
🤖 Parakeet / External AI: {{ $externalAi }}
</span>
@endif
</div>
</td>
<td style="text-align: right; white-space: nowrap;">
@ -431,7 +449,7 @@
<!-- SOS CHEATING ALERT BUTTON (Flashing Red) -->
<button id="sos-alert-btn" onclick="openSosModal()" class="btn-nav" style="display: none; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; box-shadow: 0 0 15px rgba(239,68,68,0.7); cursor: pointer; animation: sosPulse 1.2s infinite alternate;">
🚨 SOS CHEATING DETECTED!
🚨 candidate might cheating
</button>
<!-- REGENERATE CANDIDATE PASSWORD -->
@ -461,6 +479,12 @@
<button id="btn-start-call" onclick="startInterviewerCall()" class="btn-nav" style="background: #10b981; color: white; border: none; font-weight: 700; padding: 6px 14px; cursor: pointer;">
📞 Start / Join Call
</button>
<button id="btn-start-recording" onclick="startCallRecording()" class="btn-nav" style="background: rgba(239,68,68,0.2); border-color: #ef4444; color: #fca5a5; font-weight: 700; padding: 6px 14px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
⏺️ Record Call (Silent)
</button>
<button id="btn-stop-recording" onclick="stopCallRecording()" class="btn-nav" style="display: none; background: #ef4444; color: white; border: none; font-weight: 700; padding: 6px 14px; cursor: pointer; animation: pulse 1s infinite alternate;">
⏹️ Stop & Save Recording
</button>
<button id="btn-leave-call" onclick="leaveInterviewerCall()" class="btn-nav" style="display: none; background: rgba(234,179,8,0.2); border-color: #eab308; color: #fde047; font-size: 0.75rem; font-weight: 600;" title="Leave your individual connection without ending call for others">
🚪 Leave Call
</button>
@ -559,16 +583,17 @@
<span style="font-size: 0.7rem; color: #34d399;"> Live Synced</span>
</div>
<!-- Tab Switcher for Code Solution, Notepad, Drawing -->
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings -->
<div style="display: flex; gap: 6px;">
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
<button id="tab-btn-recordings" onclick="switchIdeTab('recordings')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📹 Saved Recordings (.MP4)</button>
</div>
</div>
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
<!-- Left Column: Tab Content (Code Solution, Notepad, or Canvas Drawing) -->
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, or Saved Video Recordings) -->
<div>
<!-- Tab 1: Live Code Solution & Terminal Output -->
<div id="tab-content-code" style="display: block;">
@ -593,6 +618,14 @@
<div id="no-drawing-placeholder" style="color: #94a3b8; font-size: 0.8rem;">No drawing data submitted by candidate yet.</div>
</div>
</div>
<!-- Tab 4: Saved Candidate Profile Video Recordings & MP4 Downloads -->
<div id="tab-content-recordings" style="display: none;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Downloadable in MP4 format):</div>
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 350px; overflow-y: auto;">
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
</div>
</div>
</div>
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
@ -633,7 +666,7 @@
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(239,68,68,0.3); padding-bottom: 12px; margin-bottom: 14px;">
<div style="font-size: 2.2rem;">🚨</div>
<div>
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">SOS CHEATING DETECTED</h3>
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">candidate might cheating</h3>
<div style="font-size: 0.72rem; color: #94a3b8;">Proctoring engine flagged candidate cheating activity!</div>
</div>
</div>
@ -739,6 +772,7 @@ function switchIdeTab(tab) {
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none';
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none';
document.getElementById('tab-content-recordings').style.display = (tab === 'recordings') ? 'block' : 'none';
document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
@ -748,6 +782,9 @@ function switchIdeTab(tab) {
document.getElementById('tab-btn-drawing').style.background = (tab === 'drawing') ? '#06b6d4' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-drawing').style.color = (tab === 'drawing') ? 'white' : '#94a3b8';
document.getElementById('tab-btn-recordings').style.background = (tab === 'recordings') ? '#10b981' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-recordings').style.color = (tab === 'recordings') ? 'white' : '#94a3b8';
}
function openReviewModal(id, name, uid) {
@ -795,6 +832,35 @@ function pollReviewData() {
noDrawing.style.display = 'block';
}
// Render Candidate Saved Video Recordings in Profile Modal
const recContainer = document.getElementById('modal-recordings-container');
if (recContainer) {
const recPath = data.recording_path;
const recList = data.recordings || [];
if (recPath || recList.length > 0) {
let recHtml = '';
let allRecs = recList.length > 0 ? recList : [{ url: recPath, created_at: new Date().toISOString() }];
allRecs.forEach((r, idx) => {
const videoUrl = typeof r === 'string' ? r : (r.url || recPath);
const dateStr = r.created_at ? new Date(r.created_at).toLocaleTimeString() : 'Session Recording';
const downloadUrl = `{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${idx}`;
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="color: white; font-size: 0.8rem;">📹 Video Session Recording #${idx + 1} (${dateStr})</strong>
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none;">
📥 Download MP4 Video
</a>
</div>
<video src="${videoUrl}" controls style="width: 100%; max-height: 200px; border-radius: 8px; background: #000; outline: none;"></video>
</div>`;
});
recContainer.innerHTML = recHtml;
} else {
recContainer.innerHTML = '<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>';
}
}
const chatHistory = document.getElementById('interviewer-chat-history');
if (chatHistory && data.warnings && data.warnings.length > 0) {
let chatHtml = '';
@ -844,8 +910,12 @@ function pollReviewData() {
data.proctor_logs.forEach(l => {
let icon = '🔴';
if (l.type === 'focus_lost') icon = '🟡';
if (l.type === 'gaze_anomaly') icon = '👁️';
if (l.type === 'paste_event' || l.type === 'tab_switch' || l.type === 'focus_lost' || l.type === 'gaze_anomaly') {
if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') icon = '👁️';
if (l.type === 'gaze_lower_device') icon = '📱';
if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') icon = '🗣️';
if (l.type === 'external_ai_detected') 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)) {
currentViolations.push(l);
}
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
@ -869,7 +939,7 @@ function pollReviewData() {
if (totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) {
sosDismissed = false;
sosBtn.style.animation = 'sosPulse 1.2s infinite alternate';
sosBtn.innerText = '🚨 SOS CHEATING DETECTED!';
sosBtn.innerText = '🚨 candidate might cheating';
sosBtn.style.background = 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)';
openSosModal();
}
@ -1064,6 +1134,10 @@ function initInterviewerSigChannel() {
const msg = event.data;
if (!msg) return;
if (msg.type === 'violation_occurred') {
pollReviewData();
}
if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') {
if (msg.offer) {
await handleCandidateOffer(msg.offer);
@ -1285,6 +1359,111 @@ function endCallForAll() {
}
}
// --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS ---
let adminMediaRecorder = null;
let adminRecordedChunks = [];
function startCallRecording() {
if (!currentInterviewId) {
alert('Please open an active candidate interview session first.');
return;
}
const candVid = document.getElementById('interviewer-cand-video');
let streamToRecord = null;
if (candVid && candVid.srcObject) {
streamToRecord = candVid.srcObject;
} else if (interviewerLocalStream) {
streamToRecord = interviewerLocalStream;
}
if (!streamToRecord) {
alert('No active video/audio stream available to record. Please click "Start / Join Call" first.');
return;
}
adminRecordedChunks = [];
try {
const options = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? { mimeType: 'video/webm;codecs=vp9' }
: (MediaRecorder.isTypeSupported('video/webm') ? { mimeType: 'video/webm' } : {});
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
adminMediaRecorder.ondataavailable = function(e) {
if (e.data && e.data.size > 0) {
adminRecordedChunks.push(e.data);
}
};
adminMediaRecorder.onstop = function() {
if (adminRecordedChunks.length === 0) return;
const blob = new Blob(adminRecordedChunks, { type: 'video/webm' });
uploadRecordedBlob(blob);
};
adminMediaRecorder.start(1000);
document.getElementById('btn-start-recording').style.display = 'none';
document.getElementById('btn-stop-recording').style.display = 'inline-flex';
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'info',
title: 'Recording Started',
text: 'Interview call recording is active (silent mode - candidate is not notified).',
toast: true,
position: 'top-end',
timer: 3000,
showConfirmButton: false
});
}
} catch(e) {
alert('Recording failed to initialize: ' + e.message);
}
}
function stopCallRecording() {
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
adminMediaRecorder.stop();
}
document.getElementById('btn-start-recording').style.display = 'inline-flex';
document.getElementById('btn-stop-recording').style.display = 'none';
}
function uploadRecordedBlob(blob) {
if (!currentInterviewId) return;
const formData = new FormData();
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.mp4`);
formData.append('_token', '{{ csrf_token() }}');
fetch(`{{ route('interview.upload-recording', ':id') }}`.replace(':id', currentInterviewId), {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(res => {
if (res.success) {
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'success',
title: 'Recording Saved!',
text: 'Call recording saved successfully under Candidate Profile and Report.',
toast: true,
position: 'top-end',
timer: 3500,
showConfirmButton: false
});
}
pollReviewData();
}
})
.catch(err => console.log('Recording upload error:', err));
}
function toggleInterviewerMic() {
if (!interviewerLocalStream) return;
const audioTracks = interviewerLocalStream.getAudioTracks();

View File

@ -206,8 +206,8 @@
max-width: 100%;
padding: 0;
}
.print-btn {
display: none;
.print-btn, .top-download-pdf-btn {
display: none !important;
}
}
</style>
@ -219,6 +219,16 @@
</button>
<div class="report-container">
<!-- Top Bar with Download PDF Button -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border);">
<div style="font-size: 0.85rem; font-weight: 700; color: var(--primary); display: flex; align-items: center; gap: 6px;">
<span>📄 Executive Assessment Performance Report</span>
</div>
<button onclick="window.print()" class="top-download-pdf-btn" style="background: #6366f1; color: white; border: none; font-weight: 700; font-size: 0.85rem; padding: 10px 20px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 14px rgba(99,102,241,0.3);">
📥 Download PDF Report
</button>
</div>
<!-- Header -->
<div class="header-bar">
<div>
@ -302,26 +312,34 @@
<div class="section-heading">
🔍 Behavioral Audit & Proctoring Incident Breakdown
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px;">
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.2rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 20px;">
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
</div>
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.2rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
</div>
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.2rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
</div>
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.2rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
</div>
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $lowerGazeViolations }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">📱 Lower Gaze</div>
</div>
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
<div style="font-size: 1.1rem; font-weight: 800; color: #f43f5e;">{{ $questionRepeatViolations }}</div>
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">🗣️ Question Repeat</div>
</div>
</div>
@if(count($logs) > 0)
@if(is_array($logs) && count($logs) > 0)
<table class="audit-table">
<thead>
<tr>
@ -362,20 +380,38 @@
{{ $interview->code_output ?? 'No execution output recorded.' }}
</div>
<!-- Candidate Notes & Drawing Section -->
@if(!empty($interview->candidate_notes) || !empty($interview->candidate_drawing))
<!-- Candidate Notes, Interviewer Evaluation Notes, Drawing & Video Recordings Section -->
@if(!empty($interview->candidate_notes) || !empty($interview->interviewer_notes) || !empty($interview->candidate_drawing) || (is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0))
<div class="section-heading">
📝 Workspace Artifacts (Scratchpad Notes & Architecture Drawing)
📝 Workspace Artifacts, Evaluation Notes & Call Session Recordings
</div>
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 28px;">
@if(!empty($interview->candidate_notes))
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTES:</div>
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):</div>
<pre style="font-family: monospace; font-size: 0.8rem; background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;">{{ $interview->candidate_notes }}</pre>
@endif
@if(!empty($interview->interviewer_notes))
<div style="font-size: 0.75rem; font-weight: 700; color: #10b981; margin-bottom: 6px;">INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):</div>
<pre style="font-family: sans-serif; font-size: 0.8rem; background: #f0fdf4; color: #166534; padding: 10px; border-radius: 8px; border: 1px solid #bbf7d0; margin-bottom: 12px;">{{ $interview->interviewer_notes }}</pre>
@endif
@if(!empty($interview->candidate_drawing))
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE DRAWING DIAGRAM:</div>
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border);" />
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;" />
@endif
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px;">📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):</div>
@foreach($interview->formatted_recordings as $idx => $rec)
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ $idx + 1 }} ({{ $rec['created_at'] }})</span>
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none;">📥 Download MP4 Video</a>
</div>
<video src="{{ $rec['stream_url'] }}" controls style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;"></video>
</div>
@endforeach
@endif
</div>
@endif

View File

@ -69,6 +69,7 @@
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
});