aki-008 commited on
Commit
8b61324
·
1 Parent(s): 795dc3a

feat: vapi working

Browse files
Backend/app/api/v1/api.py CHANGED
@@ -1,5 +1,5 @@
1
  from fastapi import APIRouter
2
- from app.api.v1.endpoints import auth, quiz, notes, vapi_ai # Add vapi import
3
 
4
  api_router = APIRouter()
5
 
@@ -24,9 +24,8 @@ api_router.include_router(
24
  tags=["notes"]
25
  )
26
 
27
- # Add Vapi routes
28
  api_router.include_router(
29
- vapi_ai.router,
30
- prefix="/vapi",
31
- tags=["Voice Interview"]
32
  )
 
1
  from fastapi import APIRouter
2
+ from app.api.v1.endpoints import auth, quiz, notes, interview
3
 
4
  api_router = APIRouter()
5
 
 
24
  tags=["notes"]
25
  )
26
 
 
27
  api_router.include_router(
28
+ interview.router,
29
+ prefix="/interview",
30
+ tags=["Interview"]
31
  )
Backend/app/api/v1/endpoints/interview.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uvicorn
3
+ from fastapi import APIRouter, HTTPException, Request
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from pydantic import BaseModel
6
+ from app.config import settings
7
+ from vapi import Vapi
8
+ from dotenv import load_dotenv
9
+
10
+ # Load environment variables from .env file
11
+ load_dotenv()
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ # --- CONFIGURATION ---
17
+ VAPI_PRIVATE_KEY = os.getenv("VAPI_PRIVATE_KEY")
18
+ VAPI_ASSISTANT_ID = os.getenv("VAPI_ASSISTANT_ID")
19
+ # The SERVER_URL MUST be set to your public ngrok HTTPS URL for external webhooks to work.
20
+ SERVER_URL = os.getenv("SERVER_URL", "http://localhost:8000")
21
+
22
+ # Initialize Vapi Server SDK
23
+ try:
24
+ vapi_server = Vapi(token=VAPI_PRIVATE_KEY)
25
+ except Exception as e:
26
+ print(f"Vapi SDK Initialization Error: {e}")
27
+ print("Ensure VAPI_PRIVATE_KEY is set in .env")
28
+
29
+ # --- CORS SETUP ---
30
+ # app.add_middleware(
31
+ # CORSMiddleware,
32
+ # # Allow communication from the frontend running on localhost:5173
33
+ # # and also allow the ngrok base URL for safety.
34
+ # allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", SERVER_URL],
35
+ # allow_credentials=True,
36
+ # allow_methods=["*"],
37
+ # allow_headers=["*"],
38
+ # )
39
+
40
+ # --- SCHEMAS ---
41
+ class ConfigRequest(BaseModel):
42
+ name: str
43
+ job_role: str
44
+ experience: str
45
+
46
+ # --- ENDPOINTS ---
47
+
48
+ @router.post("/api/get-vapi-config")
49
+ async def get_vapi_config(data: ConfigRequest):
50
+ """
51
+ Endpoint called by the Frontend to get the dynamically generated Assistant configuration.
52
+ """
53
+ if VAPI_ASSISTANT_ID == "asst_11111111111111111111":
54
+ raise HTTPException(
55
+ status_code=503,
56
+ detail="VAPI_ASSISTANT_ID not configured in .env. Please set your ID."
57
+ )
58
+
59
+ try:
60
+ print(f"\n--- New Interview Request ---")
61
+ print(f"👤 User: {data.name}, Role: {data.job_role}, Exp: {data.experience}")
62
+
63
+ # 1. Construct the Dynamic System Prompt
64
+ system_prompt = (
65
+ f"You are a strict technical interviewer. You are interviewing {data.name} for a {data.job_role} role. "
66
+ f"They have {data.experience} years of experience. "
67
+ f"Ask short, concise questions. Wait for their answer. Do not lecture. "
68
+ f"Start by asking them to introduce themselves and briefly describe their experience."
69
+ )
70
+
71
+ # 2. Dynamic Webhook URL (for this call only)
72
+ # Vapi will send webhooks to the public URL defined in SERVER_URL,
73
+ # specifically hitting this backend's /api/webhook route.
74
+ webhook_url = f"{SERVER_URL}/api/webhook"
75
+
76
+ # 3. Construct the Overrides Payload
77
+ assistant_overrides = {
78
+ "model": {
79
+ "provider": "openai",
80
+ "model": "gpt-4o-mini", # or "gpt-4", "gpt-3.5-turbo"
81
+ "messages": [
82
+ {"role": "system", "content": system_prompt}
83
+ ]
84
+ },
85
+ "server": {
86
+ "url": webhook_url
87
+ },
88
+ # ... keep metadata as is ...
89
+ "metadata": {
90
+ "user_name": data.name,
91
+ "job_role": data.job_role,
92
+ "environment": "standalone-test"
93
+ }
94
+ }
95
+
96
+ # 4. Return the necessary config to the frontend Web SDK
97
+ return {
98
+ "assistantId": VAPI_ASSISTANT_ID,
99
+ "overrides": assistant_overrides
100
+ }
101
+
102
+ except Exception as e:
103
+ print(f"❌ Vapi Configuration Error: {e}")
104
+ raise HTTPException(status_code=500, detail=f"Failed to configure agent: {str(e)}")
105
+
106
+
107
+ @router.post("/api/webhook")
108
+ async def vapi_webhook_receiver(request: Request):
109
+ """
110
+ Endpoint that receives asynchronous events from Vapi's servers.
111
+ """
112
+ payload = await request.json()
113
+ message = payload.get("message", {})
114
+
115
+ # Log incoming transcripts in real-time
116
+ if message.get("type") == "transcript" and message.get("transcriptType") == "final":
117
+ print(f"🗣️ [Transcript] {message.get('role').upper()}: {message.get('transcript')}")
118
+
119
+ # Log the final report (contains summary, full conversation, etc.)
120
+ elif message.get("type") == "end-of-call-report":
121
+ metadata = payload.get("assistant", {}).get("metadata", {})
122
+ print(f"\n--- 🏁 Call Ended Report ---")
123
+ print(f" User: {metadata.get('user_name')}, Role: {metadata.get('job_role')}")
124
+ print(f" Summary: {message.get('summary', 'N/A')}")
125
+ print(f"---------------------------\n")
126
+
127
+ # Vapi expects a 200 OK response
128
+ return {"status": "ok"}
129
+
130
+ @router.get("/")
131
+ async def root():
132
+ return {"message": "Vapi Standalone Backend is running on port 8000."}
133
+
134
+ if __name__ == "__main__":
135
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
Backend/app/config.py CHANGED
@@ -23,6 +23,10 @@ class Settings(BaseSettings):
23
  VAPI_PUBLIC_KEY: str
