Codingo / backend /templates /interview.html
husseinelsaadi's picture
check up done
cb47676
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Interview - Interactive Session</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.interview-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 800px;
min-height: 600px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: linear-gradient(45deg, #4CAF50, #45a049);
color: white;
padding: 20px;
text-align: center;
position: relative;
}
.header h1 {
font-size: 1.8rem;
margin-bottom: 5px;
}
.header p {
opacity: 0.9;
font-size: 0.9rem;
}
.chat-area {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 400px;
overflow-y: auto;
}
.ai-message {
display: flex;
align-items: flex-start;
gap: 15px;
animation: slideIn 0.5s ease-out;
}
.ai-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(45deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.2rem;
position: relative;
flex-shrink: 0;
}
.ai-avatar.talking {
animation: pulse 1.5s infinite;
}
.ai-avatar.talking::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(102, 126, 234, 0.3);
animation: ripple 1.5s infinite;
}
.message-bubble {
background: #f8f9fa;
border-radius: 15px;
padding: 15px 20px;
max-width: 70%;
position: relative;
}
.message-bubble::before {
content: '';
position: absolute;
left: -10px;
top: 15px;
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid #f8f9fa;
}
.user-message {
display: flex;
justify-content: flex-end;
animation: slideIn 0.5s ease-out;
}
.user-bubble {
background: linear-gradient(45deg, #4CAF50, #45a049);
color: white;
border-radius: 15px;
padding: 15px 20px;
max-width: 70%;
position: relative;
}
.user-bubble::before {
content: '';
position: absolute;
right: -10px;
top: 15px;
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 10px solid #4CAF50;
}
.controls-area {
background: #f8f9fa;
padding: 20px;
border-top: 1px solid #e9ecef;
}
.recording-section {
display: flex;
flex-direction: column;
gap: 15px;
}
.mic-container {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
}
.mic-button {
width: 80px;
height: 80px;
border-radius: 50%;
border: none;
background: linear-gradient(45deg, #ff6b6b, #ee5a52);
color: white;
font-size: 1.8rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.mic-button:hover {
transform: scale(1.05);
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.3);
}
.mic-button.recording {
background: linear-gradient(45deg, #ff4757, #ff3742);
animation: recordPulse 1s infinite;
}
.mic-button.recording::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255, 71, 87, 0.3);
animation: ripple 1s infinite;
}
.recording-status {
font-size: 0.9rem;
color: #666;
text-align: center;
}
.transcript-area {
background: white;
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 15px;
font-size: 0.9rem;
color: #333;
min-height: 60px;
max-height: 120px;
overflow-y: auto;
transition: border-color 0.3s ease;
}
.transcript-area:focus {
outline: none;
border-color: #4CAF50;
}
.action-buttons {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 15px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(45deg, #4CAF50, #45a049);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(76, 175, 80, 0.3);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.question-counter {
position: absolute;
top: 15px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
padding: 5px 12px;
border-radius: 20px;
font-size: 0.8rem;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
.summary-panel {
display: none;
background: white;
border-radius: 15px;
padding: 30px;
margin: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.summary-panel h2 {
color: #333;
margin-bottom: 20px;
text-align: center;
}
.summary-item {
background: #f8f9fa;
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
border-left: 4px solid #4CAF50;
}
.summary-item h4 {
color: #333;
margin-bottom: 8px;
}
.summary-item p {
color: #666;
margin-bottom: 5px;
line-height: 1.4;
}
.evaluation-score {
display: inline-block;
background: #4CAF50;
color: white;
padding: 4px 8px;
border-radius: 15px;
font-size: 0.8rem;
font-weight: bold;
}
.error-message {
background: #ff4757;
color: white;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
text-align: center;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes ripple {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
@keyframes recordPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.interview-container {
margin: 10px;
min-height: 90vh;
}
.mic-button {
width: 70px;
height: 70px;
font-size: 1.5rem;
}
.message-bubble,
.user-bubble {
max-width: 85%;
}
.header h1 {
font-size: 1.5rem;
}
}
audio {
display: none;
}
</style>
</head>
<body>
<div class="interview-container">
<div class="header">
<div class="question-counter">
Question <span id="currentQuestionNum">1</span> of <span id="totalQuestions">{{ job.num_questions }}</span>
</div>
<h1>🤖 AI Interview Assistant</h1>
<p>Answer thoughtfully and take your time</p>
</div>
<div class="chat-area" id="chatArea">
<div class="ai-message">
<div class="ai-avatar" id="aiAvatar">AI</div>
<div class="message-bubble">
<div id="loadingMessage">
<div class="loading"></div>
<span style="margin-left: 10px;">Generating your first question...</span>
</div>
</div>
</div>
</div>
<div class="controls-area">
<div class="recording-section">
<div class="mic-container">
<button class="mic-button" id="micButton" disabled>
<span id="micIcon">🎤</span>
</button>
</div>
<div class="recording-status" id="recordingStatus">
Click the microphone to record your answer
</div>
<div class="transcript-area" id="transcriptArea" contenteditable="true"
placeholder="Your transcribed answer will appear here..."></div>
<div class="action-buttons">
<button class="btn btn-primary" id="confirmButton" disabled>
<span>Confirm Answer</span>
<span id="confirmLoading" style="display: none;">
<div class="loading" style="width: 16px; height: 16px; border-width: 2px;"></div>
</span>
</button>
<button class="btn btn-secondary" id="retryRecording" style="display: none;">
🔄 Re-record
</button>
</div>
</div>
</div>
<!-- Summary Panel (hidden initially) -->
<div class="summary-panel" id="summaryPanel">
<h2>📋 Interview Summary</h2>
<div id="summaryContent"></div>
<div style="text-align: center; margin-top: 30px;">
<button class="btn btn-primary" onclick="window.location.href='/jobs'">
Back to Jobs
</button>
</div>
</div>
</div>
<!-- Hidden audio element for TTS playback -->
<audio id="ttsAudio" preload="auto"></audio>
<script>
const JOB_ID = {{ job.id }};
class AIInterviewer {
constructor() {
this.currentQuestionIndex = 0;
// Set the total number of questions based on the job configuration.
// The value is read from the DOM element #totalQuestions, which
// has been populated server‑side using ``job.num_questions``.
const totalSpan = document.getElementById('totalQuestions');
const parsedTotal = parseInt(totalSpan && totalSpan.textContent);
this.totalQuestions = Number.isNaN(parsedTotal) || parsedTotal <= 0 ? 4 : parsedTotal;
// Ensure the counter display shows the correct total number of
// questions. Without this update the span would retain the
// server‑rendered value even if the fallback above changed
// ``this.totalQuestions``.
if (totalSpan) {
totalSpan.textContent = this.totalQuestions;
}
this.isRecording = false;
this.mediaRecorder = null;
this.audioChunks = [];
this.currentQuestion = "";
this.interviewData = {
questions: [],
answers: [],
evaluations: []
};
this.initializeElements();
this.initializeInterview();
}
initializeElements() {
this.chatArea = document.getElementById('chatArea');
this.micButton = document.getElementById('micButton');
this.micIcon = document.getElementById('micIcon');
this.recordingStatus = document.getElementById('recordingStatus');
this.transcriptArea = document.getElementById('transcriptArea');
this.confirmButton = document.getElementById('confirmButton');
this.confirmLoading = document.getElementById('confirmLoading');
this.retryButton = document.getElementById('retryRecording');
this.aiAvatar = document.getElementById('aiAvatar');
this.ttsAudio = document.getElementById('ttsAudio');
this.summaryPanel = document.getElementById('summaryPanel');
this.currentQuestionNum = document.getElementById('currentQuestionNum');
this.totalQuestionsSpan = document.getElementById('totalQuestions');
this.bindEvents();
}
bindEvents() {
// Mouse events for desktop
this.micButton.addEventListener('mousedown', (e) => {
e.preventDefault();
this.startRecording();
});
this.micButton.addEventListener('mouseup', (e) => {
e.preventDefault();
this.stopRecording();
});
this.micButton.addEventListener('mouseleave', (e) => {
e.preventDefault();
this.stopRecording();
});
// Touch events for mobile
this.micButton.addEventListener('touchstart', (e) => {
e.preventDefault();
this.startRecording();
});
this.micButton.addEventListener('touchend', (e) => {
e.preventDefault();
this.stopRecording();
});
this.confirmButton.addEventListener('click', () => this.submitAnswer());
this.retryButton.addEventListener('click', () => this.resetRecording());
this.transcriptArea.addEventListener('input', () => {
const hasText = this.transcriptArea.textContent.trim().length > 0;
this.confirmButton.disabled = !hasText;
});
this.ttsAudio.addEventListener('play', () => {
this.aiAvatar.classList.add('talking');
});
this.ttsAudio.addEventListener('ended', () => {
this.aiAvatar.classList.remove('talking');
this.enableControls();
});
this.ttsAudio.addEventListener('error', (e) => {
console.error('Audio playback error:', e);
this.aiAvatar.classList.remove('talking');
this.enableControls();
});
}
async initializeInterview() {
try {
console.log('Starting interview...');
const response = await fetch('/api/start_interview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ job_id: JOB_ID })
});
if (!response.ok) {
const errorText = await response.text();
console.error('Server response:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Received interview data:', data);
if (data.error) {
this.showError(data.error);
return;
}
// Store the current question for evaluation
this.currentQuestion = data.question;
this.displayQuestion(data.question, data.audio_url);
this.interviewData.questions.push(data.question);
} catch (error) {
console.error('Error starting interview:', error);
this.showError('Failed to start interview. Please check your connection and try again.');
}
}
displayQuestion(question, audioUrl = null) {
// Remove loading message
const loadingMsg = document.getElementById('loadingMessage');
if (loadingMsg) {
loadingMsg.remove();
}
// Create question message
const messageDiv = document.createElement('div');
messageDiv.className = 'ai-message';
messageDiv.innerHTML = `
<div class="ai-avatar">AI</div>
<div class="message-bubble">
<p>${question}</p>
</div>
`;
this.chatArea.appendChild(messageDiv);
this.chatArea.scrollTop = this.chatArea.scrollHeight;
// Update question counter
this.currentQuestionNum.textContent = this.currentQuestionIndex + 1;
// Play audio if available
if (audioUrl) {
console.log('Playing audio:', audioUrl);
this.playQuestionAudio(audioUrl);
} else {
console.log('No audio URL provided, enabling controls');
setTimeout(() => this.enableControls(), 1000);
}
}
playQuestionAudio(audioUrl) {
// Add talking animation immediately
const avatars = this.chatArea.querySelectorAll('.ai-avatar');
avatars.forEach(avatar => avatar.classList.add('talking'));
this.ttsAudio.src = audioUrl;
this.ttsAudio.load(); // Ensure audio is loaded
this.ttsAudio.play().catch(error => {
console.error('Audio play error:', error);
avatars.forEach(avatar => avatar.classList.remove('talking'));
this.enableControls();
});
}
enableControls() {
this.micButton.disabled = false;
this.recordingStatus.textContent = 'Click and hold to record your answer';
// Remove talking animation from all avatars
const avatars = this.chatArea.querySelectorAll('.ai-avatar');
avatars.forEach(avatar => avatar.classList.remove('talking'));
}
async startRecording() {
if (this.isRecording || this.micButton.disabled) return;
try {
console.log('Starting recording...');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 16000
}
});
// Use webm format with opus codec for better compatibility
const options = {
mimeType: 'audio/webm;codecs=opus'
};
// Fallback for browsers that don't support webm
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
options.mimeType = 'audio/webm';
}
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
delete options.mimeType;
}
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
console.log('Audio chunk received:', event.data.size, 'bytes');
}
};
this.mediaRecorder.onstop = () => {
console.log('Recording stopped, processing...');
stream.getTracks().forEach(track => track.stop());
this.processRecording();
};
this.mediaRecorder.onerror = (event) => {
console.error('MediaRecorder error:', event.error);
this.recordingStatus.textContent = 'Recording error. Please try again.';
stream.getTracks().forEach(track => track.stop());
};
this.mediaRecorder.start(1000); // Collect data every second
this.isRecording = true;
// Update UI
this.micButton.classList.add('recording');
this.micIcon.textContent = '🔴';
this.recordingStatus.textContent = 'Recording... Release to stop';
} catch (error) {
console.error('Error starting recording:', error);
this.recordingStatus.textContent = 'Microphone access denied. Please allow microphone access and try again.';
this.recordingStatus.style.color = '#ff4757';
}
}
stopRecording() {
if (!this.isRecording || !this.mediaRecorder) return;
console.log('Stopping recording...');
this.mediaRecorder.stop();
this.isRecording = false;
// Update UI
this.micButton.classList.remove('recording');
this.micIcon.textContent = '🎤';
this.recordingStatus.textContent = 'Processing audio...';
this.recordingStatus.style.color = '#666';
}
async processRecording() {
try {
if (this.audioChunks.length === 0) {
console.error('No audio chunks recorded');
this.recordingStatus.textContent = 'No audio recorded. Please try again.';
return;
}
console.log('Processing', this.audioChunks.length, 'audio chunks');
// Create blob from audio chunks
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
// Initialise a new FormData instance AFTER creating the blob
// and append the audio. Previously a call to `formData.append` was
// made before the variable was declared, causing a reference
// error and triggering the generic "Error processing audio" message.
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
console.log('Created audio blob:', audioBlob.size, 'bytes');
if (audioBlob.size === 0) {
console.error('Audio blob is empty');
this.recordingStatus.textContent = 'No audio data captured. Please try again.';
return;
}
// formData has already been created and the audio appended above.
// Do not recreate or re-append here.
console.log('Sending audio for transcription...');
const response = await fetch('/api/transcribe_audio', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error('Transcription error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Transcription response:', data);
if (data.error) {
this.recordingStatus.textContent = data.error;
this.recordingStatus.style.color = '#ff4757';
return;
}
if (data.transcript && data.transcript.trim()) {
this.transcriptArea.textContent = data.transcript;
this.confirmButton.disabled = false;
this.retryButton.style.display = 'inline-flex';
this.recordingStatus.textContent = 'Transcription complete. Review and confirm your answer.';
this.recordingStatus.style.color = '#4CAF50';
} else {
this.recordingStatus.textContent = 'No speech detected. Please try recording again.';
this.recordingStatus.style.color = '#ff4757';
}
} catch (error) {
console.error('Error processing recording:', error);
this.recordingStatus.textContent = 'Error processing audio. Please try again.';
this.recordingStatus.style.color = '#ff4757';
}
}
resetRecording() {
this.transcriptArea.textContent = '';
this.confirmButton.disabled = true;
this.retryButton.style.display = 'none';
this.recordingStatus.textContent = 'Click and hold to record your answer';
this.recordingStatus.style.color = '#666';
}
async submitAnswer() {
const answer = this.transcriptArea.textContent.trim();
if (!answer) return;
console.log('Submitting answer:', answer);
// Show loading state
this.confirmButton.disabled = true;
this.confirmLoading.style.display = 'inline-block';
this.confirmButton.querySelector('span').style.display = 'none';
// Add user message to chat
this.addUserMessage(answer);
try {
const response = await fetch('/api/process_answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
answer: answer,
questionIndex: this.currentQuestionIndex,
current_question: this.currentQuestion,
job_id: JOB_ID
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('Process answer error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Process answer response:', data);
if (!data.success) {
this.showError(data.error || 'Failed to process answer. Please try again.');
return;
}
// Record the user's answer and its evaluation
this.interviewData.answers.push(answer);
this.interviewData.evaluations.push(data.evaluation || {});
if (data.is_complete) {
console.log('Interview completed');
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
this.showInterviewSummary();
} else {
console.log('Moving to next question');
this.currentQuestionIndex++;
this.currentQuestion = data.next_question;
this.displayQuestion(data.next_question, data.audio_url);
this.interviewData.questions.push(data.next_question);
this.resetForNextQuestion();
}
} catch (error) {
console.error('Error submitting answer:', error);
this.showError('Connection error. Please try again.');
} finally {
// Reset button state
this.confirmLoading.style.display = 'none';
this.confirmButton.querySelector('span').style.display = 'inline';
}
}
addUserMessage(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'user-message';
messageDiv.innerHTML = `
<div class="user-bubble">
<p>${message}</p>
</div>
`;
this.chatArea.appendChild(messageDiv);
this.chatArea.scrollTop = this.chatArea.scrollHeight;
}
resetForNextQuestion() {
this.transcriptArea.textContent = '';
this.confirmButton.disabled = true;
this.retryButton.style.display = 'none';
this.recordingStatus.textContent = 'Wait for the next question...';
this.recordingStatus.style.color = '#666';
this.micButton.disabled = true;
}
showInterviewSummary() {
const summaryContent = document.getElementById('summaryContent');
let summaryHtml = '';
this.interviewData.questions.forEach((question, index) => {
const answer = this.interviewData.answers[index] || 'No answer provided';
const evaluation = this.interviewData.evaluations[index] || {};
summaryHtml += `
<div class="summary-item">
<h4>Question ${index + 1}:</h4>
<p><strong>Q:</strong> ${question}</p>
<p><strong>A:</strong> ${answer}</p>
<p><strong>Score:</strong> <span class="evaluation-score">${evaluation.score || 'N/A'}</span></p>
<p><strong>Feedback:</strong> ${evaluation.feedback || 'No feedback provided'}</p>
</div>
`;
});
summaryContent.innerHTML = summaryHtml;
// Hide main interface and show summary
document.querySelector('.interview-container').style.display = 'none';
this.summaryPanel.style.display = 'block';
}
showError(message) {
console.error('Showing error:', message);
// Create error message element
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
// Insert at the top of chat area
this.chatArea.insertBefore(errorDiv, this.chatArea.firstChild);
// Remove after 5 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
// Also update recording status
this.recordingStatus.textContent = message;
this.recordingStatus.style.color = '#ff4757';
setTimeout(() => {
this.recordingStatus.style.color = '#666';
}, 3000);
}
}
// Initialize the interview when page loads
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing AI Interviewer...');
new AIInterviewer();
});
// Add placeholder attribute support for contenteditable
document.addEventListener('DOMContentLoaded', function () {
const transcriptArea = document.getElementById('transcriptArea');
const placeholder = transcriptArea.getAttribute('placeholder');
function checkPlaceholder() {
if (transcriptArea.textContent.trim() === '') {
transcriptArea.style.color = '#999';
transcriptArea.textContent = placeholder;
} else if (transcriptArea.textContent === placeholder) {
transcriptArea.style.color = '#333';
transcriptArea.textContent = '';
}
}
transcriptArea.addEventListener('focus', function () {
if (transcriptArea.textContent === placeholder) {
transcriptArea.textContent = '';
transcriptArea.style.color = '#333';
}
});
transcriptArea.addEventListener('blur', function () {
if (transcriptArea.textContent.trim() === '') {
transcriptArea.style.color = '#999';
transcriptArea.textContent = placeholder;
}
});
// Initial check
checkPlaceholder();
});
</script>
</body>
</html>