feature done: Ai interviewer
This commit is contained in:
commit
f6295196be
30
.env.example
Normal file
30
.env.example
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# ── MongoDB ──
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/ai-interviewer
|
||||||
|
|
||||||
|
# ── LLM (Cerebras) ──
|
||||||
|
CEREBRAS_API_KEY=
|
||||||
|
|
||||||
|
# ── Voice Provider: "sarvam" or "deepgram" ──
|
||||||
|
VOICE_PROVIDER=sarvam
|
||||||
|
|
||||||
|
# ── Sarvam AI ──
|
||||||
|
SARVAM_API_KEY=
|
||||||
|
SARVAM_STT_LANGUAGE=en-IN
|
||||||
|
SARVAM_STT_MODEL=saarika:v1
|
||||||
|
SARVAM_TTS_LANGUAGE=en-IN
|
||||||
|
SARVAM_TTS_MODEL=bulbul:v2
|
||||||
|
SARVAM_TTS_SPEAKER=anushka
|
||||||
|
SARVAM_TTS_PACE=1.0
|
||||||
|
SARVAM_TTS_TEMPERATURE=0.6
|
||||||
|
SARVAM_TTS_CODEC=wav
|
||||||
|
|
||||||
|
# ── Deepgram (alternative) ──
|
||||||
|
DEEPGRAM_API_KEY=
|
||||||
|
DEEPGRAM_STT_LANGUAGE=en-US
|
||||||
|
DEEPGRAM_STT_MODEL=nova-2
|
||||||
|
DEEPGRAM_TTS_MODEL=aura-asteria-en
|
||||||
|
DEEPGRAM_TTS_CODEC=wav
|
||||||
|
|
||||||
|
# ── Server ──
|
||||||
|
PORT=3001
|
||||||
|
CORS_ORIGIN=http://localhost:5173
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# AI Interviewer
|
||||||
|
|
||||||
|
A real-time AI-powered interview platform with audio streaming, face verification, and intelligent evaluation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ai_interview_assistant/
|
||||||
|
├── server/ # NestJS backend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── candidate/ # Profile, resume upload, OCR
|
||||||
|
│ ├── face-auth/ # face-api.js face verification
|
||||||
|
│ ├── interview/ # WebSocket gateway, orchestrator, voice providers
|
||||||
|
│ └── brain/ # Cerebras LLM integration, evaluation
|
||||||
|
├── client/ # React + Vite frontend
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/ # Avatar
|
||||||
|
│ ├── pages/ # Onboarding, InterviewRoom
|
||||||
|
│ ├── hooks/ # useSocket, useAudioRecorder
|
||||||
|
│ └── services/ # REST API client
|
||||||
|
└── .env.example # Environment variables template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in your API keys: DEEPGRAM_API_KEY, SARVAM_API_KEY, CEREBRAS_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
npm run start:dev # http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `face-api.js` requires model weight files in `server/face-models/`. Download them from [face-api.js models](https://github.com/justadudewhohacks/face-api.js/tree/master/weights).
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. MongoDB
|
||||||
|
|
||||||
|
Ensure MongoDB is running on `localhost:27017` (or update `MONGODB_URI` in `.env`).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| -------- | ---------------------------------------- |
|
||||||
|
| Backend | NestJS, Mongoose, Socket.io, face-api.js |
|
||||||
|
| Frontend | React 19, Vite, TailwindCSS, socket.io |
|
||||||
|
| LLM | Cerebras (llama-4-scout) |
|
||||||
|
| STT/TTS | Deepgram (primary), Sarvam (fallback) |
|
||||||
|
| OCR | pdf-parse + tesseract.js |
|
||||||
|
| Database | MongoDB |
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Real-time audio streaming** via WebSocket (Socket.io)
|
||||||
|
- **Face verification** on interview start (flag-only, non-blocking)
|
||||||
|
- **Resume OCR** supporting text PDFs and scanned images
|
||||||
|
- **Stage-aware interviewing** (Intro → Technical → Behavioral → Wrap-up)
|
||||||
|
- **Structured evaluation** with per-dimension ratings and reviews
|
||||||
|
- **Swappable voice providers** (Deepgram ↔ Sarvam via env config)
|
||||||
22
client/index.html
Normal file
22
client/index.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="AI-powered interview platform with real-time voice interaction, face verification, and intelligent evaluation."
|
||||||
|
/>
|
||||||
|
<title>AI Interviewer</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface-950 text-white antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2890
client/package-lock.json
generated
Normal file
2890
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
client/package.json
Normal file
28
client/package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-interviewer-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.0",
|
||||||
|
"react-webcam": "^7.2.0",
|
||||||
|
"socket.io-client": "^4.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
31
client/src/App.tsx
Normal file
31
client/src/App.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import CandidateOnboarding from "./pages/CandidateOnboarding";
|
||||||
|
import InterviewRoom from "./pages/InterviewRoom";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="min-h-screen bg-surface-950">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 glass border-b border-surface-700/30">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold gradient-text tracking-tight">
|
||||||
|
AI Interviewer
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs text-surface-200/40 font-mono">v1.0</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Routes ── */}
|
||||||
|
<main className="pt-20">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<CandidateOnboarding />} />
|
||||||
|
<Route path="/interview/:sessionId" element={<InterviewRoom />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
85
client/src/components/Avatar.tsx
Normal file
85
client/src/components/Avatar.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AIState } from "../hooks/useSocket";
|
||||||
|
|
||||||
|
// You can replace these with actual avatar assets from your public folder
|
||||||
|
const IMG_STATIC =
|
||||||
|
"https://api.dicebear.com/7.x/bottts/svg?seed=Felix&mouth=smile01";
|
||||||
|
const IMG_TALK_1 =
|
||||||
|
"https://api.dicebear.com/7.x/bottts/svg?seed=Felix&mouth=smile02";
|
||||||
|
const IMG_TALK_2 =
|
||||||
|
"https://api.dicebear.com/7.x/bottts/svg?seed=Felix&mouth=smile03";
|
||||||
|
|
||||||
|
const FRAMES = [IMG_STATIC, IMG_TALK_1, IMG_TALK_2];
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
state: AIState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Avatar({ state }: AvatarProps) {
|
||||||
|
const [frameIndex, setFrameIndex] = useState(0);
|
||||||
|
|
||||||
|
// Cycle frames when speaking to simulate mouth movement
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "speaking") {
|
||||||
|
setFrameIndex(0); // Show static frame when not speaking
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setFrameIndex((prev) => (prev + 1) % 3);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const stateLabels: Record<AIState, string> = {
|
||||||
|
idle: "Ready",
|
||||||
|
listening: "Listening…",
|
||||||
|
thinking: "Thinking…",
|
||||||
|
speaking: "Speaking…",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* Avatar container */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Outer pulse ring (listening) */}
|
||||||
|
{state === "listening" && (
|
||||||
|
<div className="absolute inset-[-12px] rounded-full pulse-ring bg-emerald-500/20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Image Frame */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-48 h-48 rounded-full flex items-center justify-center overflow-hidden
|
||||||
|
bg-surface-700 border-4 shadow-2xl transition-all duration-300
|
||||||
|
${state === "speaking" ? "border-primary-500 shadow-primary-500/40" : ""}
|
||||||
|
${state === "listening" ? "border-emerald-500 shadow-emerald-500/40" : ""}
|
||||||
|
${state === "thinking" ? "border-amber-500 shadow-amber-500/40" : ""}
|
||||||
|
${state === "idle" ? "border-surface-600 shadow-none" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={FRAMES[frameIndex]}
|
||||||
|
alt="AI Avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State label */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
px-4 py-1.5 rounded-full text-sm font-medium
|
||||||
|
${state === "listening" ? "bg-emerald-500/20 text-emerald-300" : ""}
|
||||||
|
${state === "thinking" ? "bg-amber-500/20 text-amber-300" : ""}
|
||||||
|
${state === "speaking" ? "bg-primary-500/20 text-primary-300" : ""}
|
||||||
|
${state === "idle" ? "bg-surface-700/30 text-surface-200/60" : ""}
|
||||||
|
transition-all duration-300
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{stateLabels[state]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
client/src/hooks/useAudioRecorder.ts
Normal file
116
client/src/hooks/useAudioRecorder.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UseAudioRecorderOptions {
|
||||||
|
onChunk?: (chunk: ArrayBuffer) => void;
|
||||||
|
onSilence?: () => void;
|
||||||
|
silenceThreshold?: number;
|
||||||
|
silenceTimeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
|
||||||
|
const {
|
||||||
|
onChunk,
|
||||||
|
onSilence,
|
||||||
|
silenceThreshold = 0.01,
|
||||||
|
silenceTimeout = 1500,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||||
|
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||||
|
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
sampleRate: 16000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaStreamRef.current = stream;
|
||||||
|
|
||||||
|
// Enforce passing a known sampleRate when supported
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||||
|
audioCtxRef.current = audioCtx;
|
||||||
|
|
||||||
|
const source = audioCtx.createMediaStreamSource(stream);
|
||||||
|
|
||||||
|
// Buffer size of 4096 is ~256ms at 16000Hz
|
||||||
|
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
||||||
|
processorRef.current = processor;
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(audioCtx.destination);
|
||||||
|
|
||||||
|
setIsRecording(true);
|
||||||
|
|
||||||
|
processor.onaudioprocess = (e) => {
|
||||||
|
const float32Array = e.inputBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
// Check for silence (RMS)
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < float32Array.length; i++) {
|
||||||
|
sum += float32Array[i] * float32Array[i];
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sum / float32Array.length);
|
||||||
|
|
||||||
|
if (rms < silenceThreshold) {
|
||||||
|
if (!silenceTimerRef.current) {
|
||||||
|
silenceTimerRef.current = setTimeout(() => {
|
||||||
|
onSilence?.();
|
||||||
|
silenceTimerRef.current = null;
|
||||||
|
}, silenceTimeout);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (silenceTimerRef.current) {
|
||||||
|
clearTimeout(silenceTimerRef.current);
|
||||||
|
silenceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChunk) {
|
||||||
|
// Convert float32 to int16 (PCM)
|
||||||
|
const int16Buffer = new Int16Array(float32Array.length);
|
||||||
|
for (let i = 0; i < float32Array.length; i++) {
|
||||||
|
let s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||||
|
int16Buffer[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||||
|
}
|
||||||
|
onChunk(int16Buffer.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start recording:', err);
|
||||||
|
}
|
||||||
|
}, [onChunk, onSilence, silenceThreshold, silenceTimeout]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (processorRef.current) {
|
||||||
|
processorRef.current.disconnect();
|
||||||
|
processorRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioCtxRef.current) {
|
||||||
|
audioCtxRef.current.close();
|
||||||
|
audioCtxRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaStreamRef.current) {
|
||||||
|
mediaStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
mediaStreamRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silenceTimerRef.current) {
|
||||||
|
clearTimeout(silenceTimerRef.current);
|
||||||
|
silenceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isRecording, startRecording, stopRecording };
|
||||||
|
}
|
||||||
126
client/src/hooks/useSocket.ts
Normal file
126
client/src/hooks/useSocket.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
export type AIState = 'idle' | 'listening' | 'thinking' | 'speaking';
|
||||||
|
|
||||||
|
interface UseSocketOptions {
|
||||||
|
onTranscript?: (text: string) => void;
|
||||||
|
onAudioResponse?: (audio: ArrayBuffer) => void;
|
||||||
|
onStateChange?: (state: AIState) => void;
|
||||||
|
onSessionCreated?: (data: { sessionId: string; stage: string }) => void;
|
||||||
|
onFaceResult?: (data: { verified: boolean; message: string }) => void;
|
||||||
|
onError?: (err: { message: string }) => void;
|
||||||
|
onInterviewEnded?: (data: { sessionId: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook managing the Socket.io connection to the interview gateway.
|
||||||
|
* Handles audio chunk emission, TTS playback, and all socket events.
|
||||||
|
*/
|
||||||
|
export function useSocket(options: UseSocketOptions = {}) {
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [aiState, setAiState] = useState<AIState>('idle');
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
|
// ── Connect on mount ──
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io(`${SOCKET_URL}/interview`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
autoConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => setConnected(true));
|
||||||
|
socket.on('disconnect', () => setConnected(false));
|
||||||
|
|
||||||
|
socket.on('session-created', (data) => {
|
||||||
|
options.onSessionCreated?.(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ai-transcript', (data: { text: string }) => {
|
||||||
|
options.onTranscript?.(data.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ai-audio', (audioData: ArrayBuffer) => {
|
||||||
|
options.onAudioResponse?.(audioData);
|
||||||
|
playAudio(audioData);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ai-state', (data: { state: AIState }) => {
|
||||||
|
setAiState(data.state);
|
||||||
|
options.onStateChange?.(data.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('face-result', (data) => {
|
||||||
|
options.onFaceResult?.(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('interview-ended', (data) => {
|
||||||
|
options.onInterviewEnded?.(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
options.onError?.(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Audio playback ──
|
||||||
|
const playAudio = useCallback(async (audioData: ArrayBuffer) => {
|
||||||
|
try {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioContext();
|
||||||
|
}
|
||||||
|
const ctx = audioContextRef.current;
|
||||||
|
const audioBuffer = await ctx.decodeAudioData(audioData.slice(0));
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
source.start(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audio playback failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Emit helpers ──
|
||||||
|
const joinRoom = useCallback((candidateId: string) => {
|
||||||
|
socketRef.current?.emit('join-room', { candidateId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendAudioChunk = useCallback((chunk: ArrayBuffer) => {
|
||||||
|
socketRef.current?.emit('audio-chunk', chunk);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signalEndOfSpeech = useCallback(() => {
|
||||||
|
socketRef.current?.emit('end-of-speech');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendFaceFrame = useCallback(
|
||||||
|
(candidateId: string, frame: ArrayBuffer) => {
|
||||||
|
socketRef.current?.emit('face-verify', { candidateId, frame });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endInterview = useCallback(() => {
|
||||||
|
socketRef.current?.emit('end-interview');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
aiState,
|
||||||
|
joinRoom,
|
||||||
|
sendAudioChunk,
|
||||||
|
signalEndOfSpeech,
|
||||||
|
sendFaceFrame,
|
||||||
|
endInterview,
|
||||||
|
};
|
||||||
|
}
|
||||||
120
client/src/index.css
Normal file
120
client/src/index.css
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ─── Base Styles ─── */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
background: #020617;
|
||||||
|
color: #f8fafc;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ─── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Glass Card ─── */
|
||||||
|
.glass {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Avatar Animation ─── */
|
||||||
|
@keyframes avatar-speak {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin-slow {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-speaking {
|
||||||
|
animation: avatar-speak 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin-slow {
|
||||||
|
animation: spin-slow 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Pulse Ring (Listening State) ─── */
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 20px rgba(99, 102, 241, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-ring {
|
||||||
|
animation: pulse-ring 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Gradient Text ─── */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #818cf8, #6366f1, #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Button Styles ─── */
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white font-semibold
|
||||||
|
rounded-xl transition-all duration-200 shadow-lg shadow-primary-600/25
|
||||||
|
hover:shadow-primary-500/40 active:scale-[0.98];
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply px-6 py-3 bg-red-600 hover:bg-red-500 text-white font-semibold
|
||||||
|
rounded-xl transition-all duration-200 shadow-lg shadow-red-600/25
|
||||||
|
hover:shadow-red-500/40 active:scale-[0.98];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Input Styles ─── */
|
||||||
|
.input-field {
|
||||||
|
@apply w-full px-4 py-3 bg-surface-900/50 border border-surface-700/50
|
||||||
|
rounded-xl text-white placeholder-surface-200/30 focus:outline-none
|
||||||
|
focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50
|
||||||
|
transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── File Upload ─── */
|
||||||
|
.file-upload {
|
||||||
|
@apply w-full px-4 py-8 border-2 border-dashed border-surface-700/50
|
||||||
|
rounded-xl text-center cursor-pointer hover:border-primary-500/50
|
||||||
|
hover:bg-primary-500/5 transition-all duration-200;
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
215
client/src/pages/CandidateOnboarding.tsx
Normal file
215
client/src/pages/CandidateOnboarding.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useRef, type FormEvent, type ChangeEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createCandidate } from "../services/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CandidateOnboarding page — collects candidate info, resume, and photo
|
||||||
|
* before starting the interview.
|
||||||
|
*/
|
||||||
|
export default function CandidateOnboarding() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [experienceSummary, setExperienceSummary] = useState("");
|
||||||
|
const [resumeFile, setResumeFile] = useState<File | null>(null);
|
||||||
|
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const resumeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", name);
|
||||||
|
formData.append("email", email);
|
||||||
|
if (experienceSummary)
|
||||||
|
formData.append("experienceSummary", experienceSummary);
|
||||||
|
if (resumeFile) formData.append("resume", resumeFile);
|
||||||
|
if (photoFile) formData.append("profilePicture", photoFile);
|
||||||
|
|
||||||
|
const candidate = await createCandidate(formData);
|
||||||
|
// Navigate to interview room with candidate data
|
||||||
|
navigate(`/interview/${candidate._id}`, {
|
||||||
|
state: { candidateId: candidate._id, candidateName: candidate.name },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange =
|
||||||
|
(setter: (f: File | null) => void) =>
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setter(e.target.files?.[0] || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-5rem)] flex items-center justify-center px-4 py-12">
|
||||||
|
<div className="glass w-full max-w-lg p-8">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold gradient-text mb-2">
|
||||||
|
Welcome, Candidate
|
||||||
|
</h2>
|
||||||
|
<p className="text-surface-200/60 text-sm">
|
||||||
|
Fill in your details to start the AI-powered interview
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Form ── */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-200/80 mb-1.5">
|
||||||
|
Full Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="input-name"
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-200/80 mb-1.5">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="input-email"
|
||||||
|
type="email"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Experience Summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-200/80 mb-1.5">
|
||||||
|
Experience Summary
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="input-experience"
|
||||||
|
className="input-field min-h-[80px] resize-y"
|
||||||
|
placeholder="Brief summary of your experience..."
|
||||||
|
value={experienceSummary}
|
||||||
|
onChange={(e) => setExperienceSummary(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resume Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-200/80 mb-1.5">
|
||||||
|
Resume (PDF or Image)
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="file-upload"
|
||||||
|
onClick={() => resumeInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={resumeInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange(setResumeFile)}
|
||||||
|
/>
|
||||||
|
{resumeFile ? (
|
||||||
|
<span className="text-primary-400 font-medium">
|
||||||
|
📄 {resumeFile.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-surface-200/40">
|
||||||
|
Click to upload resume
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-surface-200/80 mb-1.5">
|
||||||
|
Profile Photo (optional)
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="file-upload"
|
||||||
|
onClick={() => photoInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={photoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange(setPhotoFile)}
|
||||||
|
/>
|
||||||
|
{photoFile ? (
|
||||||
|
<span className="text-primary-400 font-medium">
|
||||||
|
📷 {photoFile.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-surface-200/40">
|
||||||
|
Click to upload photo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 text-sm bg-red-500/10 px-4 py-2 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
id="btn-start-interview"
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name || !email}
|
||||||
|
className="btn-primary w-full disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Processing…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Start Interview →"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
client/src/pages/InterviewRoom.tsx
Normal file
315
client/src/pages/InterviewRoom.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { useParams, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import Webcam from "react-webcam";
|
||||||
|
import Avatar from "../components/Avatar";
|
||||||
|
import { useSocket, type AIState } from "../hooks/useSocket";
|
||||||
|
import { useAudioRecorder } from "../hooks/useAudioRecorder";
|
||||||
|
import { evaluateInterview } from "../services/api";
|
||||||
|
|
||||||
|
interface TranscriptLine {
|
||||||
|
role: "user" | "ai";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InterviewRoom — the core interview experience.
|
||||||
|
*
|
||||||
|
* Left: User's webcam feed
|
||||||
|
* Right: AI Avatar + transcript
|
||||||
|
*
|
||||||
|
* Audio is captured from the mic, streamed via socket as binary chunks,
|
||||||
|
* and TTS audio is played back through the Web Audio API.
|
||||||
|
*/
|
||||||
|
export default function InterviewRoom() {
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const candidateId = (location.state as any)?.candidateId || sessionId || "";
|
||||||
|
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState("");
|
||||||
|
const [faceStatus, setFaceStatus] = useState<string>("");
|
||||||
|
const [interviewEnded, setInterviewEnded] = useState(false);
|
||||||
|
const [evaluation, setEvaluation] = useState<any>(null);
|
||||||
|
|
||||||
|
const webcamRef = useRef<Webcam>(null);
|
||||||
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ── Socket connection ──
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
aiState,
|
||||||
|
joinRoom,
|
||||||
|
sendAudioChunk,
|
||||||
|
signalEndOfSpeech,
|
||||||
|
sendFaceFrame,
|
||||||
|
endInterview,
|
||||||
|
} = useSocket({
|
||||||
|
onSessionCreated: (data) => {
|
||||||
|
setCurrentSessionId(data.sessionId);
|
||||||
|
// Send face verification frame once session is established
|
||||||
|
setTimeout(() => captureAndVerifyFace(), 2000);
|
||||||
|
},
|
||||||
|
onTranscript: (text) => {
|
||||||
|
setTranscript((prev) => [...prev, { role: "ai", text }]);
|
||||||
|
},
|
||||||
|
onFaceResult: (data) => {
|
||||||
|
setFaceStatus(data.message);
|
||||||
|
},
|
||||||
|
onInterviewEnded: async (data) => {
|
||||||
|
setInterviewEnded(true);
|
||||||
|
try {
|
||||||
|
const result = (await evaluateInterview(data.sessionId)) as any;
|
||||||
|
setEvaluation(result.evaluation);
|
||||||
|
} catch {
|
||||||
|
// Evaluation fetch failed — non-critical
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error("Socket error:", err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Audio recording with silence detection ──
|
||||||
|
const { isRecording, startRecording, stopRecording } = useAudioRecorder({
|
||||||
|
onChunk: (chunk) => {
|
||||||
|
sendAudioChunk(chunk);
|
||||||
|
},
|
||||||
|
onSilence: () => {
|
||||||
|
// User stopped speaking — trigger pipeline
|
||||||
|
signalEndOfSpeech();
|
||||||
|
setTranscript((prev) => {
|
||||||
|
// Add a placeholder for user speech (will be refined by STT)
|
||||||
|
if (prev.length === 0 || prev[prev.length - 1].role === "ai") {
|
||||||
|
return [...prev, { role: "user", text: "(processing speech…)" }];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto-scroll transcript ──
|
||||||
|
useEffect(() => {
|
||||||
|
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [transcript]);
|
||||||
|
|
||||||
|
// ── Join room on mount ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected && candidateId) {
|
||||||
|
joinRoom(candidateId);
|
||||||
|
}
|
||||||
|
}, [connected, candidateId, joinRoom]);
|
||||||
|
|
||||||
|
// ── Face verification ──
|
||||||
|
const captureAndVerifyFace = useCallback(() => {
|
||||||
|
if (!webcamRef.current || !candidateId) return;
|
||||||
|
|
||||||
|
const canvas = webcamRef.current.getCanvas();
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
blob.arrayBuffer().then((buffer) => {
|
||||||
|
sendFaceFrame(candidateId, buffer);
|
||||||
|
});
|
||||||
|
}, "image/jpeg");
|
||||||
|
}, [candidateId, sendFaceFrame]);
|
||||||
|
|
||||||
|
// ── End interview handler ──
|
||||||
|
const handleEndInterview = () => {
|
||||||
|
stopRecording();
|
||||||
|
endInterview();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render: Evaluation results ──
|
||||||
|
if (interviewEnded && evaluation) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-5rem)] flex items-center justify-center px-4 py-12">
|
||||||
|
<div className="glass w-full max-w-2xl p-8">
|
||||||
|
<h2 className="text-2xl font-bold gradient-text mb-6 text-center">
|
||||||
|
Interview Complete
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
{Object.entries(evaluation).map(([key, val]: [string, any]) => (
|
||||||
|
<div key={key} className="glass p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-primary-300 uppercase tracking-wider mb-1">
|
||||||
|
{key}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
|
<span className="text-3xl font-bold text-white">
|
||||||
|
{val.rating}
|
||||||
|
</span>
|
||||||
|
<span className="text-surface-200/40 text-sm">/ 10</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-200/60 text-sm leading-relaxed">
|
||||||
|
{val.review_message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={() => navigate("/")} className="btn-primary w-full">
|
||||||
|
Back to Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Interview room ──
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-5rem)] px-4 py-6">
|
||||||
|
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-8rem)]">
|
||||||
|
{/* ─── LEFT: User Video ─── */}
|
||||||
|
<div className="glass p-4 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-surface-200/60 uppercase tracking-wider">
|
||||||
|
Your Camera
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
connected ? "bg-emerald-400" : "bg-red-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-surface-200/40">
|
||||||
|
{connected ? "Connected" : "Connecting…"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-xl overflow-hidden bg-surface-900/50 relative">
|
||||||
|
<Webcam
|
||||||
|
ref={webcamRef}
|
||||||
|
audio={false}
|
||||||
|
mirrored
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
videoConstraints={{
|
||||||
|
facingMode: "user",
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Face status badge */}
|
||||||
|
{faceStatus && (
|
||||||
|
<div className="absolute bottom-3 left-3 right-3">
|
||||||
|
<div
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-lg backdrop-blur-md ${
|
||||||
|
faceStatus.includes("verified") ||
|
||||||
|
faceStatus.includes("captured")
|
||||||
|
? "bg-emerald-500/20 text-emerald-300"
|
||||||
|
: "bg-amber-500/20 text-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{faceStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-4">
|
||||||
|
<button
|
||||||
|
id="btn-toggle-mic"
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
className={`
|
||||||
|
w-14 h-14 rounded-full flex items-center justify-center
|
||||||
|
transition-all duration-200
|
||||||
|
${
|
||||||
|
isRecording
|
||||||
|
? "bg-red-500 shadow-lg shadow-red-500/30 hover:bg-red-400"
|
||||||
|
: "bg-primary-600 shadow-lg shadow-primary-600/30 hover:bg-primary-500"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
// Stop icon
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// Mic icon
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19 10v2a7 7 0 01-14 0v-2"
|
||||||
|
/>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="btn-end-interview"
|
||||||
|
onClick={handleEndInterview}
|
||||||
|
className="btn-danger text-sm"
|
||||||
|
>
|
||||||
|
End Interview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── RIGHT: Avatar + Transcript ─── */}
|
||||||
|
<div className="glass p-4 flex flex-col">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex justify-center py-6">
|
||||||
|
<Avatar state={aiState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 px-2">
|
||||||
|
<h3 className="text-sm font-semibold text-surface-200/60 uppercase tracking-wider mb-2">
|
||||||
|
Transcript
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{transcript.length === 0 && (
|
||||||
|
<p className="text-center text-surface-200/30 text-sm py-8">
|
||||||
|
Start speaking to begin the interview…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transcript.map((line, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex ${
|
||||||
|
line.role === "user" ? "justify-end" : "justify-start"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] px-4 py-2.5 rounded-2xl text-sm leading-relaxed ${
|
||||||
|
line.role === "user"
|
||||||
|
? "bg-primary-600/30 text-primary-100 rounded-br-md"
|
||||||
|
: "bg-surface-700/40 text-surface-200/80 rounded-bl-md"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{line.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div ref={transcriptEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
client/src/services/api.ts
Normal file
53
client/src/services/api.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around fetch for backend REST calls.
|
||||||
|
*/
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${API_URL}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Candidates ─── */
|
||||||
|
|
||||||
|
export async function createCandidate(formData: FormData) {
|
||||||
|
const res = await fetch(`${API_URL}/candidates`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData, // multipart — no Content-Type header
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Create candidate failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCandidate(id: string) {
|
||||||
|
return request(`/candidates/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Interviews ─── */
|
||||||
|
|
||||||
|
export async function createInterview(candidateId: string) {
|
||||||
|
return request('/interviews', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ candidateId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInterview(id: string) {
|
||||||
|
return request(`/interviews/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateInterview(id: string) {
|
||||||
|
return request(`/interviews/${id}/evaluate`, { method: 'POST' });
|
||||||
|
}
|
||||||
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
40
client/tailwind.config.js
Normal file
40
client/tailwind.config.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: "#eef2ff",
|
||||||
|
100: "#e0e7ff",
|
||||||
|
200: "#c7d2fe",
|
||||||
|
300: "#a5b4fc",
|
||||||
|
400: "#818cf8",
|
||||||
|
500: "#6366f1",
|
||||||
|
600: "#4f46e5",
|
||||||
|
700: "#4338ca",
|
||||||
|
800: "#3730a3",
|
||||||
|
900: "#312e81",
|
||||||
|
950: "#1e1b4b",
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
50: "#f8fafc",
|
||||||
|
100: "#f1f5f9",
|
||||||
|
200: "#e2e8f0",
|
||||||
|
700: "#334155",
|
||||||
|
800: "#1e293b",
|
||||||
|
900: "#0f172a",
|
||||||
|
950: "#020617",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||||
|
"bounce-subtle": "bounce 2s ease-in-out infinite",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
25
client/tsconfig.json
Normal file
25
client/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
client/tsconfig.tsbuildinfo
Normal file
1
client/tsconfig.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/hooks/useaudiorecorder.ts","./src/hooks/usesocket.ts","./src/pages/candidateonboarding.tsx","./src/pages/interviewroom.tsx","./src/services/api.ts"],"version":"5.9.3"}
|
||||||
22
client/vite.config.ts
Normal file
22
client/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
39
package-lock.json
generated
Normal file
39
package-lock.json
generated
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "ai_interview_assistant",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/pdf-parse": "^1.1.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||||
|
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/pdf-parse": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/pdf-parse": "^1.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
server/eng.traineddata
Normal file
BIN
server/eng.traineddata
Normal file
Binary file not shown.
8
server/nest-cli.json
Normal file
8
server/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
9457
server/package-lock.json
generated
Normal file
9457
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
76
server/package.json
Normal file
76
server/package.json
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-interviewer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI Interview Platform — NestJS Backend",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.0",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.0",
|
||||||
|
"@nestjs/mongoose": "^10.1.0",
|
||||||
|
"@nestjs/platform-express": "^10.4.0",
|
||||||
|
"@nestjs/platform-socket.io": "^10.4.0",
|
||||||
|
"@nestjs/websockets": "^10.4.0",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"mongoose": "^8.9.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"sarvamai": "^1.0.0",
|
||||||
|
"@deepgram/sdk": "^3.0.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"socket.io": "^4.8.0",
|
||||||
|
"tesseract.js": "^5.1.1",
|
||||||
|
"uuid": "^11.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"face-api.js": "^0.22.2",
|
||||||
|
"canvas": "^2.11.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ws": "^8.5.0",
|
||||||
|
"@nestjs/cli": "^10.4.0",
|
||||||
|
"@nestjs/schematics": "^10.2.0",
|
||||||
|
"@nestjs/testing": "^10.4.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.2.0",
|
||||||
|
"ts-loader": "^9.5.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/src/app.module.ts
Normal file
29
server/src/app.module.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { CandidateModule } from './candidate/candidate.module';
|
||||||
|
import { FaceAuthModule } from './face-auth/face-auth.module';
|
||||||
|
import { InterviewModule } from './interview/interview.module';
|
||||||
|
import { BrainModule } from './brain/brain.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// Load .env from project root
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: '../.env',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// MongoDB connection
|
||||||
|
MongooseModule.forRoot(
|
||||||
|
process.env.MONGODB_URI || 'mongodb://localhost:27017/ai-interviewer',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Feature modules
|
||||||
|
CandidateModule,
|
||||||
|
FaceAuthModule,
|
||||||
|
InterviewModule,
|
||||||
|
BrainModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule { }
|
||||||
25
server/src/brain/brain.module.ts
Normal file
25
server/src/brain/brain.module.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
Candidate,
|
||||||
|
CandidateSchema,
|
||||||
|
} from '../candidate/schemas/candidate.schema';
|
||||||
|
import {
|
||||||
|
ConversationState,
|
||||||
|
ConversationStateSchema,
|
||||||
|
} from '../interview/schemas/conversation-state.schema';
|
||||||
|
import { BrainService } from './brain.service';
|
||||||
|
import { EvaluationService } from './evaluation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// BrainService needs access to the Candidate collection for resume context
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Candidate.name, schema: CandidateSchema },
|
||||||
|
{ name: ConversationState.name, schema: ConversationStateSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [BrainService, EvaluationService],
|
||||||
|
exports: [BrainService, EvaluationService],
|
||||||
|
})
|
||||||
|
export class BrainModule { }
|
||||||
239
server/src/brain/brain.service.ts
Normal file
239
server/src/brain/brain.service.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { Candidate, CandidateDocument } from '../candidate/schemas/candidate.schema';
|
||||||
|
import { TranscriptEntry } from '../interview/schemas/interview-session.schema';
|
||||||
|
import { ConversationState, ConversationStateDocument } from '../interview/schemas/conversation-state.schema';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrainService {
|
||||||
|
private readonly logger = new Logger(BrainService.name);
|
||||||
|
private readonly apiKey = process.env.CEREBRAS_API_KEY || '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Candidate.name)
|
||||||
|
private readonly candidateModel: Model<CandidateDocument>,
|
||||||
|
@InjectModel(ConversationState.name)
|
||||||
|
private readonly stateModel: Model<ConversationStateDocument>,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async generateResponse(
|
||||||
|
sessionId: string,
|
||||||
|
candidateId: string,
|
||||||
|
userText: string,
|
||||||
|
history: TranscriptEntry[],
|
||||||
|
): Promise<string> {
|
||||||
|
const candidate = await this.candidateModel.findById(candidateId).exec();
|
||||||
|
if (!candidate) return "I'm sorry, I cannot find your profile.";
|
||||||
|
|
||||||
|
let state = await this.stateModel.findOne({ sessionId }).exec();
|
||||||
|
if (!state) {
|
||||||
|
state = new this.stateModel({ sessionId, currentStrategy: 'INTRODUCTION' });
|
||||||
|
await state.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. generateChainedQuestion(): If previous user answer was very short or vague, ask "Why?" or probe deeper.
|
||||||
|
if (history.length > 0 && userText.split(' ').length < 10 && state.currentStrategy !== 'INTRODUCTION' && state.currentStrategy !== 'CONCLUSION') {
|
||||||
|
return this.generateChainedQuestion(userText, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
// Dynamic thresholds based on MAX_INTERVIEW_QUESTIONS
|
||||||
|
const maxEnv = parseInt(process.env.MAX_INTERVIEW_QUESTIONS || '15', 10);
|
||||||
|
const maxQuestions = Math.min(Math.max(maxEnv, 5), 25);
|
||||||
|
|
||||||
|
// At least 1 intro turn (which usually translates to 2 history items: user + ai greeting)
|
||||||
|
// We measure turns by history.length / 2.
|
||||||
|
const currentTurn = Math.floor(history.length / 2);
|
||||||
|
|
||||||
|
const introLimit = Math.max(1, Math.floor(maxQuestions * 0.10));
|
||||||
|
const expLimit = introLimit + Math.max(1, Math.floor(maxQuestions * 0.30));
|
||||||
|
const techLimit = expLimit + Math.max(1, Math.floor(maxQuestions * 0.30));
|
||||||
|
const oddLimit = techLimit + Math.max(1, Math.floor(maxQuestions * 0.15));
|
||||||
|
const behavLimit = maxQuestions; // The rest
|
||||||
|
|
||||||
|
// Switch based on current strategy lifecycle
|
||||||
|
switch (state.currentStrategy) {
|
||||||
|
case 'INTRODUCTION':
|
||||||
|
responseText = await this.handleIntro(candidate, history);
|
||||||
|
if (currentTurn >= introLimit) {
|
||||||
|
state.currentStrategy = 'EXPERIENCE_DEEP_DIVE';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'EXPERIENCE_DEEP_DIVE':
|
||||||
|
responseText = await this.askExperienceBased(candidate, history);
|
||||||
|
if (currentTurn >= expLimit) {
|
||||||
|
state.currentStrategy = 'TECHNICAL_CORE';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'TECHNICAL_CORE':
|
||||||
|
responseText = await this.askTechnicalAndCore(state, candidate, history);
|
||||||
|
if (currentTurn >= techLimit) {
|
||||||
|
state.currentStrategy = 'ODD_SKILL_PROBE';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ODD_SKILL_PROBE':
|
||||||
|
responseText = await this.detectAndAskOddSkill(state, candidate, history);
|
||||||
|
if (currentTurn >= oddLimit) {
|
||||||
|
state.currentStrategy = 'BEHAVIORAL';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'BEHAVIORAL':
|
||||||
|
responseText = await this.handleBehavioral(candidate, history);
|
||||||
|
if (currentTurn >= behavLimit - 1) { // Minus 1 because the next turn will be conclusion wrapper
|
||||||
|
state.currentStrategy = 'CONCLUSION';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'CONCLUSION':
|
||||||
|
default:
|
||||||
|
responseText = await this.handleConclusion(candidate, history);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastQuestionAsked = responseText;
|
||||||
|
await state.save();
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callCerebras(messages: Array<{ role: string, content: string }>, maxTokens = 150): Promise<string> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn('CEREBRAS_API_KEY missing. Mocking response.');
|
||||||
|
return "That's interesting. Please tell me more.";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.cerebras.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama3.1-8b',
|
||||||
|
messages,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data?.choices?.[0]?.message?.content || "Can you elaborate?";
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Cerebras error: ${err}`);
|
||||||
|
return "I missed that, could you repeat your point?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatHistory(history: TranscriptEntry[]): Array<{ role: string; content: string }> {
|
||||||
|
return history.map(h => ({
|
||||||
|
role: h.role === 'ai' ? 'assistant' : 'user',
|
||||||
|
content: h.text
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Chained Question
|
||||||
|
private async generateChainedQuestion(userText: string, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: 'The candidate just gave a very brief or vague answer. Politely ask them to elaborate, explain "why", or provide a specific example. Keep the prompt exactly 1 sentence.' },
|
||||||
|
...this.formatHistory(history.slice(-3)),
|
||||||
|
{ role: 'user', content: userText }
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Odd Skill Probe
|
||||||
|
private async detectAndAskOddSkill(state: ConversationStateDocument, candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const skills = candidate.skills?.join(', ') || 'general web development';
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `You are a technical interviewer. The candidate listed these skills: ${skills}. Find the most unusual, niche, or distinct skill in that list. Ask a highly specific, challenging question about their experience with that exact skill. 1-2 sentences max.` },
|
||||||
|
...this.formatHistory(history.slice(-4))
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Experience Deep Dive
|
||||||
|
private async askExperienceBased(candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `You are an interviewer focusing on the candidate's past projects. Here is their experience summary: "${candidate.experienceSummary || 'Candidate has a background in software engineering.'}". Ask a probing, role-specific question about the impact or architecture of one of those projects. 1-2 sentences max.` },
|
||||||
|
...this.formatHistory(history.slice(-4))
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Technical Core
|
||||||
|
private async askTechnicalAndCore(state: ConversationStateDocument, candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
state.technicalQuestionsAsked += 1;
|
||||||
|
const type = state.technicalQuestionsAsked % 2 === 0 ? 'Computer Science core concepts (e.g., Big O, memory, concurrency)' : 'frameworks mentioned in their resume';
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `You are a technical interviewer. Ask a technical question focusing on ${type}. Base it on their skills: ${candidate.skills?.join(', ') || 'programming'}. Make it challenging but fair. 1-2 sentences max.` },
|
||||||
|
...this.formatHistory(history.slice(-4))
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Behavioral
|
||||||
|
private async handleBehavioral(candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `You are a behavioral interviewer. Switch the tone to soft skills. Ask a tough scenario-based question (STAR method) regarding conflict resolution, tight deadlines, or difficult teamwork. 1-2 sentences max.` },
|
||||||
|
...this.formatHistory(history.slice(-4))
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Introduction
|
||||||
|
private async handleIntro(candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `You are the AI interviewer. The candidate's name is ${candidate.name}. Greet them, confirm their name, and ask for a very brief introduction. Be extremely welcoming. 2 sentences max.` },
|
||||||
|
...this.formatHistory(history)
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conclusion
|
||||||
|
private async handleConclusion(candidate: CandidateDocument, history: TranscriptEntry[]): Promise<string> {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: `Wrap up the interview. Thank the candidate ${candidate.name} for their time, summarize briefly that they did well, and ask if they have any final questions. 2 sentences max.` },
|
||||||
|
...this.formatHistory(history.slice(-2))
|
||||||
|
];
|
||||||
|
return this.callCerebras(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final Report Generator
|
||||||
|
async generateFinalReport(transcript: TranscriptEntry[]): Promise<any> {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
role: 'system', content: `You are an expert HR Interview Evaluator. Read the following interview transcript and output strictly a JSON object evaluating the candidate.
|
||||||
|
Return ONLY JSON in this exact structure:
|
||||||
|
{
|
||||||
|
"communication_skills": { "rating": 5, "review_message": "..." },
|
||||||
|
"technical": { "rating": 8, "review_message": "..." },
|
||||||
|
"behavior": { "rating": 7, "review_message": "..." },
|
||||||
|
"previous_experience_and_contribution": { "rating": 6, "review_message": "..." }
|
||||||
|
}` },
|
||||||
|
{ role: 'user', content: JSON.stringify(transcript) }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.cerebras.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama3.1-8b',
|
||||||
|
messages,
|
||||||
|
temperature: 0.2,
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data?.choices?.[0]?.message?.content;
|
||||||
|
if (content) {
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error generating final report: ${err}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
server/src/brain/evaluation.service.ts
Normal file
97
server/src/brain/evaluation.service.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { TranscriptEntry } from '../interview/schemas/interview-session.schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluation output schema as specified in requirements.
|
||||||
|
*/
|
||||||
|
export interface EvaluationResult {
|
||||||
|
communication: { rating: number; review_message: string };
|
||||||
|
technical: { rating: number; review_message: string };
|
||||||
|
behaviour: { rating: number; review_message: string };
|
||||||
|
experience: { rating: number; review_message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EvaluationService analyses the full interview transcript via Cerebras LLM
|
||||||
|
* and produces a structured JSON evaluation.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EvaluationService {
|
||||||
|
private readonly logger = new Logger(EvaluationService.name);
|
||||||
|
private readonly apiKey = process.env.CEREBRAS_API_KEY || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate the entire interview transcript.
|
||||||
|
* @param transcript Full conversation history
|
||||||
|
* @returns Structured evaluation with ratings and review messages
|
||||||
|
*/
|
||||||
|
async evaluate(transcript: TranscriptEntry[]): Promise<EvaluationResult> {
|
||||||
|
const formattedTranscript = transcript
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
`[${entry.role.toUpperCase()}]: ${entry.text}`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const systemPrompt = `You are an expert interview evaluator. Analyze the following interview transcript and provide a structured evaluation.
|
||||||
|
|
||||||
|
You MUST respond with ONLY valid JSON in this exact format — no markdown, no explanation:
|
||||||
|
{
|
||||||
|
"communication": { "rating": <0-10>, "review_message": "<brief review>" },
|
||||||
|
"technical": { "rating": <0-10>, "review_message": "<brief review>" },
|
||||||
|
"behaviour": { "rating": <0-10>, "review_message": "<brief review>" },
|
||||||
|
"experience": { "rating": <0-10>, "review_message": "<brief review>" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rating scale: 0 = not assessed, 1-3 = below expectations, 4-6 = meets expectations, 7-9 = exceeds expectations, 10 = exceptional.`;
|
||||||
|
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn(
|
||||||
|
'CEREBRAS_API_KEY not set — returning default evaluation',
|
||||||
|
);
|
||||||
|
return this.defaultEvaluation();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://api.cerebras.ai/v1/chat/completions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama-4-scout-17b-16e-instruct',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: formattedTranscript },
|
||||||
|
],
|
||||||
|
max_tokens: 500,
|
||||||
|
temperature: 0.3,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data?.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
|
// Parse the JSON response
|
||||||
|
const evaluation: EvaluationResult = JSON.parse(content);
|
||||||
|
this.logger.log('Evaluation generated successfully');
|
||||||
|
return evaluation;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Evaluation failed: ${err}`);
|
||||||
|
return this.defaultEvaluation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultEvaluation(): EvaluationResult {
|
||||||
|
return {
|
||||||
|
communication: { rating: 0, review_message: 'Not evaluated' },
|
||||||
|
technical: { rating: 0, review_message: 'Not evaluated' },
|
||||||
|
behaviour: { rating: 0, review_message: 'Not evaluated' },
|
||||||
|
experience: { rating: 0, review_message: 'Not evaluated' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
70
server/src/candidate/candidate.controller.ts
Normal file
70
server/src/candidate/candidate.controller.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { CandidateService } from './candidate.service';
|
||||||
|
import { CreateCandidateDto } from './dto/create-candidate.dto';
|
||||||
|
|
||||||
|
/** Multer storage configuration – saves uploads to /uploads with unique names */
|
||||||
|
const storage = diskStorage({
|
||||||
|
destination: path.join(__dirname, '..', '..', 'uploads'),
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${uuidv4()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
@Controller('candidates')
|
||||||
|
export class CandidateController {
|
||||||
|
constructor(private readonly candidateService: CandidateService) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /candidates
|
||||||
|
* Accepts multipart form data with optional resume and profile picture.
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(
|
||||||
|
FileFieldsInterceptor(
|
||||||
|
[
|
||||||
|
{ name: 'resume', maxCount: 1 },
|
||||||
|
{ name: 'profilePicture', maxCount: 1 },
|
||||||
|
],
|
||||||
|
{ storage },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async create(
|
||||||
|
@Body() dto: CreateCandidateDto,
|
||||||
|
@UploadedFiles()
|
||||||
|
files: {
|
||||||
|
resume?: Express.Multer.File[];
|
||||||
|
profilePicture?: Express.Multer.File[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.candidateService.create(
|
||||||
|
dto,
|
||||||
|
files?.resume?.[0],
|
||||||
|
files?.profilePicture?.[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /candidates/:id */
|
||||||
|
@Get(':id')
|
||||||
|
async findById(@Param('id') id: string) {
|
||||||
|
return this.candidateService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /candidates */
|
||||||
|
@Get()
|
||||||
|
async findAll() {
|
||||||
|
return this.candidateService.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server/src/candidate/candidate.module.ts
Normal file
20
server/src/candidate/candidate.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { Candidate, CandidateSchema } from './schemas/candidate.schema';
|
||||||
|
import { CandidateController } from './candidate.controller';
|
||||||
|
import { CandidateService } from './candidate.service';
|
||||||
|
import { OcrService } from './services/ocr.service';
|
||||||
|
import { FaceAuthModule } from '../face-auth/face-auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Candidate.name, schema: CandidateSchema },
|
||||||
|
]),
|
||||||
|
forwardRef(() => FaceAuthModule),
|
||||||
|
],
|
||||||
|
controllers: [CandidateController],
|
||||||
|
providers: [CandidateService, OcrService],
|
||||||
|
exports: [CandidateService],
|
||||||
|
})
|
||||||
|
export class CandidateModule { }
|
||||||
137
server/src/candidate/candidate.service.ts
Normal file
137
server/src/candidate/candidate.service.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { Candidate, CandidateDocument } from './schemas/candidate.schema';
|
||||||
|
import { CreateCandidateDto } from './dto/create-candidate.dto';
|
||||||
|
import { OcrService } from './services/ocr.service';
|
||||||
|
import { FaceAuthService } from '../face-auth/face-auth.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CandidateService {
|
||||||
|
private readonly logger = new Logger(CandidateService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Candidate.name)
|
||||||
|
private readonly candidateModel: Model<CandidateDocument>,
|
||||||
|
private readonly ocrService: OcrService,
|
||||||
|
private readonly faceAuthService: FaceAuthService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new candidate, process their resume and profile picture.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateCandidateDto,
|
||||||
|
resumeFile?: Express.Multer.File,
|
||||||
|
profilePicture?: Express.Multer.File,
|
||||||
|
): Promise<CandidateDocument> {
|
||||||
|
const candidate = new this.candidateModel({
|
||||||
|
name: dto.name,
|
||||||
|
email: dto.email,
|
||||||
|
experienceSummary: dto.experienceSummary || '',
|
||||||
|
resumePath: resumeFile?.path || '',
|
||||||
|
profilePicturePath: profilePicture?.path || '',
|
||||||
|
captureFaceOnCall: !profilePicture, // No photo → capture during call
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── OCR: extract resume text ──
|
||||||
|
if (resumeFile?.path) {
|
||||||
|
try {
|
||||||
|
candidate.resumeText = await this.ocrService.extractText(
|
||||||
|
resumeFile.path,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Extracted ${candidate.resumeText.length} chars from resume`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use Cerebras to extract structured skills and summary
|
||||||
|
if (candidate.resumeText) {
|
||||||
|
this.logger.log(`Extracting metadata from resume via Cerebras`);
|
||||||
|
const metadata = await this.extractResumeMetadata(candidate.resumeText);
|
||||||
|
candidate.skills = metadata.skills;
|
||||||
|
if (!candidate.experienceSummary) {
|
||||||
|
candidate.experienceSummary = metadata.experienceSummary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Resume OCR or Metadata Extraction failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Face descriptor extraction ──
|
||||||
|
if (profilePicture?.path) {
|
||||||
|
try {
|
||||||
|
const descriptor = await this.faceAuthService.extractDescriptor(
|
||||||
|
profilePicture.path,
|
||||||
|
);
|
||||||
|
if (descriptor) {
|
||||||
|
candidate.faceDescriptor = Array.from(descriptor);
|
||||||
|
candidate.captureFaceOnCall = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Face descriptor extraction failed: ${err}`);
|
||||||
|
candidate.captureFaceOnCall = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<CandidateDocument> {
|
||||||
|
const candidate = await this.candidateModel.findById(id).exec();
|
||||||
|
if (!candidate) {
|
||||||
|
throw new NotFoundException(`Candidate ${id} not found`);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<CandidateDocument[]> {
|
||||||
|
return this.candidateModel.find().exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extractResumeMetadata(resumeText: string): Promise<{ skills: string[], experienceSummary: string }> {
|
||||||
|
this.logger.log(`Extracting metadata from resume via Cerebras`);
|
||||||
|
const apiKey = process.env.CEREBRAS_API_KEY;
|
||||||
|
if (!apiKey || !resumeText) return { skills: [], experienceSummary: '' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = `Extract the core technical skills and a brief professional experience summary from this resume.
|
||||||
|
Return strictly a JSON object with this exact structure, nothing else:
|
||||||
|
{
|
||||||
|
"skills": ["skill1", "skill2"],
|
||||||
|
"experienceSummary": "A concise 2-sentence summary of their professional background."
|
||||||
|
}
|
||||||
|
|
||||||
|
Resume Text:
|
||||||
|
${resumeText.substring(0, 5000)}`;
|
||||||
|
|
||||||
|
const response = await fetch('https://api.cerebras.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'llama3.1-8b',
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
temperature: 0.1,
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data?.choices?.[0]?.message?.content;
|
||||||
|
if (content) {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return {
|
||||||
|
skills: parsed.skills || [],
|
||||||
|
experienceSummary: parsed.experienceSummary || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to extract metadata from resume via Cerebras: ${err}`);
|
||||||
|
}
|
||||||
|
return { skills: [], experienceSummary: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
18
server/src/candidate/dto/create-candidate.dto.ts
Normal file
18
server/src/candidate/dto/create-candidate.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new candidate.
|
||||||
|
* Resume and profile picture are handled via Multer file uploads.
|
||||||
|
*/
|
||||||
|
export class CreateCandidateDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
experienceSummary?: string;
|
||||||
|
}
|
||||||
39
server/src/candidate/schemas/candidate.schema.ts
Normal file
39
server/src/candidate/schemas/candidate.schema.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { Document } from 'mongoose';
|
||||||
|
|
||||||
|
export type CandidateDocument = Candidate & Document;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class Candidate {
|
||||||
|
@Prop({ required: true, trim: true })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Prop({ required: true, trim: true, lowercase: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
resumeText: string;
|
||||||
|
|
||||||
|
@Prop({ type: [Number], default: [] })
|
||||||
|
faceDescriptor: number[];
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
captureFaceOnCall: boolean;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
profilePicturePath: string;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
resumePath: string;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
experienceSummary: string;
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
skills: string[];
|
||||||
|
|
||||||
|
@Prop({ type: Object, default: {} })
|
||||||
|
evaluation: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CandidateSchema = SchemaFactory.createForClass(Candidate);
|
||||||
94
server/src/candidate/services/ocr.service.ts
Normal file
94
server/src/candidate/services/ocr.service.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OcrService extracts text from uploaded resume files.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. If the file is a PDF, try `pdf-parse` first (fast, works on text PDFs).
|
||||||
|
* 2. If pdf-parse returns little/no text, fall back to `tesseract.js` OCR.
|
||||||
|
* 3. If the file is an image, go straight to tesseract.
|
||||||
|
*
|
||||||
|
* tesseract.js runs in its own worker, so the NestJS event loop is not blocked.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OcrService {
|
||||||
|
private readonly logger = new Logger(OcrService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract text from a resume file.
|
||||||
|
* @param filePath – absolute path to the uploaded file
|
||||||
|
* @returns the extracted plain text
|
||||||
|
*/
|
||||||
|
async extractText(filePath: string): Promise<string> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
if (ext === '.pdf') {
|
||||||
|
return this.extractFromPdf(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image files (.png, .jpg, .jpeg, .bmp, .tiff)
|
||||||
|
return this.extractFromImage(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── PDF extraction ────────────────────
|
||||||
|
|
||||||
|
private async extractFromPdf(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Dynamic import to keep the module optional if not installed
|
||||||
|
const pdfParseModule = await import('pdf-parse');
|
||||||
|
// Support both CommonJS and ESM-style imports
|
||||||
|
const pdfParse = (pdfParseModule as any).default || pdfParseModule;
|
||||||
|
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
const data = await pdfParse(buffer);
|
||||||
|
|
||||||
|
// If meaningful text was extracted, return it
|
||||||
|
if (data.text && data.text.trim().length > 50) {
|
||||||
|
this.logger.log(
|
||||||
|
`pdf-parse extracted ${data.text.length} chars from ${filePath}`,
|
||||||
|
);
|
||||||
|
return data.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanned PDF → fall back to OCR
|
||||||
|
this.logger.warn(
|
||||||
|
'pdf-parse returned little text; falling back to tesseract OCR',
|
||||||
|
);
|
||||||
|
return this.extractFromImage(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`pdf-parse failed: ${error}; falling back to OCR`);
|
||||||
|
return this.extractFromImage(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────── Image / OCR extraction ────────────────────
|
||||||
|
|
||||||
|
private async extractFromImage(filePath: string): Promise<string> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === '.pdf') {
|
||||||
|
this.logger.warn(`Tesseract OCR cannot process PDF files directly: ${filePath}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createWorker } = await import('tesseract.js');
|
||||||
|
const worker = await createWorker('eng');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { text },
|
||||||
|
} = await worker.recognize(filePath);
|
||||||
|
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`tesseract.js extracted ${text.length} chars from ${filePath}`,
|
||||||
|
);
|
||||||
|
return text.trim();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Tesseract OCR failed: ${error}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/src/face-auth/face-auth.module.ts
Normal file
8
server/src/face-auth/face-auth.module.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FaceAuthService } from './face-auth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [FaceAuthService],
|
||||||
|
exports: [FaceAuthService],
|
||||||
|
})
|
||||||
|
export class FaceAuthModule { }
|
||||||
123
server/src/face-auth/face-auth.service.ts
Normal file
123
server/src/face-auth/face-auth.service.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FaceAuthService handles face descriptor extraction and verification
|
||||||
|
* using face-api.js running on the Node.js canvas backend.
|
||||||
|
*
|
||||||
|
* On module init it loads the required neural-network models from disk.
|
||||||
|
* If the models aren't present yet, it logs a warning and works in
|
||||||
|
* "passthrough" mode (all verifications return true).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FaceAuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(FaceAuthService.name);
|
||||||
|
private faceapi: any = null;
|
||||||
|
private modelsLoaded = false;
|
||||||
|
|
||||||
|
/** Directory where face-api.js model weight files live */
|
||||||
|
private readonly modelsPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'face-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
try {
|
||||||
|
// face-api.js requires the canvas package in Node
|
||||||
|
const canvas = await import('canvas');
|
||||||
|
const faceapi = await import('face-api.js');
|
||||||
|
|
||||||
|
// Patch face-api.js to use node-canvas
|
||||||
|
const { Canvas, Image, ImageData } = canvas;
|
||||||
|
faceapi.env.monkeyPatch({
|
||||||
|
Canvas: Canvas as any,
|
||||||
|
Image: Image as any,
|
||||||
|
ImageData: ImageData as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load detection + recognition models
|
||||||
|
await faceapi.nets.ssdMobilenetv1.loadFromDisk(this.modelsPath);
|
||||||
|
await faceapi.nets.faceLandmark68Net.loadFromDisk(this.modelsPath);
|
||||||
|
await faceapi.nets.faceRecognitionNet.loadFromDisk(this.modelsPath);
|
||||||
|
|
||||||
|
this.faceapi = faceapi;
|
||||||
|
this.modelsLoaded = true;
|
||||||
|
this.logger.log('face-api.js models loaded successfully');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`face-api.js initialization failed (models may be missing): ${err}. ` +
|
||||||
|
'Face verification will run in passthrough mode.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a 128-dimensional face descriptor from an image file.
|
||||||
|
* Returns null if no face is detected or models aren't loaded.
|
||||||
|
*/
|
||||||
|
async extractDescriptor(imagePath: string): Promise<Float32Array | null> {
|
||||||
|
if (!this.modelsLoaded || !this.faceapi) {
|
||||||
|
this.logger.warn('Models not loaded – skipping descriptor extraction');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = await import('canvas');
|
||||||
|
const img = await canvas.loadImage(imagePath);
|
||||||
|
const detection = await this.faceapi
|
||||||
|
.detectSingleFace(img as any)
|
||||||
|
.withFaceLandmarks()
|
||||||
|
.withFaceDescriptor();
|
||||||
|
|
||||||
|
if (!detection) {
|
||||||
|
this.logger.warn(`No face detected in ${imagePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detection.descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a face descriptor from a raw image buffer (e.g. a webcam frame).
|
||||||
|
*/
|
||||||
|
async extractDescriptorFromBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<Float32Array | null> {
|
||||||
|
if (!this.modelsLoaded || !this.faceapi) return null;
|
||||||
|
|
||||||
|
const canvas = await import('canvas');
|
||||||
|
const img = await canvas.loadImage(buffer);
|
||||||
|
const detection = await this.faceapi
|
||||||
|
.detectSingleFace(img as any)
|
||||||
|
.withFaceLandmarks()
|
||||||
|
.withFaceDescriptor();
|
||||||
|
|
||||||
|
return detection?.descriptor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two face descriptors. Returns match result and euclidean distance.
|
||||||
|
* Threshold of 0.6 is the standard for face-api.js.
|
||||||
|
*/
|
||||||
|
verifyFace(
|
||||||
|
incoming: Float32Array | number[],
|
||||||
|
stored: Float32Array | number[],
|
||||||
|
threshold = 0.6,
|
||||||
|
): { match: boolean; distance: number } {
|
||||||
|
if (!this.modelsLoaded || !this.faceapi) {
|
||||||
|
// Passthrough mode – assume match
|
||||||
|
return { match: true, distance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = this.faceapi.euclideanDistance(
|
||||||
|
Array.from(incoming),
|
||||||
|
Array.from(stored),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
match: distance < threshold,
|
||||||
|
distance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
162
server/src/interview/deepgram.handler.ts
Normal file
162
server/src/interview/deepgram.handler.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { BrainService } from '../brain/brain.service';
|
||||||
|
import { InterviewSessionDocument } from './schemas/interview-session.schema';
|
||||||
|
|
||||||
|
export class DeepgramClientHandler {
|
||||||
|
private readonly logger = new Logger(DeepgramClientHandler.name);
|
||||||
|
private readonly sttApiKey = process.env.DEEPGRAM_API_KEY || '';
|
||||||
|
|
||||||
|
private sttWs!: WebSocket;
|
||||||
|
private active = false;
|
||||||
|
|
||||||
|
private transcriptBuffer = '';
|
||||||
|
private silenceTimer?: NodeJS.Timeout;
|
||||||
|
private readonly SILENCE_DELAY = 1000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly socket: Socket,
|
||||||
|
private readonly sessionId: string,
|
||||||
|
private readonly session: InterviewSessionDocument,
|
||||||
|
private readonly brainService: BrainService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sttUrl = 'wss://api.deepgram.com/v1/listen?encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300';
|
||||||
|
this.sttWs = new WebSocket(sttUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${this.sttApiKey}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sttWs.on('open', () => {
|
||||||
|
this.logger.log(`Deepgram STT ready for session ${this.sessionId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sttWs.on('message', async (data: any) => {
|
||||||
|
if (!this.active) return;
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data.toString());
|
||||||
|
const transcript = response.channel?.alternatives?.[0]?.transcript?.trim();
|
||||||
|
const isFinal = response.is_final;
|
||||||
|
const speechFinal = response.speech_final;
|
||||||
|
|
||||||
|
if (transcript) {
|
||||||
|
this.logger.log(`[${this.sessionId}] Deepgram STT returned: "${transcript}"`);
|
||||||
|
this.transcriptBuffer += (this.transcriptBuffer ? ' ' : '') + transcript;
|
||||||
|
this.socket.emit('ai-transcript', { text: transcript });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speechFinal || (isFinal && transcript)) {
|
||||||
|
this.resetSilenceTimer();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Error parsing Deepgram message: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sttWs.on('error', (err) => this.logger.error(`Deepgram STT error: ${err}`));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to initialize Deepgram connections: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetSilenceTimer() {
|
||||||
|
if (this.silenceTimer) clearTimeout(this.silenceTimer);
|
||||||
|
this.silenceTimer = setTimeout(async () => {
|
||||||
|
if (this.transcriptBuffer.trim()) {
|
||||||
|
await this.processUserInput(this.transcriptBuffer.trim());
|
||||||
|
this.transcriptBuffer = '';
|
||||||
|
}
|
||||||
|
}, this.SILENCE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async processUserInput(text: string) {
|
||||||
|
this.logger.log(`[${this.sessionId}] User said: "${text.slice(0, 100)}..."`);
|
||||||
|
this.socket.emit('ai-state', { state: 'thinking' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.session.transcriptLogs.push({
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiResponse = await this.brainService.generateResponse(
|
||||||
|
this.sessionId,
|
||||||
|
this.session.candidateId.toString(),
|
||||||
|
text,
|
||||||
|
this.session.transcriptLogs,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.session.transcriptLogs.push({
|
||||||
|
role: 'ai',
|
||||||
|
text: aiResponse,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
await this.session.save();
|
||||||
|
|
||||||
|
this.socket.emit('ai-transcript', { text: aiResponse });
|
||||||
|
this.socket.emit('ai-state', { state: 'speaking' });
|
||||||
|
|
||||||
|
await this.sendToTts(aiResponse);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.socket.emit('ai-state', { state: 'listening' });
|
||||||
|
}, 500);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Pipeline error: ${err}`);
|
||||||
|
this.socket.emit('error', { message: 'Pipeline processing failed' });
|
||||||
|
this.socket.emit('ai-state', { state: 'listening' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendToTts(text: string) {
|
||||||
|
try {
|
||||||
|
this.logger.log(`[${this.sessionId}] Calling Deepgram TTS for response...`);
|
||||||
|
const url = `https://api.deepgram.com/v1/speak?model=aura-asteria-en`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token ${this.sttApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Deepgram TTS failed: ${response.statusText}`);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
this.logger.log(`[${this.sessionId}] Deepgram TTS returned audio arraybuffer of length: ${arrayBuffer.byteLength}`);
|
||||||
|
this.socket.emit('ai-audio', Buffer.from(arrayBuffer));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to stream Deepgram TTS: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAudioChunk(audioBase64: string) {
|
||||||
|
if (!this.active || !this.sttWs || this.sttWs.readyState !== 1) return;
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(audioBase64, 'base64');
|
||||||
|
this.sttWs.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Transcribe error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (!this.active) return;
|
||||||
|
this.active = false;
|
||||||
|
if (this.silenceTimer) clearTimeout(this.silenceTimer);
|
||||||
|
try {
|
||||||
|
this.sttWs?.close?.();
|
||||||
|
this.logger.log(`Cleaned up Deepgram session ${this.sessionId}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cleanup error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
server/src/interview/interview.controller.ts
Normal file
47
server/src/interview/interview.controller.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Controller, Post, Param, Get, Body } from '@nestjs/common';
|
||||||
|
import { OrchestratorService } from './services/orchestrator.service';
|
||||||
|
import { BrainService } from '../brain/brain.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST endpoints for interview session management (non-realtime operations).
|
||||||
|
*/
|
||||||
|
@Controller('interviews')
|
||||||
|
export class InterviewController {
|
||||||
|
constructor(
|
||||||
|
private readonly orchestrator: OrchestratorService,
|
||||||
|
private readonly brainService: BrainService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/** POST /interviews — create a new interview session */
|
||||||
|
@Post()
|
||||||
|
async createSession(@Body() body: { candidateId: string }) {
|
||||||
|
return this.orchestrator.createSession(body.candidateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /interviews/:id — get session details */
|
||||||
|
@Get(':id')
|
||||||
|
async getSession(@Param('id') id: string) {
|
||||||
|
return this.orchestrator.getSession(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /interviews/:id/evaluate — end the interview and trigger
|
||||||
|
* LLM-based evaluation of the full transcript.
|
||||||
|
*/
|
||||||
|
@Post(':id/evaluate')
|
||||||
|
async evaluate(@Param('id') id: string) {
|
||||||
|
const session = await this.orchestrator.getSession(id);
|
||||||
|
await this.orchestrator.endSession(id);
|
||||||
|
|
||||||
|
const evaluation = await this.brainService.generateFinalReport(
|
||||||
|
session.transcriptLogs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist evaluation on the session
|
||||||
|
session.evaluation = evaluation;
|
||||||
|
session.status = 'completed';
|
||||||
|
await session.save();
|
||||||
|
|
||||||
|
return { sessionId: id, evaluation };
|
||||||
|
}
|
||||||
|
}
|
||||||
242
server/src/interview/interview.gateway.ts
Normal file
242
server/src/interview/interview.gateway.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
MessageBody,
|
||||||
|
ConnectedSocket,
|
||||||
|
OnGatewayInit,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { OrchestratorService } from './services/orchestrator.service';
|
||||||
|
import { FaceAuthService } from '../face-auth/face-auth.service';
|
||||||
|
import { CandidateService } from '../candidate/candidate.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InterviewGateway — real-time WebSocket entry point for AI interviews.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Room joining (linking a socket to a session)
|
||||||
|
* - Streaming audio chunks → STT (real-time via WebSocket)
|
||||||
|
* - VAD events are emitted automatically by the STT provider
|
||||||
|
* - TTS audio chunks are streamed back as they are generated
|
||||||
|
* - Manual end-of-speech fallback for providers without VAD
|
||||||
|
* - Face verification (single frame from webcam)
|
||||||
|
*/
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: { origin: '*' },
|
||||||
|
namespace: '/interview',
|
||||||
|
})
|
||||||
|
export class InterviewGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(InterviewGateway.name);
|
||||||
|
|
||||||
|
/** Map socket → sessionId for cleanup */
|
||||||
|
private socketSessions: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly orchestrator: OrchestratorService,
|
||||||
|
private readonly faceAuth: FaceAuthService,
|
||||||
|
private readonly candidateService: CandidateService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
afterInit() {
|
||||||
|
this.logger.log('Interview WebSocket Gateway initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
const sessionId = this.socketSessions.get(client.id);
|
||||||
|
if (sessionId) {
|
||||||
|
this.logger.log(
|
||||||
|
`Client ${client.id} disconnected — cleaning up session ${sessionId}`,
|
||||||
|
);
|
||||||
|
this.orchestrator.endSession(sessionId).catch((err) => {
|
||||||
|
this.logger.error(`Cleanup error: ${err}`);
|
||||||
|
});
|
||||||
|
this.socketSessions.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────── Socket Events ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client joins an interview session room.
|
||||||
|
* Initializes the streaming STT + TTS connections for this session.
|
||||||
|
*
|
||||||
|
* Payload: { candidateId: string }
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('join-room')
|
||||||
|
async handleJoinRoom(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { candidateId: string },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await this.orchestrator.createSession(data.candidateId);
|
||||||
|
const sessionId = session._id.toString();
|
||||||
|
|
||||||
|
client.join(sessionId);
|
||||||
|
this.socketSessions.set(client.id, sessionId);
|
||||||
|
|
||||||
|
// Initialize streaming with the socket directly
|
||||||
|
await this.orchestrator.initStreaming(sessionId, client);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Client ${client.id} joined session ${sessionId} — streaming ready`,
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit('session-created', {
|
||||||
|
sessionId,
|
||||||
|
stage: session.currentStage,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client.emit('error', { message: `Failed to join room: ${err}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive a base64-encoded audio chunk from the client's microphone.
|
||||||
|
* Piped directly to the STT streaming WebSocket (no buffering).
|
||||||
|
*
|
||||||
|
* Payload: { audio: string } (base64) or Buffer (binary array from Socket.io)
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('audio-chunk')
|
||||||
|
handleAudioChunk(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: any,
|
||||||
|
) {
|
||||||
|
const sessionId = this.socketSessions.get(client.id);
|
||||||
|
if (!sessionId) {
|
||||||
|
client.emit('error', { message: 'Not in a session' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioBase64: string;
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
// It's a binary buffer from Socket.io
|
||||||
|
audioBase64 = data.toString('base64');
|
||||||
|
} else if (data instanceof ArrayBuffer) {
|
||||||
|
audioBase64 = Buffer.from(data).toString('base64');
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
audioBase64 = data;
|
||||||
|
} else if (data && data.audio) {
|
||||||
|
audioBase64 = data.audio;
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Unknown audio chunk format received: ${typeof data}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print dot to let us know chunks are flowing without spamming logs
|
||||||
|
process.stdout.write('.');
|
||||||
|
this.orchestrator.streamAudioChunk(sessionId, audioBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual end-of-speech signal (fallback for providers without VAD).
|
||||||
|
* Triggers the STT → Brain → TTS pipeline with accumulated transcript.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('end-of-speech')
|
||||||
|
async handleEndOfSpeech(@ConnectedSocket() client: Socket) {
|
||||||
|
const sessionId = this.socketSessions.get(client.id);
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.orchestrator.triggerPipeline(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Pipeline error: ${err}`);
|
||||||
|
client.emit('error', { message: 'Pipeline processing failed' });
|
||||||
|
client.emit('ai-state', { state: 'listening' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive a video frame (JPEG buffer) for face verification.
|
||||||
|
* Payload: { candidateId: string, frame: Buffer }
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('face-verify')
|
||||||
|
async handleFaceVerify(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { candidateId: string; frame: Buffer },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const candidate = await this.candidateService.findById(data.candidateId);
|
||||||
|
|
||||||
|
// Extract descriptor from the incoming frame
|
||||||
|
const frameBuffer = Buffer.from(data.frame);
|
||||||
|
const incomingDescriptor =
|
||||||
|
await this.faceAuth.extractDescriptorFromBuffer(frameBuffer);
|
||||||
|
|
||||||
|
if (!incomingDescriptor) {
|
||||||
|
client.emit('face-result', {
|
||||||
|
verified: false,
|
||||||
|
message: 'No face detected in frame',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no stored descriptor, save this one as reference
|
||||||
|
if (
|
||||||
|
!candidate.faceDescriptor ||
|
||||||
|
candidate.faceDescriptor.length === 0
|
||||||
|
) {
|
||||||
|
candidate.faceDescriptor = Array.from(incomingDescriptor);
|
||||||
|
candidate.captureFaceOnCall = false;
|
||||||
|
await candidate.save();
|
||||||
|
client.emit('face-result', {
|
||||||
|
verified: true,
|
||||||
|
message: 'Reference face captured and saved',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare with stored descriptor
|
||||||
|
const result = this.faceAuth.verifyFace(
|
||||||
|
incomingDescriptor,
|
||||||
|
candidate.faceDescriptor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update session verification status
|
||||||
|
const sessionId = this.socketSessions.get(client.id);
|
||||||
|
if (sessionId) {
|
||||||
|
const session = await this.orchestrator.getSession(sessionId);
|
||||||
|
session.faceVerified = result.match;
|
||||||
|
await session.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
client.emit('face-result', {
|
||||||
|
verified: result.match,
|
||||||
|
distance: result.distance,
|
||||||
|
message: result.match
|
||||||
|
? 'Face verified successfully'
|
||||||
|
: 'Face mismatch — flagged in report',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Face verify error: ${err}`);
|
||||||
|
client.emit('face-result', {
|
||||||
|
verified: false,
|
||||||
|
message: `Verification error: ${err}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the interview session — closes streaming connections.
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('end-interview')
|
||||||
|
async handleEndInterview(@ConnectedSocket() client: Socket) {
|
||||||
|
const sessionId = this.socketSessions.get(client.id);
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
await this.orchestrator.endSession(sessionId);
|
||||||
|
client.emit('interview-ended', { sessionId });
|
||||||
|
this.socketSessions.delete(client.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/interview/interview.module.ts
Normal file
37
server/src/interview/interview.module.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
InterviewSession,
|
||||||
|
InterviewSessionSchema,
|
||||||
|
} from './schemas/interview-session.schema';
|
||||||
|
import {
|
||||||
|
ConversationState,
|
||||||
|
ConversationStateSchema,
|
||||||
|
} from './schemas/conversation-state.schema';
|
||||||
|
import { InterviewGateway } from './interview.gateway';
|
||||||
|
import { InterviewController } from './interview.controller';
|
||||||
|
import { OrchestratorService } from './services/orchestrator.service';
|
||||||
|
import { BrainModule } from '../brain/brain.module';
|
||||||
|
import { FaceAuthModule } from '../face-auth/face-auth.module';
|
||||||
|
import { CandidateModule } from '../candidate/candidate.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: InterviewSession.name, schema: InterviewSessionSchema },
|
||||||
|
{ name: ConversationState.name, schema: ConversationStateSchema },
|
||||||
|
]),
|
||||||
|
ConfigModule,
|
||||||
|
BrainModule,
|
||||||
|
FaceAuthModule,
|
||||||
|
forwardRef(() => CandidateModule),
|
||||||
|
],
|
||||||
|
controllers: [InterviewController],
|
||||||
|
providers: [
|
||||||
|
InterviewGateway,
|
||||||
|
OrchestratorService,
|
||||||
|
],
|
||||||
|
exports: [OrchestratorService],
|
||||||
|
})
|
||||||
|
export class InterviewModule { }
|
||||||
79
server/src/interview/providers/deepgram-stt.provider.ts
Normal file
79
server/src/interview/providers/deepgram-stt.provider.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createClient, LiveTranscriptionEvents } from '@deepgram/sdk';
|
||||||
|
import { IStreamingSTT } from './voice-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deepgram streaming STT provider.
|
||||||
|
* Uses Deepgram's live transcription WebSocket (Nova-2 model).
|
||||||
|
*
|
||||||
|
* Env: DEEPGRAM_API_KEY, DEEPGRAM_STT_LANGUAGE (default: en-US), DEEPGRAM_STT_MODEL (default: nova-2)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DeepgramSttProvider implements IStreamingSTT {
|
||||||
|
readonly name = 'deepgram-stt';
|
||||||
|
private readonly logger = new Logger(DeepgramSttProvider.name);
|
||||||
|
|
||||||
|
private connection: any = null;
|
||||||
|
private transcriptCb?: (text: string) => void;
|
||||||
|
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly language: string;
|
||||||
|
private readonly model: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiKey = this.config.get<string>('DEEPGRAM_API_KEY', '');
|
||||||
|
this.language = this.config.get<string>('DEEPGRAM_STT_LANGUAGE', 'en-US');
|
||||||
|
this.model = this.config.get<string>('DEEPGRAM_STT_MODEL', 'nova-2');
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn('DEEPGRAM_API_KEY not set — STT will not work');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dg = createClient(this.apiKey);
|
||||||
|
|
||||||
|
this.connection = dg.listen.live({
|
||||||
|
model: this.model,
|
||||||
|
language: this.language,
|
||||||
|
smart_format: true,
|
||||||
|
interim_results: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.connection!.on(LiveTranscriptionEvents.Open, resolve);
|
||||||
|
this.connection!.on(LiveTranscriptionEvents.Error, reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Deepgram STT WebSocket connected');
|
||||||
|
|
||||||
|
this.connection.on(LiveTranscriptionEvents.Transcript, (data: any) => {
|
||||||
|
const transcript = data.channel?.alternatives?.[0]?.transcript;
|
||||||
|
if (transcript) {
|
||||||
|
this.transcriptCb?.(transcript);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
transcribe(audioBase64: string): void {
|
||||||
|
if (!this.connection) return;
|
||||||
|
const buffer = Buffer.from(audioBase64, 'base64');
|
||||||
|
this.connection.send(buffer as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTranscript(cb: (text: string) => void): void {
|
||||||
|
this.transcriptCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deepgram doesn't natively emit VAD events in the same way — no-op */
|
||||||
|
onVadEvent(_cb: (event: any) => void): void {
|
||||||
|
// Not supported by Deepgram live transcription
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.connection?.requestClose?.();
|
||||||
|
this.logger.log('Deepgram STT WebSocket closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
80
server/src/interview/providers/deepgram-tts.provider.ts
Normal file
80
server/src/interview/providers/deepgram-tts.provider.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createClient as createDeepgramClient } from '@deepgram/sdk';
|
||||||
|
import { IStreamingTTS } from './voice-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deepgram streaming TTS provider.
|
||||||
|
* Uses Deepgram's speak.live WebSocket for real-time audio generation.
|
||||||
|
*
|
||||||
|
* Env: DEEPGRAM_API_KEY, DEEPGRAM_TTS_MODEL (default: aura-asteria-en),
|
||||||
|
* DEEPGRAM_TTS_CODEC (default: wav)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DeepgramTtsProvider implements IStreamingTTS {
|
||||||
|
readonly name = 'deepgram-tts';
|
||||||
|
private readonly logger = new Logger(DeepgramTtsProvider.name);
|
||||||
|
|
||||||
|
private conn?: any;
|
||||||
|
private audioCb?: (audioBase64: string) => void;
|
||||||
|
private errorCb?: (error: Error) => void;
|
||||||
|
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly model: string;
|
||||||
|
private readonly codec: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiKey = this.config.get<string>('DEEPGRAM_API_KEY', '');
|
||||||
|
this.model = this.config.get<string>('DEEPGRAM_TTS_MODEL', 'aura-asteria-en');
|
||||||
|
this.codec = this.config.get<string>('DEEPGRAM_TTS_CODEC', 'wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn('DEEPGRAM_API_KEY not set — TTS will not work');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createDeepgramClient(this.apiKey);
|
||||||
|
|
||||||
|
this.conn = client.speak.live({
|
||||||
|
model: this.model,
|
||||||
|
encoding: this.codec as any,
|
||||||
|
sample_rate: 8000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
this.conn!.on('open', resolve);
|
||||||
|
this.conn!.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Deepgram TTS WebSocket connected');
|
||||||
|
|
||||||
|
this.conn!.on('data', (chunk: ArrayBuffer) => {
|
||||||
|
try {
|
||||||
|
const audioBase64 = Buffer.from(chunk).toString('base64');
|
||||||
|
this.audioCb?.(audioBase64);
|
||||||
|
} catch (err) {
|
||||||
|
this.errorCb?.(err as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async speak(text: string): Promise<void> {
|
||||||
|
if (!this.conn) throw new Error('Deepgram TTS not connected');
|
||||||
|
this.conn.sendText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAudio(cb: (audioBase64: string) => void): void {
|
||||||
|
this.audioCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(cb: (error: Error) => void): void {
|
||||||
|
this.errorCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.conn?.requestClose?.();
|
||||||
|
this.logger.log('Deepgram TTS WebSocket closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/src/interview/providers/deepgram.provider.ts
Normal file
3
server/src/interview/providers/deepgram.provider.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// This file has been replaced by deepgram-stt.provider.ts and deepgram-tts.provider.ts
|
||||||
|
// It can be safely deleted.
|
||||||
|
export { };
|
||||||
121
server/src/interview/providers/sarvam-stt.provider.ts
Normal file
121
server/src/interview/providers/sarvam-stt.provider.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SarvamAIClient } from 'sarvamai';
|
||||||
|
import { SpeechToTextStreamingSocket } from 'sarvamai/dist/cjs/api/resources/speechToTextStreaming/client/Socket';
|
||||||
|
import { IStreamingSTT } from './voice-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sarvam AI streaming STT provider.
|
||||||
|
* Uses Sarvam's `speechToTextStreaming` WebSocket with VAD signals enabled.
|
||||||
|
*
|
||||||
|
* Env: SARVAM_API_KEY, SARVAM_STT_LANGUAGE (default: en-IN), SARVAM_STT_MODEL (default: saarika:v2.5)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SarvamSttProvider implements IStreamingSTT {
|
||||||
|
readonly name = 'sarvam-stt';
|
||||||
|
private readonly logger = new Logger(SarvamSttProvider.name);
|
||||||
|
|
||||||
|
private ws!: SpeechToTextStreamingSocket;
|
||||||
|
private transcriptCb?: (text: string) => void;
|
||||||
|
private vadCb?: (event: any) => void;
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly language: string;
|
||||||
|
private readonly model: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiKey = this.config.get<string>('SARVAM_API_KEY', '');
|
||||||
|
this.language = this.config.get<string>('SARVAM_STT_LANGUAGE', 'en-IN');
|
||||||
|
this.model = this.config.get<string>('SARVAM_STT_MODEL', 'saarika:v2.5');
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn('SARVAM_API_KEY not set — STT will not work');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new SarvamAIClient({ apiSubscriptionKey: this.apiKey });
|
||||||
|
|
||||||
|
this.logger.log('Connecting to Sarvam STT WebSocket...');
|
||||||
|
|
||||||
|
this.ws = await client.speechToTextStreaming.connect({
|
||||||
|
'Api-Subscription-Key': this.apiKey,
|
||||||
|
'language-code': this.language as any,
|
||||||
|
model: this.model as any,
|
||||||
|
high_vad_sensitivity: 'false',
|
||||||
|
vad_signals: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ws.waitForOpen();
|
||||||
|
this.connected = true;
|
||||||
|
this.logger.log('Sarvam STT WebSocket connected');
|
||||||
|
|
||||||
|
this.ws.on('message', (msg: any) => {
|
||||||
|
try {
|
||||||
|
const data = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||||
|
this.logger.debug(`STT message received: ${JSON.stringify(data).slice(0, 200)}`);
|
||||||
|
|
||||||
|
// Transcript events
|
||||||
|
if (data?.data?.transcript) {
|
||||||
|
this.transcriptCb?.(data.data.transcript);
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAD events (speech_start / speech_end / etc.)
|
||||||
|
if (data?.type === 'events') {
|
||||||
|
this.vadCb?.(data.data);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
this.logger.error(`STT message parse error: ${parseErr}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (err: any) => {
|
||||||
|
this.logger.error(`Sarvam STT WebSocket error: ${err}`);
|
||||||
|
this.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', () => {
|
||||||
|
this.logger.warn('Sarvam STT WebSocket closed unexpectedly');
|
||||||
|
this.connected = false;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to connect Sarvam STT: ${err}`);
|
||||||
|
this.connected = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transcribe(audioBase64: string): void {
|
||||||
|
if (!this.connected || !this.ws) {
|
||||||
|
this.logger.warn('STT not connected — dropping audio chunk');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws.transcribe({
|
||||||
|
audio: audioBase64,
|
||||||
|
encoding: 'audio/wav',
|
||||||
|
sample_rate: 16000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`STT transcribe error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTranscript(cb: (text: string) => void): void {
|
||||||
|
this.transcriptCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
onVadEvent(cb: (event: any) => void): void {
|
||||||
|
this.vadCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.connected = false;
|
||||||
|
this.ws?.close?.();
|
||||||
|
this.logger.log('Sarvam STT WebSocket closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
104
server/src/interview/providers/sarvam-tts.provider.ts
Normal file
104
server/src/interview/providers/sarvam-tts.provider.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SarvamAIClient } from 'sarvamai';
|
||||||
|
import { TextToSpeechStreamingSocket } from 'sarvamai/dist/cjs/api/resources/textToSpeechStreaming/client/Socket';
|
||||||
|
import { IStreamingTTS } from './voice-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sarvam AI streaming TTS provider — **Bulbul V3**.
|
||||||
|
*
|
||||||
|
* Key V3 changes vs V2:
|
||||||
|
* - `pitch` and `loudness` are REMOVED
|
||||||
|
* - `pace` (0.5–2.0) controls speech rate
|
||||||
|
* - `temperature` (0.01–2.0, default 0.6) controls expressiveness
|
||||||
|
* - preprocessing is always enabled
|
||||||
|
*
|
||||||
|
* Env: SARVAM_API_KEY, SARVAM_TTS_LANGUAGE, SARVAM_TTS_SPEAKER, SARVAM_TTS_PACE,
|
||||||
|
* SARVAM_TTS_TEMPERATURE, SARVAM_TTS_CODEC
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SarvamTtsProvider implements IStreamingTTS {
|
||||||
|
readonly name = 'sarvam-tts';
|
||||||
|
private readonly logger = new Logger(SarvamTtsProvider.name);
|
||||||
|
|
||||||
|
private client!: SarvamAIClient;
|
||||||
|
private ws?: TextToSpeechStreamingSocket;
|
||||||
|
private audioCb?: (audioBase64: string) => void;
|
||||||
|
private errorCb?: (error: Error) => void;
|
||||||
|
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly language: string;
|
||||||
|
private readonly speaker: string;
|
||||||
|
private readonly pace: number;
|
||||||
|
private readonly temperature: number;
|
||||||
|
private readonly codec: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiKey = this.config.get<string>('SARVAM_API_KEY', '');
|
||||||
|
this.language = this.config.get<string>('SARVAM_TTS_LANGUAGE', 'en-IN');
|
||||||
|
this.speaker = this.config.get<string>('SARVAM_TTS_SPEAKER', 'anushka');
|
||||||
|
this.pace = this.config.get<number>('SARVAM_TTS_PACE', 1.0);
|
||||||
|
this.temperature = this.config.get<number>('SARVAM_TTS_TEMPERATURE', 0.6);
|
||||||
|
this.codec = this.config.get<string>('SARVAM_TTS_CODEC', 'wav');
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (!this.apiKey) {
|
||||||
|
this.logger.warn('SARVAM_API_KEY not set — TTS will not work');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new SarvamAIClient({ apiSubscriptionKey: this.apiKey });
|
||||||
|
|
||||||
|
// Connect with Bulbul V3 model
|
||||||
|
this.ws = await this.client.textToSpeechStreaming.connect({
|
||||||
|
'Api-Subscription-Key': this.apiKey,
|
||||||
|
model: 'bulbul:v3' as any,
|
||||||
|
});
|
||||||
|
await this.ws.waitForOpen();
|
||||||
|
this.logger.log('Sarvam TTS WebSocket connected (bulbul:v3)');
|
||||||
|
|
||||||
|
// Configure connection with V3-specific parameters
|
||||||
|
this.ws.configureConnection({
|
||||||
|
target_language_code: this.language as any,
|
||||||
|
speaker: this.speaker as any,
|
||||||
|
pace: this.pace,
|
||||||
|
// V3: no pitch, no loudness — temperature controls expressiveness
|
||||||
|
min_buffer_size: 160,
|
||||||
|
output_audio_bitrate: '32k' as any,
|
||||||
|
speech_sample_rate: 8000,
|
||||||
|
max_chunk_length: 50,
|
||||||
|
output_audio_codec: this.codec as any,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Listen for audio chunks
|
||||||
|
this.ws.on('message', (msg: any) => {
|
||||||
|
try {
|
||||||
|
if (msg.type === 'audio' && msg.data?.audio) {
|
||||||
|
this.audioCb?.(msg.data.audio);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.errorCb?.(err as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async speak(text: string): Promise<void> {
|
||||||
|
if (!this.ws) throw new Error('Sarvam TTS not connected');
|
||||||
|
this.ws.convert(text);
|
||||||
|
this.ws.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAudio(cb: (audioBase64: string) => void): void {
|
||||||
|
this.audioCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(cb: (error: Error) => void): void {
|
||||||
|
this.errorCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.ws?.close();
|
||||||
|
this.logger.log('Sarvam TTS WebSocket closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/src/interview/providers/sarvam.provider.ts
Normal file
3
server/src/interview/providers/sarvam.provider.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// This file has been replaced by sarvam-stt.provider.ts and sarvam-tts.provider.ts
|
||||||
|
// It can be safely deleted.
|
||||||
|
export { };
|
||||||
49
server/src/interview/providers/voice-provider.interface.ts
Normal file
49
server/src/interview/providers/voice-provider.interface.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Streaming STT interface — WebSocket-based real-time transcription.
|
||||||
|
* Providers maintain an open connection and stream transcripts back.
|
||||||
|
*/
|
||||||
|
export interface IStreamingSTT {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** Open the WebSocket connection to the STT service */
|
||||||
|
connect(): Promise<void>;
|
||||||
|
|
||||||
|
/** Send a base64-encoded audio chunk to be transcribed */
|
||||||
|
transcribe(audioBase64: string): void;
|
||||||
|
|
||||||
|
/** Register callback for final transcript text */
|
||||||
|
onTranscript(cb: (text: string) => void): void;
|
||||||
|
|
||||||
|
/** Register callback for VAD events (speech_start, speech_end, etc.) */
|
||||||
|
onVadEvent?(cb: (event: any) => void): void;
|
||||||
|
|
||||||
|
/** Gracefully close the connection */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming TTS interface — WebSocket-based real-time speech synthesis.
|
||||||
|
* Providers stream audio chunks back as they are generated.
|
||||||
|
*/
|
||||||
|
export interface IStreamingTTS {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** Open the WebSocket connection to the TTS service */
|
||||||
|
connect(): Promise<void>;
|
||||||
|
|
||||||
|
/** Send text to be converted to speech */
|
||||||
|
speak(text: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Register callback for receiving audio chunks (base64) */
|
||||||
|
onAudio(cb: (audioBase64: string) => void): void;
|
||||||
|
|
||||||
|
/** Register callback for errors */
|
||||||
|
onError?(cb: (error: Error) => void): void;
|
||||||
|
|
||||||
|
/** Gracefully close the connection */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injection tokens */
|
||||||
|
export const STT_PROVIDER = 'STT_PROVIDER';
|
||||||
|
export const TTS_PROVIDER = 'TTS_PROVIDER';
|
||||||
192
server/src/interview/sarvam.handler.ts
Normal file
192
server/src/interview/sarvam.handler.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
import { SarvamAIClient } from 'sarvamai';
|
||||||
|
import { SpeechToTextStreamingSocket } from 'sarvamai/dist/cjs/api/resources/speechToTextStreaming/client/Socket';
|
||||||
|
import { TextToSpeechStreamingSocket } from 'sarvamai/dist/cjs/api/resources/textToSpeechStreaming/client/Socket';
|
||||||
|
import { BrainService } from '../brain/brain.service';
|
||||||
|
import { InterviewSessionDocument } from './schemas/interview-session.schema';
|
||||||
|
import { pcm16ToWav } from '../utils/audio.utils';
|
||||||
|
|
||||||
|
export class SarvamClientHandler {
|
||||||
|
private readonly logger = new Logger(SarvamClientHandler.name);
|
||||||
|
private readonly apiKey = process.env.SARVAM_API_KEY || '';
|
||||||
|
|
||||||
|
private sttWs!: SpeechToTextStreamingSocket;
|
||||||
|
private ttsWs!: TextToSpeechStreamingSocket;
|
||||||
|
|
||||||
|
private active = false;
|
||||||
|
private userSpeaking = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly socket: Socket,
|
||||||
|
private readonly sessionId: string,
|
||||||
|
private readonly session: InterviewSessionDocument,
|
||||||
|
private readonly brainService: BrainService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.active = true;
|
||||||
|
const sarvam = new SarvamAIClient({ apiSubscriptionKey: this.apiKey });
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.sttWs = await sarvam.speechToTextStreaming.connect({
|
||||||
|
'Api-Subscription-Key': this.apiKey,
|
||||||
|
'language-code': 'en-IN',
|
||||||
|
model: 'saaras:v2.5' as any,
|
||||||
|
high_vad_sensitivity: 'false',
|
||||||
|
vad_signals: 'true',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
this.ttsWs = await sarvam.textToSpeechStreaming.connect({
|
||||||
|
'Api-Subscription-Key': this.apiKey,
|
||||||
|
model: 'bulbul:v3' as any,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await Promise.all([this.sttWs.waitForOpen(), this.ttsWs.waitForOpen()]);
|
||||||
|
this.logger.log(`STT/TTS sockets ready for session ${this.sessionId}`);
|
||||||
|
|
||||||
|
this.setupSttHandlers();
|
||||||
|
this.setupTtsHandlers();
|
||||||
|
|
||||||
|
this.ttsWs.configureConnection({
|
||||||
|
target_language_code: 'en-IN',
|
||||||
|
speaker: 'anushka' as any,
|
||||||
|
pace: 1,
|
||||||
|
min_buffer_size: 160,
|
||||||
|
output_audio_bitrate: '32k' as any,
|
||||||
|
speech_sample_rate: 8000,
|
||||||
|
max_chunk_length: 50,
|
||||||
|
output_audio_codec: 'wav' as any,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to initialize Sarvam connections: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSttHandlers() {
|
||||||
|
this.sttWs.on('message', async (msg: any) => {
|
||||||
|
if (!this.active) return;
|
||||||
|
|
||||||
|
const data = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||||
|
|
||||||
|
if (data.type === 'events') {
|
||||||
|
const signal = data?.data?.signal_type;
|
||||||
|
if (signal === 'START_SPEECH') {
|
||||||
|
this.userSpeaking = true;
|
||||||
|
this.logger.debug(`User started speaking [${this.sessionId}]`);
|
||||||
|
} else if (signal === 'END_SPEECH') {
|
||||||
|
this.userSpeaking = false;
|
||||||
|
this.logger.debug(`User ended speaking [${this.sessionId}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.data?.transcript) return;
|
||||||
|
const transcript = data.data.transcript;
|
||||||
|
|
||||||
|
this.logger.log(`[${this.sessionId}] Sarvam STT returned transcript: "${transcript}"`);
|
||||||
|
|
||||||
|
this.socket.emit('ai-transcript', { text: transcript });
|
||||||
|
await this.processUserInput(transcript);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sttWs.on('error', (err) => this.logger.error(`STT error: ${err}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTtsHandlers() {
|
||||||
|
this.ttsWs.on('message', (msg: any) => {
|
||||||
|
if (!this.active || msg.type !== 'audio') return;
|
||||||
|
|
||||||
|
if (this.userSpeaking) {
|
||||||
|
return; // Drop TTS audio if user is speaking
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (msg.data?.audio) {
|
||||||
|
this.logger.log(`[${this.sessionId}] Sarvam TTS returned audio chunk of length: ${msg.data.audio.length}`);
|
||||||
|
this.socket.emit('ai-audio', Buffer.from(msg.data.audio, 'base64'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`TTS audio parsing error: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ttsWs.on('error', (err) => this.logger.error(`TTS error: ${err}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async processUserInput(text: string) {
|
||||||
|
this.logger.log(`[${this.sessionId}] User said: "${text.slice(0, 100)}..."`);
|
||||||
|
this.socket.emit('ai-state', { state: 'thinking' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.session.transcriptLogs.push({
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiResponse = await this.brainService.generateResponse(
|
||||||
|
this.sessionId,
|
||||||
|
this.session.candidateId.toString(),
|
||||||
|
text,
|
||||||
|
this.session.transcriptLogs,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.session.transcriptLogs.push({
|
||||||
|
role: 'ai',
|
||||||
|
text: aiResponse,
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
await this.session.save();
|
||||||
|
|
||||||
|
this.socket.emit('ai-transcript', { text: aiResponse });
|
||||||
|
this.socket.emit('ai-state', { state: 'speaking' });
|
||||||
|
|
||||||
|
if (!this.userSpeaking) {
|
||||||
|
this.logger.log(`[${this.sessionId}] Calling Sarvam TTS for response...`);
|
||||||
|
this.ttsWs.convert(aiResponse);
|
||||||
|
this.ttsWs.flush();
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[${this.sessionId}] Dropped TTS call because user is speaking.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.socket.emit('ai-state', { state: 'listening' });
|
||||||
|
}, 500);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Pipeline error: ${err}`);
|
||||||
|
this.socket.emit('error', { message: 'Pipeline processing failed' });
|
||||||
|
this.socket.emit('ai-state', { state: 'listening' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAudioChunk(audioBase64: string) {
|
||||||
|
if (!this.active || !this.sttWs || this.sttWs.readyState !== 1) return;
|
||||||
|
try {
|
||||||
|
// Sarvam STT expects audio/wav payload strings
|
||||||
|
// Our frontend streams raw PCM. Wrap it in a WAV header per chunk!
|
||||||
|
const rawPcm = Buffer.from(audioBase64, 'base64');
|
||||||
|
const wavBuffer = pcm16ToWav(rawPcm, 16000);
|
||||||
|
|
||||||
|
this.sttWs.transcribe({
|
||||||
|
audio: wavBuffer.toString('base64'),
|
||||||
|
encoding: 'audio/wav',
|
||||||
|
sample_rate: 16000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Transcribe error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (!this.active) return;
|
||||||
|
this.active = false;
|
||||||
|
try {
|
||||||
|
this.sttWs?.close?.();
|
||||||
|
this.ttsWs?.close?.();
|
||||||
|
this.logger.log(`Cleaned up Sarvam session ${this.sessionId}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cleanup error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/src/interview/schemas/conversation-state.schema.ts
Normal file
28
server/src/interview/schemas/conversation-state.schema.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { Document, Types } from 'mongoose';
|
||||||
|
|
||||||
|
export type ConversationStateDocument = ConversationState & Document;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class ConversationState {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'InterviewSession', required: true, unique: true })
|
||||||
|
sessionId: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
enum: ['INTRODUCTION', 'EXPERIENCE_DEEP_DIVE', 'TECHNICAL_CORE', 'ODD_SKILL_PROBE', 'BEHAVIORAL', 'CONCLUSION'],
|
||||||
|
default: 'INTRODUCTION',
|
||||||
|
})
|
||||||
|
currentStrategy: string;
|
||||||
|
|
||||||
|
@Prop({ type: [String], default: [] })
|
||||||
|
oddSkillsDetected: string[];
|
||||||
|
|
||||||
|
@Prop({ default: 0 })
|
||||||
|
technicalQuestionsAsked: number;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
lastQuestionAsked: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversationStateSchema = SchemaFactory.createForClass(ConversationState);
|
||||||
72
server/src/interview/schemas/interview-session.schema.ts
Normal file
72
server/src/interview/schemas/interview-session.schema.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||||
|
import { Document, Types } from 'mongoose';
|
||||||
|
|
||||||
|
/** A single turn in the conversation transcript */
|
||||||
|
export class TranscriptEntry {
|
||||||
|
@Prop({ required: true, enum: ['user', 'ai'] })
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
@Prop({ default: () => new Date() })
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured evaluation rating for a single dimension */
|
||||||
|
export class EvaluationDimension {
|
||||||
|
@Prop({ min: 0, max: 10, default: 0 })
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@Prop({ default: '' })
|
||||||
|
review_message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final evaluation output */
|
||||||
|
export class Evaluation {
|
||||||
|
@Prop({ type: EvaluationDimension, default: () => ({}) })
|
||||||
|
communication: EvaluationDimension;
|
||||||
|
|
||||||
|
@Prop({ type: EvaluationDimension, default: () => ({}) })
|
||||||
|
technical: EvaluationDimension;
|
||||||
|
|
||||||
|
@Prop({ type: EvaluationDimension, default: () => ({}) })
|
||||||
|
behaviour: EvaluationDimension;
|
||||||
|
|
||||||
|
@Prop({ type: EvaluationDimension, default: () => ({}) })
|
||||||
|
experience: EvaluationDimension;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InterviewSessionDocument = InterviewSession & Document;
|
||||||
|
|
||||||
|
@Schema({ timestamps: true })
|
||||||
|
export class InterviewSession {
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'Candidate', required: true })
|
||||||
|
candidateId: Types.ObjectId;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
enum: ['waiting', 'active', 'completed', 'cancelled'],
|
||||||
|
default: 'waiting',
|
||||||
|
})
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
enum: ['INTRODUCTION', 'EXPERIENCE_DEEP_DIVE', 'TECHNICAL_CORE', 'ODD_SKILL_PROBE', 'BEHAVIORAL', 'CONCLUSION'],
|
||||||
|
default: 'INTRODUCTION',
|
||||||
|
})
|
||||||
|
currentStage: string;
|
||||||
|
|
||||||
|
@Prop({ type: [TranscriptEntry], default: [] })
|
||||||
|
transcriptLogs: TranscriptEntry[];
|
||||||
|
|
||||||
|
@Prop({ default: false })
|
||||||
|
faceVerified: boolean;
|
||||||
|
|
||||||
|
@Prop({ type: Evaluation, default: () => ({}) })
|
||||||
|
evaluation: Evaluation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InterviewSessionSchema =
|
||||||
|
SchemaFactory.createForClass(InterviewSession);
|
||||||
106
server/src/interview/services/orchestrator.service.ts
Normal file
106
server/src/interview/services/orchestrator.service.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { Socket } from 'socket.io';
|
||||||
|
import {
|
||||||
|
InterviewSession,
|
||||||
|
InterviewSessionDocument,
|
||||||
|
} from '../schemas/interview-session.schema';
|
||||||
|
import { BrainService } from '../../brain/brain.service';
|
||||||
|
import { SarvamClientHandler } from '../sarvam.handler';
|
||||||
|
import { DeepgramClientHandler } from '../deepgram.handler';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrchestratorService {
|
||||||
|
private readonly logger = new Logger(OrchestratorService.name);
|
||||||
|
|
||||||
|
private sessions: Map<string, SarvamClientHandler | DeepgramClientHandler> = new Map();
|
||||||
|
private readonly providerName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(InterviewSession.name)
|
||||||
|
private readonly sessionModel: Model<InterviewSessionDocument>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly brainService: BrainService,
|
||||||
|
) {
|
||||||
|
this.providerName = this.configService.get<string>('VOICE_PROVIDER', 'deepgram');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(candidateId: string): Promise<InterviewSessionDocument> {
|
||||||
|
const session = new this.sessionModel({
|
||||||
|
candidateId,
|
||||||
|
status: 'active',
|
||||||
|
currentStage: 'INTRODUCTION',
|
||||||
|
});
|
||||||
|
return session.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionId: string): Promise<InterviewSessionDocument> {
|
||||||
|
const session = await this.sessionModel.findById(sessionId).exec();
|
||||||
|
if (!session) throw new Error(`Session ${sessionId} not found`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initStreaming(
|
||||||
|
sessionId: string,
|
||||||
|
socket: Socket,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await this.getSession(sessionId);
|
||||||
|
|
||||||
|
let handler: SarvamClientHandler | DeepgramClientHandler;
|
||||||
|
|
||||||
|
if (this.providerName === 'sarvam') {
|
||||||
|
handler = new SarvamClientHandler(socket, sessionId, session, this.brainService);
|
||||||
|
} else {
|
||||||
|
handler = new DeepgramClientHandler(socket, sessionId, session, this.brainService);
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler.init();
|
||||||
|
this.sessions.set(sessionId, handler);
|
||||||
|
this.logger.log(`[${sessionId}] Streaming initialized with ${this.providerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamAudioChunk(sessionId: string, audioBase64: string): void {
|
||||||
|
const handler = this.sessions.get(sessionId);
|
||||||
|
if (!handler) {
|
||||||
|
this.logger.warn(`[${sessionId}] No active streaming session`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handler.handleAudioChunk(audioBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerPipeline(sessionId: string): Promise<void> {
|
||||||
|
const handler = this.sessions.get(sessionId);
|
||||||
|
if (!handler) return;
|
||||||
|
|
||||||
|
// Since the pipeline handles variables itself via websocket, manual trigger might not be needed
|
||||||
|
// but if the frontend sends "end-of-speech", we let Deepgram or Sarvam's silence handle it
|
||||||
|
this.logger.log(`Manual trigger pipeline called on ${sessionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceStage(sessionId: string): Promise<string> {
|
||||||
|
const session = await this.getSession(sessionId);
|
||||||
|
const stages = ['INTRODUCTION', 'EXPERIENCE_DEEP_DIVE', 'TECHNICAL_CORE', 'ODD_SKILL_PROBE', 'BEHAVIORAL', 'CONCLUSION'];
|
||||||
|
const currentIdx = stages.indexOf(session.currentStage);
|
||||||
|
const nextStage = stages[Math.min(currentIdx + 1, stages.length - 1)];
|
||||||
|
|
||||||
|
session.currentStage = nextStage;
|
||||||
|
await session.save();
|
||||||
|
this.logger.log(`[${sessionId}] Advanced to stage: ${nextStage}`);
|
||||||
|
return nextStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(sessionId: string): Promise<void> {
|
||||||
|
const handler = this.sessions.get(sessionId);
|
||||||
|
if (handler) {
|
||||||
|
handler.cleanup();
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.getSession(sessionId);
|
||||||
|
session.status = 'completed';
|
||||||
|
await session.save();
|
||||||
|
this.logger.log(`[${sessionId}] Session completed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/src/main.ts
Normal file
28
server/src/main.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global validation pipe for DTO validation
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS for frontend
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`🚀 AI Interviewer server running on http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
2
server/src/types.d.ts
vendored
Normal file
2
server/src/types.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
declare module 'pdf-parse';
|
||||||
|
declare module 'canvas';
|
||||||
29
server/src/utils/audio.utils.ts
Normal file
29
server/src/utils/audio.utils.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export function pcm16ToWav(pcm16Buffer: Buffer, sampleRate = 16000) {
|
||||||
|
const numChannels = 1;
|
||||||
|
const bitsPerSample = 16;
|
||||||
|
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
|
||||||
|
const blockAlign = (numChannels * bitsPerSample) / 8;
|
||||||
|
const wavBuffer = Buffer.alloc(44 + pcm16Buffer.length);
|
||||||
|
|
||||||
|
// RIFF header
|
||||||
|
wavBuffer.write('RIFF', 0);
|
||||||
|
wavBuffer.writeUInt32LE(36 + pcm16Buffer.length, 4);
|
||||||
|
wavBuffer.write('WAVE', 8);
|
||||||
|
|
||||||
|
// fmt chunk
|
||||||
|
wavBuffer.write('fmt ', 12);
|
||||||
|
wavBuffer.writeUInt32LE(16, 16); // Subchunk1Size (PCM)
|
||||||
|
wavBuffer.writeUInt16LE(1, 20); // PCM format
|
||||||
|
wavBuffer.writeUInt16LE(numChannels, 22);
|
||||||
|
wavBuffer.writeUInt32LE(sampleRate, 24);
|
||||||
|
wavBuffer.writeUInt32LE(byteRate, 28);
|
||||||
|
wavBuffer.writeUInt16LE(blockAlign, 32);
|
||||||
|
wavBuffer.writeUInt16LE(bitsPerSample, 34);
|
||||||
|
|
||||||
|
// data chunk
|
||||||
|
wavBuffer.write('data', 36);
|
||||||
|
wavBuffer.writeUInt32LE(pcm16Buffer.length, 40);
|
||||||
|
pcm16Buffer.copy(wavBuffer, 44);
|
||||||
|
|
||||||
|
return wavBuffer;
|
||||||
|
}
|
||||||
4
server/tsconfig.build.json
Normal file
4
server/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
24
server/tsconfig.json
Normal file
24
server/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user