24
  VAPI_ASSISTANT_ID: str
25
 
 
 
 
 
26
  class Config:
27
  env_file = ".env"
28
  extra = "ignore"
 
23
  VAPI_PUBLIC_KEY: str
24
  VAPI_ASSISTANT_ID: str
25
 
26
+ VAPI_ASSISTANT_ID: str = "your-vapi-assistant-id"
27
+ VAPI_PRIVATE_KEY: str
28
+ VAPI_PUBLIC_KEY: str
29
+
30
  class Config:
31
  env_file = ".env"
32
  extra = "ignore"
Frontend/src/api/interviewService.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import API from "./api";
2
+
3
+ export const getVapiConfig = async (
4
+ name: string,
5
+ jobRole: string,
6
+ experience: number,
7
+ level: string
8
+ ) => {
9
+ const response = await API.post("/interview/config", {
10
+ name,
11
+ job_role: jobRole,
12
+ experience,
13
+ level,
14
+ });
15
+ return response.data;
16
+ };
Frontend/src/pages/AiInterview.tsx CHANGED
@@ -1,47 +1,275 @@
1
- // AiInterview.tsx
2
- import { useEffect, useState } from "react";
3
- import { useVapi } from "../hooks/useVapi";
4
- import { getVapiConfig } from "../api/vapiService";
5
 
6
- export default function AiInterview() {
7
- const [interviewConfig, setInterviewConfig] = useState(null);
8
- const [vapiKey, setVapiKey] = useState<string | null>(null);
9
- const { vapiClient, start, stop } = useVapi(vapiKey);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- const handleStartInterview = async () => {
12
  try {
13
- // 1. Ask backend for config
14
- const cfg = await getVapiConfig(interviewConfig);
15
-
16
- // 2. Save publicKey → this triggers useVapi to create the client
17
- setVapiKey(cfg.publicKey);
18
-
19
- // 3. Wait until client is created
20
- const waitForClient = () =>
21
- new Promise<void>((resolve) => {
22
- const interval = setInterval(() => {
23
- if (vapiClient) {
24
- clearInterval(interval);
25
- resolve();
26
- }
27
- }, 50);
28
- });
29
-
30
- await waitForClient();
31
-
32
- // 4. Start the actual voice session
33
- await start(cfg.assistantId, cfg.overrides);
34
-
35
- console.log("Interview started!");
36
- } catch (err) {
37
- console.error("Failed to start interview:", err);
38
  }
39
  };
40
 
 
 
 
 
41
  return (
42
- <div>
43
- <button onClick={handleStartInterview}>Start Interview</button>
44
- <button onClick={stop}>Stop</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </div>
46
  );
47
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+ import Vapi from "@vapi-ai/web";
3
+ import { Mic, PhoneOff, Volume2, Loader2, Activity } from "lucide-react";
 
4
 
5
+ // --- CONFIG ---
6
+ // 1. Put your Vapi Public Key here
7
+ const VAPI_PUBLIC_KEY = "6e393730-74a2-4690-8cb7-845ed3880488";
8
+ // 2. Point this to your FastAPI backend
9
+ const BACKEND_URL = "http://localhost:8000";
10
+
11
+ const vapi = new Vapi(VAPI_PUBLIC_KEY);
12
+
13
+ function App() {
14
+ const [isSessionActive, setIsSessionActive] = useState(false);
15
+ const [isSpeaking, setIsSpeaking] = useState(false);
16
+ const [status, setStatus] = useState("Idle");
17
+
18
+ // Form State
19
+ const [name, setName] = useState("Jane Doe");
20
+ const [role, setRole] = useState("Senior Frontend Engineer");
21
+ const [exp, setExp] = useState("7");
22
+
23
+ useEffect(() => {
24
+ // Vapi Event Listeners
25
+ vapi.on("call-start", () => {
26
+ setStatus("Connected (AI is listening)");
27
+ setIsSessionActive(true);
28
+ });
29
+
30
+ vapi.on("call-end", () => {
31
+ setStatus("Call Ended");
32
+ setIsSessionActive(false);
33
+ setIsSpeaking(false);
34
+ });
35
+
36
+ vapi.on("speech-start", () => {
37
+ setStatus("AI is speaking...");
38
+ setIsSpeaking(true);
39
+ });
40
+
41
+ vapi.on("speech-end", () => {
42
+ if (isSessionActive) {
43
+ setStatus("Connected (Listening)");
44
+ setIsSpeaking(false);
45
+ }
46
+ });
47
+
48
+ vapi.on("error", (e) => {
49
+ console.error("Vapi Error:", e);
50
+ setStatus(`Error: ${e.message}`);
51
+ setIsSessionActive(false);
52
+ });
53
+
54
+ return () => {
55
+ // Cleanup
56
+ vapi.stop();
57
+ vapi.removeAllListeners();
58
+ };
59
+ }, []);
60
+
61
+ const startInterview = async () => {
62
+ if (VAPI_PUBLIC_KEY === "YOUR_PUBLIC_KEY_HERE") {
63
+ alert("Please update VAPI_PUBLIC_KEY in src/App.tsx");
64
+ return;
65
+ }
66
+
67
+ setStatus("Configuring...");
68
 
 
69
  try {
70
+ // 1. Call your backend to get the dynamic config
71
+ const response = await fetch(
72
+ `${BACKEND_URL}/api/v1/interview/api/get-vapi-config`,
73
+ {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ name, job_role: role, experience: exp }),
77
+ }
78
+ );
79
+
80
+ const data = await response.json();
81
+
82
+ if (!data.assistantId || !data.overrides) {
83
+ throw new Error("Invalid config from backend");
84
+ }
85
+
86
+ setStatus("Connecting...");
87
+
88
+ // 2. Start Vapi with the config from backend
89
+ vapi.start(data.assistantId, data.overrides);
90
+ } catch (err: any) {
91
+ console.error("Start Call API Error:", err);
92
+ setStatus(
93
+ `Failed: ${err.message || "Check FastAPI terminal for details."}`
94
+ );
95
  }
96
  };
97
 
98
+ const stopInterview = () => {
99
+ vapi.stop();
100
+ };
101
+
102
  return (
103
+ <div
104
+ style={{
105
+ backgroundColor: "#1f2937" /* Slate 800 */,
106
+ padding: "2.5rem",
107
+ borderRadius: "1rem",
108
+ boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.5)",
109
+ maxWidth: "450px",
110
+ width: "100%",
111
+ textAlign: "center",
112
+ color: "#f9fafb" /* Gray 50 */,
113
+ }}
114
+ >
115
+ <h1
116
+ style={{
117
+ fontSize: "2rem",
118
+ fontWeight: "bold",
119
+ marginBottom: "1.5rem",
120
+ color: "#60a5fa",
121
+ }}
122
+ >
123
+ Vapi Interview Tester
124
+ </h1>
125
+
126
+ {/* --- FORM --- */}
127
+ {!isSessionActive && (
128
+ <div
129
+ style={{
130
+ display: "flex",
131
+ flexDirection: "column",
132
+ gap: "1rem",
133
+ marginBottom: "1.5rem",
134
+ }}
135
+ >
136
+ <input
137
+ type="text"
138
+ placeholder="Your Name"
139
+ value={name}
140
+ onChange={(e) => setName(e.target.value)}
141
+ style={inputStyle}
142
+ />
143
+ <input
144
+ type="text"
145
+ placeholder="Target Role"
146
+ value={role}
147
+ onChange={(e) => setRole(e.target.value)}
148
+ style={inputStyle}
149
+ />
150
+ <input
151
+ type="number"
152
+ placeholder="Experience (Years)"
153
+ value={exp}
154
+ onChange={(e) => setExp(e.target.value)}
155
+ style={inputStyle}
156
+ />
157
+ </div>
158
+ )}
159
+
160
+ {/* --- VISUALIZER --- */}
161
+ <div
162
+ style={{ margin: "2rem 0", display: "flex", justifyContent: "center" }}
163
+ >
164
+ <div
165
+ style={{
166
+ width: "120px",
167
+ height: "120px",
168
+ borderRadius: "50%",
169
+ backgroundColor: isSpeaking
170
+ ? "#a855f7"
171
+ : isSessionActive
172
+ ? "#2563eb"
173
+ : "#4b5563",
174
+ display: "flex",
175
+ alignItems: "center",
176
+ justifyContent: "center",
177
+ color: "white",
178
+ transition: "all 0.3s ease",
179
+ transform: isSpeaking ? "scale(1.1)" : "scale(1)",
180
+ boxShadow: isSpeaking
181
+ ? "0 0 30px rgba(168, 85, 247, 0.8)"
182
+ : "0 0 20px rgba(37, 99, 235, 0.4)",
183
+ }}
184
+ >
185
+ {!isSessionActive ? (
186
+ <Mic size={48} />
187
+ ) : isSpeaking ? (
188
+ <Volume2 size={48} />
189
+ ) : (
190
+ <Activity size={48} />
191
+ )}
192
+ </div>
193
+ </div>
194
+
195
+ {/* --- CONTROLS --- */}
196
+ <div style={{ display: "flex", justifyContent: "center", gap: "1rem" }}>
197
+ {!isSessionActive ? (
198
+ <button
199
+ onClick={startInterview}
200
+ style={btnPrimary}
201
+ disabled={status === "Connecting..." || status === "Configuring..."}
202
+ >
203
+ {status === "Connecting..." || status === "Configuring..." ? (
204
+ <Loader2 className="animate-spin" />
205
+ ) : (
206
+ <Mic />
207
+ )}
208
+ {status === "Connecting..." || status === "Configuring..."
209
+ ? "Starting..."
210
+ : "Start Interview"}
211
+ </button>
212
+ ) : (
213
+ <button onClick={stopInterview} style={btnDestructive}>
214
+ <PhoneOff /> End Call
215
+ </button>
216
+ )}
217
+ </div>
218
+
219
+ <p
220
+ style={{ marginTop: "1.5rem", color: "#9ca3af", fontSize: "0.875rem" }}
221
+ >
222
+ Status:{" "}
223
+ <strong
224
+ style={{ color: status.includes("Error") ? "#f87171" : "#e5e7eb" }}
225
+ >
226
+ {status}
227
+ </strong>
228
+ </p>
229
  </div>
230
  );
231
  }
232
+
233
+ // Simple inline styles for standalone testing
234
+ const inputStyle = {
235
+ padding: "0.75rem",
236
+ borderRadius: "0.5rem",
237
+ border: "1px solid #475569" /* Slate 600 */,
238
+ fontSize: "1rem",
239
+ backgroundColor: "#0f172a" /* Slate 900 */,
240
+ color: "#f9fafb" /* Gray 50 */,
241
+ };
242
+
243
+ const btnPrimary = {
244
+ display: "flex",
245
+ alignItems: "center",
246
+ gap: "0.5rem",
247
+ backgroundColor: "#2563eb" /* Blue 600 */,
248
+ color: "white",
249
+ padding: "0.75rem 1.5rem",
250
+ borderRadius: "0.5rem",
251
+ border: "none",
252
+ cursor: "pointer",
253
+ fontSize: "1rem",
254
+ fontWeight: "bold",
255
+ transition: "background-color 0.2s",
256
+ outline: "none",
257
+ };
258
+
259
+ const btnDestructive = {
260
+ display: "flex",
261
+ alignItems: "center",
262
+ gap: "0.5rem",
263
+ backgroundColor: "#dc2626" /* Red 600 */,
264
+ color: "white",
265
+ padding: "0.75rem 1.5rem",
266
+ borderRadius: "0.5rem",
267
+ border: "none",
268
+ cursor: "pointer",
269
+ fontSize: "1rem",
270
+ fontWeight: "bold",
271
+ transition: "background-color 0.2s",
272
+ outline: "none",
273
+ };
274
+
275
+ export default App;