Param2121 commited on
Commit
3abfc90
·
0 Parent(s):

Refactor: Move project to root of repository

Browse files
Files changed (48) hide show
  1. .env.example +3 -0
  2. .gitignore +44 -0
  3. backend/agents/gemini_client.py +26 -0
  4. backend/agents/workflow.py +68 -0
  5. backend/api_routes.py +47 -0
  6. backend/app/agents/interview_graph.py +217 -0
  7. backend/app/api_routes.py +215 -0
  8. backend/app/core/config.py +43 -0
  9. backend/app/core/logging_config.py +43 -0
  10. backend/app/core/prompts.py +121 -0
  11. backend/app/db/database.py +29 -0
  12. backend/app/main.py +49 -0
  13. backend/app/models/models.py +77 -0
  14. backend/app/schemas.py +38 -0
  15. backend/app/services/gemini_service.py +161 -0
  16. backend/app/services/resume_service.py +31 -0
  17. backend/app/services/voice_service.py +123 -0
  18. backend/core/config.py +14 -0
  19. backend/core/database.py +17 -0
  20. backend/error_log.txt +1 -0
  21. backend/models/base.py +17 -0
  22. backend/models_list.txt +28 -0
  23. backend/output.txt +4 -0
  24. backend/requirements.txt +163 -0
  25. backend/services/resume_parser.py +17 -0
  26. backend/services/vector_store.py +32 -0
  27. backend/start.sh +6 -0
  28. backend/talenttalk.db +0 -0
  29. backend/test.pdf +3 -0
  30. backend/tests/list_openrouter_models.py +34 -0
  31. backend/tests/test_chat_audio.py +49 -0
  32. backend/tests/test_chat_error.py +38 -0
  33. backend/tests/test_db_connection.py +17 -0
  34. backend/tests/test_followup_logic.py +75 -0
  35. backend/tests/test_gemini_direct.py +30 -0
  36. backend/tests/test_genai_raw.py +41 -0
  37. backend/tests/test_report_generation.py +56 -0
  38. backend/tests/test_resume_error.py +39 -0
  39. backend/tests/test_service_only.py +33 -0
  40. backend/tests/test_standard_start.py +22 -0
  41. backend/tests/test_video_analysis.py +43 -0
  42. backend/tests/test_workflow.py +105 -0
  43. frontend/app.py +285 -0
  44. frontend/requirements.txt +163 -0
  45. render.yaml +13 -0
  46. requirements.txt +15 -0
  47. run_backend.bat +3 -0
  48. run_frontend.bat +3 -0
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ GOOGLE_API_KEY=your_google_api_key_here
2
+ DATABASE_URL=sqlite:///./data/talenttalk.db
3
+ # Add other keys as needed (e.g., OPENAI_API_KEY if fallback is used)
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ venv/
25
+ env/
26
+ ENV/
27
+
28
+ # Environment Variables
29
+ .env
30
+ .env.local
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
35
+
36
+ # Media Files (Generated)
37
+ *.mp3
38
+ *.wav
39
+ *.mp4
40
+ temp_*
41
+ static/audio/*
42
+
43
+ # Logs
44
+ *.log
backend/agents/gemini_client.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ from ..core.config import get_settings
3
+
4
+ settings = get_settings()
5
+
6
+ def configure_genai():
7
+ genai.configure(api_key=settings.GOOGLE_API_KEY)
8
+
9
+ def get_gemini_model(model_name: str = "gemini-1.5-flash", system_instruction: str = None):
10
+ configure_genai()
11
+ generation_config = {
12
+ "temperature": 0.7,
13
+ "top_p": 0.95,
14
+ "top_k": 40,
15
+ "max_output_tokens": 8192,
16
+ }
17
+ return genai.GenerativeModel(
18
+ model_name=model_name,
19
+ generation_config=generation_config,
20
+ system_instruction=system_instruction
21
+ )
22
+
23
+ async def generate_response(prompt: str, system_instruction: str = None):
24
+ model = get_gemini_model(system_instruction=system_instruction)
25
+ response = await model.generate_content_async(prompt)
26
+ return response.text
backend/agents/workflow.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Annotated
2
+ from langgraph.graph import StateGraph, END
3
+ from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
4
+ import operator
5
+ from .gemini_client import generate_response
6
+
7
+ class InterviewState(TypedDict):
8
+ messages: Annotated[List[BaseMessage], operator.add]
9
+ candidate_id: int
10
+ current_stage: str # "introduction", "technical", "behavioral", "conclusion"
11
+ question_count: int
12
+
13
+ async def interviewer_node(state: InterviewState):
14
+ messages = state["messages"]
15
+ stage = state.get("current_stage", "introduction")
16
+
17
+ # Simple prompt logic for MVP - can be enhanced with complex prompt templates
18
+ system_prompt = f"""You are an expert technical interviewer conducting an interview.
19
+ Current Stage: {stage}
20
+
21
+ Goal: Ask relevant questions based on the resume and previous answers.
22
+ Be professional but encouraging.
23
+ If the stage is 'introduction', ask about their background.
24
+ If 'technical', ask coding or system design questions.
25
+ If 'conclusion', thank them and wrap up.
26
+ """
27
+
28
+ # Construct prompt from history
29
+ # For Gemini, we might need to format history carefully, but simple concatenation works for now
30
+ conversation = "\n".join([f"{m.type}: {m.content}" for m in messages])
31
+ prompt = f"{conversation}\nInterviewer:"
32
+
33
+ response_text = await generate_response(prompt, system_instruction=system_prompt)
34
+
35
+ return {"messages": [AIMessage(content=response_text)], "question_count": state.get("question_count", 0) + 1}
36
+
37
+ def router_node(state: InterviewState):
38
+ # Logic to switch stages or end interview
39
+ count = state.get("question_count", 0)
40
+ stage = state.get("current_stage", "introduction")
41
+
42
+ if stage == "introduction" and count >= 2:
43
+ return "technical"
44
+ elif stage == "technical" and count >= 5:
45
+ return "conclusion"
46
+ elif stage == "conclusion" and count >= 7:
47
+ return "end"
48
+ return "continue"
49
+
50
+ # Define Graph
51
+ workflow = StateGraph(InterviewState)
52
+ workflow.add_node("interviewer", interviewer_node)
53
+ workflow.set_entry_point("interviewer")
54
+
55
+ def route_step(state: InterviewState):
56
+ decision = router_node(state)
57
+ if decision == "end":
58
+ return END
59
+ elif decision == "continue":
60
+ return END # For client-server model, we stop after generation and wait for user input
61
+ else:
62
+ # Update stage logic would go here, for now simple loop
63
+ # In a real app, we'd have a node to update state parameters
64
+ return END
65
+
66
+ workflow.add_edge("interviewer", END) # Simplified for Request/Response API pattern
67
+
68
+ app_graph = workflow.compile()
backend/api_routes.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+ from .agents.workflow import app_graph, InterviewState
4
+ from langchain_core.messages import HumanMessage, AIMessage
5
+
6
+ router = APIRouter()
7
+
8
+ class ChatRequest(BaseModel):
9
+ message: str
10
+ session_id: str # Ideally used to load state from DB
11
+
12
+ # In-memory store for MVP state (replace with Redis/DB in production)
13
+ session_store = {}
14
+
15
+ @router.post("/chat")
16
+ async def chat_endpoint(request: ChatRequest):
17
+ session_id = request.session_id
18
+ user_input = request.message
19
+
20
+ # Initialize state if new
21
+ if session_id not in session_store:
22
+ session_store[session_id] = {
23
+ "messages": [],
24
+ "candidate_id": 1, # Mock
25
+ "current_stage": "introduction",
26
+ "question_count": 0
27
+ }
28
+
29
+ current_state = session_store[session_id]
30
+
31
+ # Add user message
32
+ current_state["messages"].append(HumanMessage(content=user_input))
33
+
34
+ # Run graph
35
+ # LangGraph invoke returns the final state
36
+ result = await app_graph.ainvoke(current_state)
37
+
38
+ # Update store
39
+ session_store[session_id] = result
40
+
41
+ # Get last message
42
+ last_message = result["messages"][-1]
43
+
44
+ return {
45
+ "response": last_message.content,
46
+ "stage": result.get("current_stage")
47
+ }
backend/app/agents/interview_graph.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Dict, Any, Optional
2
+ from langgraph.graph import StateGraph, END
3
+ from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
4
+
5
+ from app.services.gemini_service import gemini_service
6
+ from app.core.logging_config import logger
7
+
8
+ class InterviewState(TypedDict):
9
+ # Chat history
10
+ messages: List[BaseMessage]
11
+ history: List[str]
12
+
13
+ # State tracking
14
+ current_question: Optional[str]
15
+ current_question_num: int
16
+ total_questions: int
17
+ follow_up_count: int # Current follow-ups for this question
18
+ max_follow_ups: int # Max allowed
19
+
20
+ # Context
21
+ target_company: str
22
+ interview_style: str
23
+ job_role: str
24
+ difficulty: str
25
+ topic: str
26
+ resume_text: Optional[str] # New field
27
+
28
+ # Results
29
+ analysis_data: List[Dict[str, Any]]
30
+ final_report: Optional[str]
31
+
32
+ # --- Nodes ---
33
+
34
+ async def generate_question_node(state: InterviewState):
35
+ """Node: Generates the next question or ends interview."""
36
+ logger.info(f"Generating question {state['current_question_num'] + 1}/{state['total_questions']}")
37
+
38
+ question = await gemini_service.generate_question(
39
+ target_company=state["target_company"],
40
+ interview_style=state["interview_style"],
41
+ job_role=state["job_role"],
42
+ difficulty=state["difficulty"],
43
+ topic=state["topic"],
44
+ question_num=state["current_question_num"] + 1,
45
+ total_questions=state["total_questions"],
46
+ history=state["history"],
47
+ resume_text=state.get("resume_text")
48
+ )
49
+
50
+ # Update state
51
+ state["current_question"] = question
52
+ state["current_question_num"] += 1
53
+
54
+ # Add to message history (as AI)
55
+ state["messages"].append(AIMessage(content=question))
56
+
57
+ # Reset follow-up count for new question
58
+ state["follow_up_count"] = 0
59
+
60
+ return state
61
+
62
+ async def generate_follow_up_node(state: InterviewState):
63
+ """Node: Generates a follow-up question."""
64
+ logger.info("Generating Follow-up Question...")
65
+
66
+ last_user_msg = state["messages"][-1]
67
+ last_answer = last_user_msg.content if isinstance(last_user_msg, HumanMessage) else ""
68
+
69
+ question = await gemini_service.generate_followup_question(
70
+ target_company=state["target_company"],
71
+ question=state["current_question"],
72
+ answer=last_answer
73
+ )
74
+
75
+ # Update state
76
+ state["current_question"] = question
77
+ # Do NOT increment current_question_num, as it's the same topic
78
+ state["follow_up_count"] += 1
79
+
80
+ # Add to message history
81
+ state["messages"].append(AIMessage(content=question))
82
+
83
+ return state
84
+
85
+ async def analyze_answer_node(state: InterviewState):
86
+ """Node: Analyzes the user's latest response."""
87
+ last_message = state["messages"][-1]
88
+
89
+ if not isinstance(last_message, HumanMessage):
90
+ # Should not happen in normal flow
91
+ return state
92
+
93
+ user_answer = last_message.content
94
+
95
+ logger.info("Analyzing user answer...")
96
+ analysis = await gemini_service.analyze_response(
97
+ question=state["current_question"],
98
+ answer=user_answer,
99
+ job_role=state["job_role"],
100
+ difficulty=state["difficulty"]
101
+ )
102
+
103
+ # Append analysis to list
104
+ if "analysis_data" not in state:
105
+ state["analysis_data"] = []
106
+
107
+ # Store complete analysis object
108
+ analysis_record = {
109
+ "question": state["current_question"],
110
+ "answer": user_answer,
111
+ "analysis": analysis,
112
+ "question_num": state["current_question_num"]
113
+ }
114
+ state["analysis_data"].append(analysis_record)
115
+
116
+ # Add context to history for the next question generator
117
+ # We include a brief summary so the AI knows how the user did, but not the full JSON
118
+ feedback_short = f"Question: {state['current_question']}\nAnswer: {user_answer}\nFeedback: {analysis.get('feedback', '')}"
119
+ state["history"].append(feedback_short)
120
+
121
+ # Adaptive Difficulty Logic
122
+ # If strongly positive, increase difficulty. If negative, decrease.
123
+ # Simple implementation for now.
124
+ score = analysis.get("sentiment_score", 0)
125
+ current_diff = state["difficulty"]
126
+
127
+ if score > 0.7 and current_diff == "Easy":
128
+ state["difficulty"] = "Medium"
129
+ elif score > 0.8 and current_diff == "Medium":
130
+ state["difficulty"] = "Hard"
131
+ elif score < 0.3 and current_diff == "Hard":
132
+ state["difficulty"] = "Medium"
133
+ elif score < 0.2 and current_diff == "Medium":
134
+ state["difficulty"] = "Easy"
135
+
136
+ return state
137
+
138
+ async def generate_report_node(state: InterviewState):
139
+ """Node: Generates the final report after all questions."""
140
+ logger.info("Generating Final Report...")
141
+
142
+ # Prepare data for the prompt
143
+ interview_data_str = json.dumps(state["analysis_data"], indent=2)
144
+
145
+ report = await gemini_service.generate_final_report(
146
+ target_company=state["target_company"],
147
+ job_role=state["job_role"],
148
+ interview_data=interview_data_str
149
+ )
150
+
151
+ state["final_report"] = report
152
+ return state
153
+
154
+ import json
155
+
156
+ # --- Routing ---
157
+
158
+ def route_interview(state: InterviewState):
159
+ """Decides whether to continue questioning, follow-up, or end."""
160
+
161
+ # 1. Check if we should ask a follow-up
162
+ if state.get("follow_up_count", 0) < state.get("max_follow_ups", 0):
163
+ return "generate_follow_up"
164
+
165
+ if state["current_question_num"] >= state["total_questions"]:
166
+ return "generate_report"
167
+ return "generate_question"
168
+
169
+ # --- Graph Definition ---
170
+
171
+ workflow = StateGraph(InterviewState)
172
+
173
+ workflow.add_node("generate_question", generate_question_node)
174
+ workflow.add_node("generate_follow_up", generate_follow_up_node)
175
+ workflow.add_node("analyze_answer", analyze_answer_node)
176
+ workflow.add_node("generate_report", generate_report_node)
177
+
178
+ # Entry point
179
+ workflow.set_entry_point("generate_question")
180
+
181
+ # Transition from Question extraction -> Wait for user input
182
+ # NOTE: In a real API, we would pause here.
183
+ # For this graph, we assume the HumanMessage is injected into state
184
+ # externally before resuming.
185
+ # BUT `StateGraph` in basic form runs until END or interrupt.
186
+ # Since we are building an API, we will likely run one step at a time or use `interrupt`.
187
+ # For MVP simplicity:
188
+ # The "cycle" is: Generate Question -> END (Return to user) -> (User calls API) -> Analyze Answer -> Route
189
+
190
+ # However, to visualize the logic:
191
+ # generate_question -> END (user sees question)
192
+ # ... User inputs answer ...
193
+ # (Resume with answer) -> analyze_answer -> route -> generate_question/report
194
+
195
+ # We will define the edge from analyze to route
196
+ workflow.add_conditional_edges(
197
+ "analyze_answer",
198
+ route_interview,
199
+ {
200
+ "generate_question": "generate_question",
201
+ "generate_follow_up": "generate_follow_up",
202
+ "generate_report": "generate_report"
203
+ }
204
+ )
205
+
206
+ workflow.add_edge("generate_report", END)
207
+
208
+ # We define the edge that "ends" a turn to wait for user input.
209
+ # In LangGraph terms, `generate_question` finishes, and we return state to the caller.
210
+ # The caller (FastAPI) will persist state.
211
+ # When user replies, we invoke `analyze_answer` directly?
212
+ # OR we define the full loop and use `interrupt_before`.
213
+
214
+ # Let's use the explicit loop for clarity and compilation,
215
+ # but at runtime we might use it differently.
216
+ # Ideally: generate_question -> END.
217
+ # Then user submits answer -> analyze_answer -> check condition.
backend/app/api_routes.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ import os
3
+ from uuid import uuid4
4
+ from fastapi import APIRouter, UploadFile, File, HTTPException, Form
5
+ from app.schemas import InterviewStartRequest, InterviewStartResponse, ChatResponse
6
+ from app.agents.interview_graph import workflow
7
+ from app.services.voice_service import voice_service
8
+ from app.core.logging_config import logger
9
+
10
+ router = APIRouter()
11
+
12
+ # In-memory session store for MVP
13
+ # In production, use Redis or the SQL database to persist LangGraph state
14
+ SESSION_STORE = {}
15
+
16
+ @router.post("/start", response_model=InterviewStartResponse)
17
+ async def start_interview(request: InterviewStartRequest):
18
+ session_id = str(uuid4())
19
+ logger.info(f"Starting session {session_id} for {request.target_company}")
20
+
21
+ # Initialize State
22
+ initial_state = {
23
+ "messages": [],
24
+ "history": [],
25
+ "current_question": None,
26
+ "current_question_num": 0,
27
+ "total_questions": 5, # Default to 5 questions
28
+ "target_company": request.target_company,
29
+ "interview_style": request.interview_style,
30
+ "job_role": request.job_role,
31
+ "difficulty": request.difficulty,
32
+ "topic": request.topic or "General",
33
+ "analysis_data": []
34
+ }
35
+
36
+ # Compile graph
37
+ app = workflow.compile()
38
+
39
+ # Run first step to get Q1
40
+ result = await app.ainvoke(initial_state)
41
+
42
+ # Store state
43
+ SESSION_STORE[session_id] = result
44
+
45
+ return InterviewStartResponse(
46
+ session_id=session_id,
47
+ message="Interview initialized.",
48
+ first_question=result["current_question"]
49
+ )
50
+
51
+ @router.post("/start_with_resume", response_model=InterviewStartResponse)
52
+ async def start_interview_with_resume(
53
+ target_company: str = Form("Google"),
54
+ job_role: str = Form("Senior Engineer"),
55
+ interview_style: str = Form("Professional"),
56
+ difficulty: str = Form("Medium"),
57
+ resume_file: UploadFile = File(...)
58
+ ):
59
+ session_id = str(uuid4())
60
+ logger.info(f"Starting Resume Session {session_id} for {target_company}")
61
+ logger.info(f"Received file: {resume_file.filename}, Size: unknown bytes")
62
+
63
+ try:
64
+ # 1. Parsing Resume
65
+ from app.services.resume_service import resume_service
66
+ resume_text = await resume_service.extract_text(resume_file)
67
+ logger.info(f"Resume text extracted (First 50 chars): {resume_text[:50]}...")
68
+
69
+ # 2. Init State
70
+ initial_state = {
71
+ "messages": [],
72
+ "history": [],
73
+ "current_question": None,
74
+ "current_question_num": 0,
75
+ "total_questions": 5,
76
+ "target_company": target_company,
77
+ "interview_style": interview_style,
78
+ "job_role": job_role,
79
+ "difficulty": difficulty,
80
+ "topic": "Resume Review", # Override topic
81
+ "resume_text": resume_text,
82
+ "analysis_data": []
83
+ }
84
+
85
+ # 3. Compile & Run
86
+ app = workflow.compile()
87
+ result = await app.ainvoke(initial_state)
88
+
89
+ SESSION_STORE[session_id] = result
90
+
91
+ return InterviewStartResponse(
92
+ session_id=session_id,
93
+ message="Interview initialized with Resume.",
94
+ first_question=result["current_question"]
95
+ )
96
+ except Exception as e:
97
+ logger.error(f"Error in start_with_resume: {str(e)}")
98
+ raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
99
+
100
+ @router.post("/chat", response_model=ChatResponse)
101
+ async def chat_interview(
102
+ session_id: str = Form(...),
103
+ text_input: str = Form(None),
104
+ audio_file: UploadFile = File(None)
105
+ ):
106
+ if session_id not in SESSION_STORE:
107
+ raise HTTPException(status_code=404, detail="Session not found")
108
+
109
+ current_state = SESSION_STORE[session_id]
110
+
111
+ # 1. Handle Input (Text or Audio)
112
+ user_response_text = ""
113
+
114
+ if audio_file:
115
+ # Save temp file
116
+ temp_filename = f"temp_{session_id}_{uuid4()}.wav"
117
+ with open(temp_filename, "wb") as buffer:
118
+ shutil.copyfileobj(audio_file.file, buffer)
119
+
120
+ try:
121
+ # Transcribe
122
+ user_response_text = await voice_service.transcribe_audio(temp_filename)
123
+ finally:
124
+ if os.path.exists(temp_filename):
125
+ os.remove(temp_filename)
126
+ elif text_input:
127
+ user_response_text = text_input
128
+ else:
129
+ raise HTTPException(status_code=400, detail="No input provided")
130
+
131
+ logger.info(f"User Response: {user_response_text}")
132
+
133
+ # 2. Update Context with User Answer
134
+ from langchain_core.messages import HumanMessage
135
+ current_state["messages"].append(HumanMessage(content=user_response_text))
136
+
137
+ try:
138
+ # 3. Run Graph (Analyze -> Route -> Generate/Report)
139
+ from app.agents.interview_graph import analyze_answer_node, route_interview, generate_question_node, generate_report_node
140
+
141
+ # A. Analyze
142
+ logger.info("Running analyze_answer_node...")
143
+ state = await analyze_answer_node(current_state)
144
+ feedback_item = state["analysis_data"][-1]
145
+
146
+ # B. Route
147
+ next_step = route_interview(state)
148
+ logger.info(f"Next step routed: {next_step}")
149
+
150
+ response_data = ChatResponse(
151
+ feedback=feedback_item["analysis"],
152
+ user_transcript=user_response_text
153
+ )
154
+
155
+ if next_step == "generate_question":
156
+ # C. Generate Next Question
157
+ logger.info("Running generate_question_node...")
158
+ state = await generate_question_node(state)
159
+ response_data.question = state["current_question"]
160
+
161
+ # D. Audio for Question (TTS)
162
+ os.makedirs("static/audio", exist_ok=True)
163
+ filename = f"q_{session_id}_{state['current_question_num']}.mp3"
164
+ filepath = os.path.join("static/audio", filename)
165
+
166
+ try:
167
+ await voice_service.generate_audio(state["current_question"], filepath)
168
+ response_data.audio_url = f"/static/audio/{filename}"
169
+ except Exception as e:
170
+ logger.error(f"TTS failed: {e}")
171
+
172
+ elif next_step == "generate_report":
173
+ # C. Generate Report
174
+ logger.info("Running generate_report_node...")
175
+ response_data.is_finished = True
176
+ state = await generate_report_node(state)
177
+
178
+ # Update Store
179
+ SESSION_STORE[session_id] = state
180
+
181
+ return response_data
182
+
183
+ except Exception as e:
184
+ logger.error(f"Error in chat_interview logic: {e}", exc_info=True)
185
+ import traceback
186
+ traceback.print_exc()
187
+ raise HTTPException(status_code=500, detail=f"Chat Error: {str(e)}")
188
+
189
+ @router.get("/report/{session_id}")
190
+ async def get_report(session_id: str):
191
+ if session_id not in SESSION_STORE:
192
+ raise HTTPException(status_code=404, detail="Session not found")
193
+
194
+ state = SESSION_STORE[session_id]
195
+ if not state.get("final_report"):
196
+ return {"status": "in_progress"}
197
+
198
+ return {"report": state["final_report"]}
199
+
200
+ @router.post("/analyze_video")
201
+ async def analyze_video(video_file: UploadFile = File(...)):
202
+ temp_filename = f"temp_video_{uuid4()}.mp4"
203
+ with open(temp_filename, "wb") as buffer:
204
+ shutil.copyfileobj(video_file.file, buffer)
205
+
206
+ try:
207
+ from app.services.gemini_service import gemini_service
208
+ analysis = await gemini_service.analyze_video_behavior(temp_filename)
209
+ return {"analysis": analysis}
210
+ except Exception as e:
211
+ logger.error(f"Video analysis failed: {e}")
212
+ raise HTTPException(status_code=500, detail=str(e))
213
+ finally:
214
+ if os.path.exists(temp_filename):
215
+ os.remove(temp_filename)
backend/app/core/config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Union
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+ from pydantic import AnyHttpUrl, field_validator
5
+
6
+ class Settings(BaseSettings):
7
+ API_V1_STR: str = "/api/v1"
8
+ PROJECT_NAME: str = "TalentTalk Pro"
9
+
10
+ # CORS
11
+ BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
12
+
13
+ @field_validator("BACKEND_CORS_ORIGINS", mode="before")
14
+ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
15
+ if isinstance(v, str) and not v.startswith("["):
16
+ return [i.strip() for i in v.split(",")]
17
+ elif isinstance(v, (list, str)):
18
+ return v
19
+ raise ValueError(v)
20
+
21
+ # Database
22
+ DATABASE_URL: str = "sqlite+aiosqlite:///./talenttalk.db"
23
+
24
+ # OpenRouter
25
+ OPENROUTER_API_KEY: str
26
+ GOOGLE_API_KEY: str = "" # Optional fallback or for Multimodal if valid
27
+
28
+ # Voice Services
29
+ ASSEMBLYAI_API_KEY: str = ""
30
+ ELEVENLABS_API_KEY: str = ""
31
+
32
+ # Environment
33
+ ENVIRONMENT: str = "development"
34
+ LOG_LEVEL: str = "INFO"
35
+
36
+ model_config = SettingsConfigDict(
37
+ env_file=".env",
38
+ env_file_encoding="utf-8",
39
+ case_sensitive=True,
40
+ extra="ignore"
41
+ )
42
+
43
+ settings = Settings()
backend/app/core/logging_config.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+ import json
4
+ from .config import settings
5
+
6
+ class JsonFormatter(logging.Formatter):
7
+ def format(self, record):
8
+ log_obj = {
9
+ "timestamp": self.formatTime(record, self.datefmt),
10
+ "level": record.levelname,
11
+ "message": record.getMessage(),
12
+ "module": record.module,
13
+ "line": record.lineno,
14
+ }
15
+ if record.exc_info:
16
+ log_obj["exception"] = self.formatException(record.exc_info)
17
+ return json.dumps(log_obj)
18
+
19
+ def setup_logging():
20
+ logger = logging.getLogger("talenttalk")
21
+ logger.setLevel(settings.LOG_LEVEL)
22
+
23
+ console_handler = logging.StreamHandler(sys.stdout)
24
+
25
+ if settings.ENVIRONMENT == "production":
26
+ console_handler.setFormatter(JsonFormatter())
27
+ else:
28
+ # Standard readable format for dev
29
+ formatter = logging.Formatter(
30
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
31
+ )
32
+ console_handler.setFormatter(formatter)
33
+
34
+ logger.addHandler(console_handler)
35
+
36
+ # Also capture uvicorn logs if in prod
37
+ if settings.ENVIRONMENT == "production":
38
+ uvicorn_logger = logging.getLogger("uvicorn.access")
39
+ uvicorn_logger.handlers = [console_handler]
40
+
41
+ return logger
42
+
43
+ logger = setup_logging()
backend/app/core/prompts.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.prompts import PromptTemplate
2
+
3
+ # Prompt for generating the next interview question
4
+ QUESTION_PROMPT_TEMPLATE = """
5
+ You are an expert technical interviewer for {target_company}.
6
+ You are conducting a {interview_style} interview for the role of {job_role}.
7
+
8
+ Context:
9
+ - Current Difficulty Level: {difficulty}
10
+ - specific Topic (if any): {topic}
11
+ - Question Number: {question_num} of {total_questions}
12
+
13
+ Candidate's Resume Context:
14
+ {resume_context}
15
+
16
+ Your goal is to assess the candidate's skills, problem-solving abilities, and cultural fit for {target_company}.
17
+ If the interview style is "Friendly", be encouraging and conversational.
18
+ If "Professional", be formal and precise.
19
+ If "HR", focus on behavioral and situational questions.
20
+ If "Technical", focus on coding, system design, and deep technical concepts.
21
+ If "Visual", assume the candidate can see you (describe your expression/gesture in brackets if needed).
22
+
23
+ Previous Conversation History:
24
+ {history}
25
+
26
+ Generate the next interview question.
27
+ Keep it concise and clear.
28
+ Do not greet the candidate again if you have already done so in the history.
29
+ Just output the question text.
30
+ """
31
+
32
+ QUESTION_PROMPT = PromptTemplate(
33
+ input_variables=["target_company", "interview_style", "job_role", "difficulty", "topic", "question_num", "total_questions", "history", "resume_context"],
34
+ template=QUESTION_PROMPT_TEMPLATE
35
+ )
36
+
37
+
38
+ # Prompt for analyzing the candidate's response
39
+ ANALYSIS_PROMPT_TEMPLATE = """
40
+ You are an AI Interview Evaluator.
41
+ Analyze the candidate's response to the following question.
42
+
43
+ Question: {question}
44
+ Candidate's Answer: {answer}
45
+
46
+ Context:
47
+ - Role: {job_role}
48
+ - Difficulty: {difficulty}
49
+
50
+ Provide your analysis in the following JSON format ONLY:
51
+ {{
52
+ "feedback": "Constructive feedback on the answer, highlighting strengths and weaknesses.",
53
+ "sentiment_score": 0.5, // Float between -1.0 (Negative) and 1.0 (Positive)
54
+ "technical_accuracy": 0.8, // Float between 0.0 and 1.0
55
+ "suggested_improvement": "A better way to phrase or answer the question.",
56
+ "is_correct": true // Boolean
57
+ }}
58
+ """
59
+
60
+ ANALYSIS_PROMPT = PromptTemplate(
61
+ input_variables=["question", "answer", "job_role", "difficulty"],
62
+ template=ANALYSIS_PROMPT_TEMPLATE
63
+ )
64
+
65
+
66
+ # Prompt for generating the final comprehensive report
67
+ FINAL_REPORT_PROMPT_TEMPLATE = """
68
+ You are a Senior Talent Acquisition Specialist at {target_company}.
69
+ You have just completed an interview with a candidate for the {job_role} position.
70
+
71
+ Interview Data:
72
+ {interview_data}
73
+
74
+ Generate a comprehensive Final Analysis Report in Markdown format.
75
+ The report should include the following sections:
76
+
77
+ 1. **Executive Summary**: A brief overview of the candidate's performance.
78
+
79
+ 2. **Full Interview Transcript**:
80
+ - List every Question asked and the Candidate's Answer.
81
+ - For each answer, provide a brief critique.
82
+
83
+ 3. **Detailed Analysis**:
84
+ - **Strengths**: Key areas where the candidate excelled.
85
+ - **Weaknesses**: Specific technical or behavioral gaps.
86
+ - **Sentiment & Confidence**: Breakdown of their tone and confidence level.
87
+
88
+ 4. **Actionable Suggestions**:
89
+ - Specific advice on how to improve for the next interview.
90
+ - Resources or topics to study if technical gaps were found.
91
+
92
+ 5. **Final Verdict**:
93
+ - **Recommendation**: Hiring recommendation (Strong Hire, Hire, No Hire) with justification.
94
+ - **Overall Rating**: Score out of 10.
95
+
96
+ Tone: Professional, constructive, and encouraging.
97
+ """
98
+
99
+ FINAL_REPORT_PROMPT = PromptTemplate(
100
+ input_variables=["target_company", "job_role", "interview_data"],
101
+ template=FINAL_REPORT_PROMPT_TEMPLATE
102
+ )
103
+
104
+ # Prompt for generating a follow-up question
105
+ FOLLOWUP_PROMPT_TEMPLATE = """
106
+ You are an expert technical interviewer for {target_company}.
107
+ The candidate just answered your question: "{question}"
108
+ Candidate's Answer: "{answer}"
109
+
110
+ Your goal is to dig deeper. Generate a short, sharp follow-up question.
111
+ - If the answer was vague, ask for clarification.
112
+ - If the answer was good, ask about a specific edge case or trade-off related to their answer.
113
+ - Keep it conversational.
114
+
115
+ Just output the follow-up question text.
116
+ """
117
+
118
+ FOLLOWUP_PROMPT = PromptTemplate(
119
+ input_variables=["target_company", "question", "answer"],
120
+ template=FOLLOWUP_PROMPT_TEMPLATE
121
+ )
backend/app/db/database.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import AsyncGenerator
2
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
3
+ from sqlalchemy.orm import sessionmaker
4
+ from sqlmodel import SQLModel
5
+
6
+ from app.core.config import settings
7
+
8
+ # Create Async Engine
9
+ # check_same_thread=False is needed only for SQLite.
10
+ connect_args = {"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
11
+
12
+ engine = create_async_engine(
13
+ settings.DATABASE_URL,
14
+ echo=(settings.LOG_LEVEL == "DEBUG"),
15
+ connect_args=connect_args,
16
+ future=True
17
+ )
18
+
19
+ async def init_db():
20
+ async with engine.begin() as conn:
21
+ # await conn.run_sync(SQLModel.metadata.drop_all)
22
+ await conn.run_sync(SQLModel.metadata.create_all)
23
+
24
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
25
+ async_session = sessionmaker(
26
+ engine, class_=AsyncSession, expire_on_commit=False
27
+ )
28
+ async with async_session() as session:
29
+ yield session
backend/app/main.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+
5
+ from app.core.config import settings
6
+ from app.core.logging_config import logger
7
+ from app.db.database import init_db
8
+
9
+ @asynccontextmanager
10
+ async def lifespan(app: FastAPI):
11
+ # Startup
12
+ logger.info("Startup: Initializing Application")
13
+ await init_db()
14
+ logger.info("Startup: Database initialized")
15
+ yield
16
+ # Shutdown
17
+ logger.info("Shutdown: Application stopping")
18
+
19
+ from fastapi.staticfiles import StaticFiles
20
+ from app.api_routes import router as api_router
21
+
22
+ app = FastAPI(
23
+ title=settings.PROJECT_NAME,
24
+ openapi_url=f"{settings.API_V1_STR}/openapi.json",
25
+ lifespan=lifespan
26
+ )
27
+
28
+ # Mount static files for audio
29
+ app.mount("/static", StaticFiles(directory="static"), name="static")
30
+
31
+ # Set all CORS enabled origins
32
+ if settings.BACKEND_CORS_ORIGINS:
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
36
+ allow_credentials=True,
37
+ allow_methods=["*"],
38
+ allow_headers=["*"],
39
+ )
40
+
41
+ app.include_router(api_router, prefix="/api/v1")
42
+
43
+ @app.get("/health")
44
+ async def health_check():
45
+ return {"status": "healthy", "environment": settings.ENVIRONMENT}
46
+
47
+ @app.get("/")
48
+ async def root():
49
+ return {"message": "Welcome to TalentTalk Pro API", "docs": "/docs"}
backend/app/models/models.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List
3
+ from enum import Enum
4
+ from sqlmodel import SQLModel, Field, Relationship
5
+ from uuid import UUID, uuid4
6
+ from pydantic import EmailStr
7
+
8
+ class UserRole(str, Enum):
9
+ ADMIN = "admin"
10
+ USER = "user"
11
+
12
+ class InterviewStatus(str, Enum):
13
+ PENDING = "pending"
14
+ IN_PROGRESS = "in_progress"
15
+ COMPLETED = "completed"
16
+
17
+ class DifficultyLevel(str, Enum):
18
+ EASY = "easy"
19
+ MEDIUM = "medium"
20
+ HARD = "hard"
21
+
22
+ class InterviewStyle(str, Enum):
23
+ VISUAL = "visual" # e.g., friendly, seeing the interviewer
24
+ PROFESSIONAL = "professional"
25
+ HR = "hr"
26
+ TECHNICAL = "technical"
27
+
28
+ class User(SQLModel, table=True):
29
+ id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
30
+ email: EmailStr = Field(index=True, unique=True)
31
+ hashed_password: str
32
+ role: UserRole = Field(default=UserRole.USER)
33
+
34
+ sessions: List["InterviewSession"] = Relationship(back_populates="user")
35
+
36
+ class InterviewSession(SQLModel, table=True):
37
+ id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
38
+ user_id: UUID = Field(foreign_key="user.id")
39
+ job_role: str
40
+ difficulty_level: DifficultyLevel = Field(default=DifficultyLevel.MEDIUM)
41
+ interview_style: InterviewStyle = Field(default=InterviewStyle.PROFESSIONAL)
42
+ target_company: Optional[str] = None
43
+ status: InterviewStatus = Field(default=InterviewStatus.PENDING)
44
+ created_at: datetime = Field(default_factory=datetime.utcnow)
45
+ final_analysis_report: Optional[str] = Field(default=None, description="JSON or text summary of the interview")
46
+
47
+ user: User = Relationship(back_populates="sessions")
48
+ questions: List["Question"] = Relationship(back_populates="session")
49
+
50
+ class Question(SQLModel, table=True):
51
+ id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
52
+ session_id: UUID = Field(foreign_key="interviewsession.id")
53
+ content: str
54
+ topic: Optional[str] = None
55
+ difficulty: Optional[str] = None
56
+ order: int
57
+
58
+ session: InterviewSession = Relationship(back_populates="questions")
59
+ response: Optional["Response"] = Relationship(back_populates="question")
60
+
61
+ class Response(SQLModel, table=True):
62
+ id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
63
+ question_id: UUID = Field(foreign_key="question.id")
64
+ audio_url: Optional[str] = None
65
+ transcript: Optional[str] = None
66
+ sentiment_score: Optional[float] = None
67
+
68
+ question: Question = Relationship(back_populates="response")
69
+ feedback: Optional["Feedback"] = Relationship(back_populates="response")
70
+
71
+ class Feedback(SQLModel, table=True):
72
+ id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
73
+ response_id: UUID = Field(foreign_key="response.id")
74
+ content: str
75
+ score: Optional[int] = None
76
+
77
+ response: Response = Relationship(back_populates="feedback")
backend/app/schemas.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, ConfigDict
2
+ from typing import List, Optional, Dict, Any
3
+ from uuid import UUID
4
+
5
+ # --- Request Models ---
6
+
7
+ class InterviewStartRequest(BaseModel):
8
+ target_company: str
9
+ job_role: str
10
+ interview_style: str
11
+ difficulty: str
12
+ topic: Optional[str] = None
13
+ max_follow_ups: int = 1 # Default to 1 follow-up per question
14
+
15
+ class ChatRequest(BaseModel):
16
+ session_id: str
17
+ user_input: Optional[str] = None
18
+ audio_file_data: Optional[bytes] = None # For direct file upload if needed, usually handled via UploadFile
19
+
20
+ class ReportRequest(BaseModel):
21
+ session_id: str
22
+
23
+ # --- Response Models ---
24
+
25
+ class InterviewStartResponse(BaseModel):
26
+ session_id: str
27
+ message: str
28
+ first_question: str
29
+
30
+ class ChatResponse(BaseModel):
31
+ question: Optional[str] = None
32
+ audio_url: Optional[str] = None # URL to TTS audio
33
+ feedback: Optional[Dict[str, Any]] = None
34
+ user_transcript: Optional[str] = None # Transcribed text from audio
35
+ is_finished: bool = False
36
+
37
+ class ReportResponse(BaseModel):
38
+ report_content: str
backend/app/services/gemini_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Dict, Any, List
3
+ from langchain_openai import ChatOpenAI
4
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
5
+
6
+ from app.core.config import settings
7
+ from app.core.prompts import QUESTION_PROMPT, ANALYSIS_PROMPT, FINAL_REPORT_PROMPT, FOLLOWUP_PROMPT
8
+ from app.core.logging_config import logger # Added for video analysis and error logging
9
+
10
+ class GeminiService:
11
+ def __init__(self):
12
+ # OpenRouter Configuration
13
+ self.llm = ChatOpenAI(
14
+ model="google/gemini-2.0-flash-001",
15
+ openai_api_key=settings.OPENROUTER_API_KEY,
16
+ openai_api_base="https://openrouter.ai/api/v1",
17
+ temperature=0.7
18
+ )
19
+ self.json_llm = ChatOpenAI(
20
+ model="google/gemini-2.0-flash-001",
21
+ openai_api_key=settings.OPENROUTER_API_KEY,
22
+ openai_api_base="https://openrouter.ai/api/v1",
23
+ temperature=0.3,
24
+ model_kwargs={"response_format": {"type": "json_object"}}
25
+ )
26
+
27
+ async def analyze_video_behavior(self, video_path: str) -> str:
28
+ """Analyzes a video file for behavioral cues and expressions."""
29
+ # Video analysis via OpenRouter (Multimodal) requires sending image frames or video URL.
30
+ # For MVP, we will stub this or use a simple text fallback since we can't upload files to OpenRouter easily via this SDK yet.
31
+ # Alternatively, we could keep the Google SDK *just* for this if a GOOGLE_API_KEY is present.
32
+
33
+ if settings.GOOGLE_API_KEY:
34
+ try:
35
+ import google.generativeai as genai
36
+ import time
37
+ genai.configure(api_key=settings.GOOGLE_API_KEY)
38
+ model = genai.GenerativeModel('gemini-1.5-flash')
39
+
40
+ logger.info(f"Uploading video {video_path} to Google for analysis...")
41
+ video_file = genai.upload_file(path=video_path)
42
+
43
+ while video_file.state.name == "PROCESSING":
44
+ time.sleep(1)
45
+ video_file = genai.get_file(video_file.name)
46
+
47
+ if video_file.state.name == "FAILED":
48
+ raise ValueError("Video processing failed by Gemini.")
49
+
50
+ prompt = "Analyze this interview video clip. Describe the candidate's facial expressions, body language, and apparent confidence level. Be concise."
51
+ response = model.generate_content([video_file, prompt])
52
+ return response.text
53
+ except Exception as e:
54
+ logger.error(f"Google Video Analysis failed: {e}")
55
+ return "Video analysis unavailable (Check Google API Key)."
56
+
57
+ return "Video analysis requires a valid GOOGLE_API_KEY in addition to OpenRouter."
58
+
59
+ async def generate_question(
60
+ self,
61
+ target_company: str,
62
+ interview_style: str,
63
+ job_role: str,
64
+ difficulty: str,
65
+ topic: str,
66
+ question_num: int,
67
+ total_questions: int,
68
+ history: List[str],
69
+ resume_text: str = None
70
+ ) -> str:
71
+ """Generates the next interview question based on context."""
72
+
73
+ # Format history string
74
+ history_text = "\n".join(history) if history else "No previous history."
75
+ resume_context = resume_text if resume_text else "No resume provided."
76
+
77
+ prompt = QUESTION_PROMPT.format(
78
+ target_company=target_company or "Generic Tech Company",
79
+ interview_style=interview_style,
80
+ job_role=job_role,
81
+ difficulty=difficulty,
82
+ topic=topic or "General",
83
+ question_num=question_num,
84
+ total_questions=total_questions,
85
+ history=history_text,
86
+ resume_context=resume_context
87
+ )
88
+
89
+ response = await self.llm.ainvoke(prompt)
90
+ return response.content
91
+
92
+
93
+ async def generate_followup_question(
94
+ self,
95
+ target_company: str,
96
+ question: str,
97
+ answer: str
98
+ ) -> str:
99
+ """Generates a follow-up question based on the previous answer."""
100
+
101
+ prompt = FOLLOWUP_PROMPT.format(
102
+ target_company=target_company or "Generic Tech Company",
103
+ question=question,
104
+ answer=answer
105
+ )
106
+
107
+ response = await self.llm.ainvoke(prompt)
108
+ return response.content
109
+
110
+ async def analyze_response(
111
+ self,
112
+ question: str,
113
+ answer: str,
114
+ job_role: str,
115
+ difficulty: str
116
+ ) -> Dict[str, Any]:
117
+ """Analyzes the candidate's answer and returns structured data."""
118
+
119
+ prompt = ANALYSIS_PROMPT.format(
120
+ question=question,
121
+ answer=answer,
122
+ job_role=job_role,
123
+ difficulty=difficulty
124
+ )
125
+
126
+ try:
127
+ response = await self.json_llm.ainvoke(prompt)
128
+ content = response.content
129
+ # Cleanup json if needed
130
+ if "```json" in content:
131
+ content = content.replace("```json", "").replace("```", "").strip()
132
+ return json.loads(content)
133
+ except Exception as e:
134
+ logger.error(f"Analysis failed: {e}")
135
+ return {
136
+ "feedback": "Could not analyze response.",
137
+ "sentiment_score": 0.0,
138
+ "technical_accuracy": 0.0,
139
+ "suggested_improvement": "",
140
+ "is_correct": False,
141
+ "error": str(e)
142
+ }
143
+
144
+ async def generate_final_report(
145
+ self,
146
+ target_company: str,
147
+ job_role: str,
148
+ interview_data: str
149
+ ) -> str:
150
+ """Generates the comprehensive final markdown report."""
151
+
152
+ prompt = FINAL_REPORT_PROMPT.format(
153
+ target_company=target_company or "Generic Tech Company",
154
+ job_role=job_role,
155
+ interview_data=interview_data
156
+ )
157
+
158
+ response = await self.llm.ainvoke(prompt)
159
+ return response.content
160
+
161
+ gemini_service = GeminiService()
backend/app/services/resume_service.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ from pypdf import PdfReader
3
+ from fastapi import UploadFile
4
+
5
+ class ResumeService:
6
+ async def extract_text(self, file: UploadFile) -> str:
7
+ """Extracts text from a PDF file."""
8
+ content = await file.read()
9
+ file_obj = io.BytesIO(content)
10
+
11
+ try:
12
+ reader = PdfReader(file_obj)
13
+ text = ""
14
+ if not reader.pages:
15
+ return "Error: Empty PDF or parsing failed."
16
+
17
+ for page in reader.pages:
18
+ extracted = page.extract_text()
19
+ if extracted:
20
+ text += extracted + "\n"
21
+
22
+ if not text.strip():
23
+ return "Warning: No text could be extracted from this PDF. It might be an image scan."
24
+
25
+ return text
26
+ except Exception as e:
27
+ # Fallback for non-pdf or error
28
+ print(f"Error in extract_text: {e}") # Print to stdout to capture in logs
29
+ return f"Error extracting resume: {str(e)}"
30
+
31
+ resume_service = ResumeService()
backend/app/services/voice_service.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ from typing import Optional
4
+ import assemblyai as aai
5
+ from elevenlabs.client import ElevenLabs
6
+
7
+ from app.core.config import settings
8
+ from app.core.logging_config import logger
9
+
10
+ class VoiceService:
11
+ def __init__(self):
12
+ # Initialize AssemblyAI
13
+ if settings.ASSEMBLYAI_API_KEY:
14
+ aai.settings.api_key = settings.ASSEMBLYAI_API_KEY
15
+ self.transcriber = aai.Transcriber()
16
+ else:
17
+ logger.warning("AssemblyAI API Key not found. STT will be disabled.")
18
+ self.transcriber = None
19
+
20
+ # Initialize ElevenLabs
21
+ if settings.ELEVENLABS_API_KEY:
22
+ self.elevenlabs = ElevenLabs(api_key=settings.ELEVENLABS_API_KEY)
23
+ else:
24
+ logger.warning("ElevenLabs API Key not found. TTS will be disabled.")
25
+ self.elevenlabs = None
26
+
27
+ async def transcribe_audio(self, file_path: str) -> str:
28
+ """Transcribes audio file using Google Gemini (Fallbacks to AssemblyAI if needed)."""
29
+
30
+ # Method 1: Google Gemini (Multimodal) - Robust & supports many formats without FFMPEG
31
+ if settings.GOOGLE_API_KEY:
32
+ try:
33
+ import google.generativeai as genai
34
+ genai.configure(api_key=settings.GOOGLE_API_KEY)
35
+
36
+ logger.info(f"Uploading audio {file_path} to Gemini...")
37
+ # Upload file
38
+ audio_file = genai.upload_file(path=file_path)
39
+
40
+ # Prompt
41
+ model = genai.GenerativeModel('gemini-1.5-flash')
42
+ response = model.generate_content([
43
+ "Transcribe this audio file verbatim. Output strictly the transcription text only.",
44
+ audio_file
45
+ ])
46
+
47
+ logger.info("Gemini Transcription complete.")
48
+ return response.text.strip()
49
+ except Exception as e:
50
+ logger.error(f"Gemini STT failed: {e}")
51
+ # Fallthrough to AssemblyAI
52
+
53
+ # Method 2: AssemblyAI
54
+ if not self.transcriber:
55
+ raise ValueError("No Transcription service available (Gemini or AssemblyAI). check API Keys.")
56
+
57
+ logger.info(f"Transcribing audio with AssemblyAI: {file_path}")
58
+
59
+ # AssemblyAI SDK is synchronous, run in executor
60
+ loop = asyncio.get_event_loop()
61
+
62
+ try:
63
+ transcript = await loop.run_in_executor(
64
+ None,
65
+ self.transcriber.transcribe,
66
+ file_path
67
+ )
68
+
69
+ if transcript.status == aai.TranscriptStatus.error:
70
+ raise Exception(transcript.error)
71
+
72
+ return transcript.text
73
+ except Exception as e:
74
+ logger.error(f"AssemblyAI Transcription failed: {e}")
75
+ raise
76
+
77
+ async def generate_audio(self, text: str, output_path: str) -> Optional[str]:
78
+ """Generates audio from text using ElevenLabs."""
79
+ logger.info(f"ENTER generate_audio: {text[:20]}...")
80
+ if not self.elevenlabs:
81
+ logger.error("ElevenLabs not configured")
82
+ raise ValueError("ElevenLabs not configured.")
83
+
84
+ logger.info(f"Generating audio for: {text[:50]}...")
85
+
86
+ try:
87
+ # Run blocking generation in executor
88
+ loop = asyncio.get_event_loop()
89
+
90
+ # Use default voice for now
91
+ audio_generator = await loop.run_in_executor(
92
+ None,
93
+ lambda: self.elevenlabs.generate(
94
+ text=text,
95
+ voice="Rachel", # Default popular voice
96
+ model="eleven_monolingual_v1"
97
+ )
98
+ )
99
+
100
+ # Save to file
101
+ with open(output_path, "wb") as f:
102
+ for chunk in audio_generator:
103
+ f.write(chunk)
104
+
105
+ return output_path
106
+ except Exception as e:
107
+ logger.error(f"ElevenLabs TTS generation failed: {e}. Falling back to gTTS.")
108
+
109
+ # Fallback: gTTS (Free)
110
+ try:
111
+ from gtts import gTTS
112
+ loop = asyncio.get_event_loop()
113
+ await loop.run_in_executor(
114
+ None,
115
+ lambda: gTTS(text=text, lang='en').save(output_path)
116
+ )
117
+ logger.info("gTTS generation successful.")
118
+ return output_path
119
+ except Exception as e_gtts:
120
+ logger.error(f"gTTS also failed: {e_gtts}")
121
+ raise
122
+
123
+ voice_service = VoiceService()
backend/core/config.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from functools import lru_cache
3
+
4
+ class Settings(BaseSettings):
5
+ APP_NAME: str = "TalentTalk Pro"
6
+ GOOGLE_API_KEY: str
7
+ DATABASE_URL: str = "sqlite:///./data/talenttalk.db"
8
+
9
+ class Config:
10
+ env_file = ".env"
11
+
12
+ @lru_cache()
13
+ def get_settings():
14
+ return Settings()
backend/core/database.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import SQLModel, create_engine, Session
2
+ from .config import get_settings
3
+
4
+ settings = get_settings()
5
+
6
+ engine = create_engine(
7
+ settings.DATABASE_URL,
8
+ echo=True,
9
+ connect_args={"check_same_thread": False} # Needed for SQLite
10
+ )
11
+
12
+ def init_db():
13
+ SQLModel.metadata.create_all(engine)
14
+
15
+ def get_session():
16
+ with Session(engine) as session:
17
+ yield session
backend/error_log.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ {"session_id":"de96fd7e-341f-4a39-a65f-b5c1ec2fa58a","message":"Interview initialized with Resume.","first_question":"Given the lack of resume information, let's start with a broad question.\n\nTell me about your experience with different types of testing methodologies.\n"}
backend/models/base.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import SQLModel, Field
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+ class Candidate(SQLModel, table=True):
6
+ id: Optional[int] = Field(default=None, primary_key=True)
7
+ name: str
8
+ email: str
9
+ resume_path: Optional[str] = None
10
+ created_at: datetime = Field(default_factory=datetime.utcnow)
11
+
12
+ class InterviewSession(SQLModel, table=True):
13
+ id: Optional[int] = Field(default=None, primary_key=True)
14
+ candidate_id: int = Field(foreign_key="candidate.id")
15
+ role: str
16
+ status: str = "scheduled" # scheduled, in_progress, completed
17
+ created_at: datetime = Field(default_factory=datetime.utcnow)
backend/models_list.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Fetching OpenRouter models...
2
+ Available Google Models:
3
+ google/gemini-3-flash-preview
4
+ google/gemini-3-pro-image-preview
5
+ google/gemini-3-pro-preview
6
+ google/gemini-2.5-flash-image
7
+ google/gemini-2.5-flash-preview-09-2025
8
+ google/gemini-2.5-flash-lite-preview-09-2025
9
+ google/gemini-2.5-flash-image-preview
10
+ google/gemini-2.5-flash-lite
11
+ google/gemma-3n-e2b-it:free
12
+ google/gemini-2.5-flash
13
+ google/gemini-2.5-pro
14
+ google/gemini-2.5-pro-preview
15
+ google/gemma-3n-e4b-it:free
16
+ google/gemma-3n-e4b-it
17
+ google/gemini-2.5-pro-preview-05-06
18
+ google/gemma-3-4b-it:free
19
+ google/gemma-3-4b-it
20
+ google/gemma-3-12b-it:free
21
+ google/gemma-3-12b-it
22
+ google/gemma-3-27b-it:free
23
+ google/gemma-3-27b-it
24
+ google/gemini-2.0-flash-lite-001
25
+ google/gemini-2.0-flash-001
26
+ google/gemini-2.0-flash-exp:free
27
+ google/gemma-2-27b-it
28
+ google/gemma-2-9b-it
backend/output.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Testing Resume Upload...
2
+ Status Code: 500
3
+ Response Body:
4
+ {"detail":"Internal Server Error: Error code: 400 - {'error': {'message': 'google/gemini-1.5-flash is not a valid model ID', 'code': 400}, 'user_id': 'user_37yjw8tgNpQjTG3dKrdmjnj6Zba'}"}
backend/requirements.txt ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiosqlite==0.22.1
2
+ altair==6.0.0
3
+ annotated-doc==0.0.4
4
+ annotated-types==0.7.0
5
+ anyio==4.12.1
6
+ assemblyai==0.48.4
7
+ asyncpg==0.31.0
8
+ attrs==25.4.0
9
+ backoff==2.2.1
10
+ bcrypt==5.0.0
11
+ blinker==1.9.0
12
+ build==1.3.0
13
+ cachetools==6.2.4
14
+ certifi==2026.1.4
15
+ charset-normalizer==3.4.4
16
+ chromadb==1.4.0
17
+ click==8.1.8
18
+ colorama==0.4.6
19
+ coloredlogs==15.0.1
20
+ distro==1.9.0
21
+ durationpy==0.10
22
+ elevenlabs==2.28.0
23
+ fastapi==0.128.0
24
+ filelock==3.20.2
25
+ filetype==1.2.0
26
+ flatbuffers==25.12.19
27
+ fsspec==2025.12.0
28
+ gitdb==4.0.12
29
+ GitPython==3.1.46
30
+ google-ai-generativelanguage==0.6.15
31
+ google-api-core==2.28.1
32
+ google-api-python-client==2.187.0
33
+ google-auth==2.47.0
34
+ google-auth-httplib2==0.3.0
35
+ google-genai==1.57.0
36
+ google-generativeai==0.8.6
37
+ googleapis-common-protos==1.72.0
38
+ greenlet==3.3.0
39
+ grpcio==1.76.0
40
+ grpcio-status==1.71.2
41
+ gTTS==2.5.4
42
+ h11==0.16.0
43
+ httpcore==1.0.9
44
+ httplib2==0.31.0
45
+ httptools==0.7.1
46
+ httpx==0.28.1
47
+ huggingface-hub==0.36.0
48
+ humanfriendly==10.0
49
+ idna==3.11
50
+ importlib_metadata==8.7.1
51
+ importlib_resources==6.5.2
52
+ iniconfig==2.3.0
53
+ Jinja2==3.1.6
54
+ jiter==0.12.0
55
+ joblib==1.5.3
56
+ jsonpatch==1.33
57
+ jsonpointer==3.0.0
58
+ jsonschema==4.26.0
59
+ jsonschema-specifications==2025.9.1
60
+ kubernetes==34.1.0
61
+ langchain==1.2.2
62
+ langchain-core==1.2.6
63
+ langchain-google-genai==4.1.3
64
+ langchain-openai==1.1.7
65
+ langgraph==1.0.5
66
+ langgraph-checkpoint==3.0.1
67
+ langgraph-prebuilt==1.0.5
68
+ langgraph-sdk==0.3.1
69
+ langsmith==0.6.1
70
+ markdown-it-py==4.0.0
71
+ MarkupSafe==3.0.3
72
+ mdurl==0.1.2
73
+ mmh3==5.2.0
74
+ mpmath==1.3.0
75
+ narwhals==2.15.0
76
+ networkx==3.6.1
77
+ numpy==2.4.0
78
+ oauthlib==3.3.1
79
+ onnxruntime==1.23.2
80
+ openai==2.14.0
81
+ opentelemetry-api==1.39.1
82
+ opentelemetry-exporter-otlp-proto-common==1.39.1
83
+ opentelemetry-exporter-otlp-proto-grpc==1.39.1
84
+ opentelemetry-proto==1.39.1
85
+ opentelemetry-sdk==1.39.1
86
+ opentelemetry-semantic-conventions==0.60b1
87
+ orjson==3.11.5
88
+ ormsgpack==1.12.1
89
+ overrides==7.7.0
90
+ packaging==25.0
91
+ pandas==2.3.3
92
+ pillow==12.1.0
93
+ pluggy==1.6.0
94
+ posthog==5.4.0
95
+ proto-plus==1.27.0
96
+ protobuf==5.29.5
97
+ pyarrow==22.0.0
98
+ pyasn1==0.6.1
99
+ pyasn1_modules==0.4.2
100
+ pybase64==1.4.3
101
+ pydantic==2.12.5
102
+ pydantic-settings==2.12.0
103
+ pydantic_core==2.41.5
104
+ pydeck==0.9.1
105
+ Pygments==2.19.2
106
+ PyMuPDF==1.26.7
107
+ pyparsing==3.3.1
108
+ pypdf==6.5.0
109
+ PyPika==0.48.9
110
+ pyproject_hooks==1.2.0
111
+ pyreadline3==3.5.4
112
+ pytest==9.0.2
113
+ python-dateutil==2.9.0.post0
114
+ python-dotenv==1.2.1
115
+ python-multipart==0.0.21
116
+ pytz==2025.2
117
+ PyYAML==6.0.3
118
+ referencing==0.37.0
119
+ regex==2025.11.3
120
+ requests==2.32.5
121
+ requests-oauthlib==2.0.0
122
+ requests-toolbelt==1.0.0
123
+ rich==14.2.0
124
+ rpds-py==0.30.0
125
+ rsa==4.9.1
126
+ safetensors==0.7.0
127
+ scikit-learn==1.8.0
128
+ scipy==1.16.3
129
+ sentence-transformers==5.2.0
130
+ setuptools==80.9.0
131
+ shellingham==1.5.4
132
+ six==1.17.0
133
+ smmap==5.0.2
134
+ sniffio==1.3.1
135
+ SQLAlchemy==2.0.45
136
+ sqlmodel==0.0.31
137
+ starlette==0.50.0
138
+ streamlit==1.52.2
139
+ sympy==1.14.0
140
+ tenacity==9.1.2
141
+ threadpoolctl==3.6.0
142
+ tiktoken==0.12.0
143
+ tokenizers==0.22.2
144
+ toml==0.10.2
145
+ torch==2.9.1
146
+ tornado==6.5.4
147
+ tqdm==4.67.1
148
+ transformers==4.57.3
149
+ typer==0.21.1
150
+ typing-inspection==0.4.2
151
+ typing_extensions==4.15.0
152
+ tzdata==2025.3
153
+ uritemplate==4.2.0
154
+ urllib3==2.3.0
155
+ uuid_utils==0.12.0
156
+ uvicorn==0.40.0
157
+ watchdog==6.0.0
158
+ watchfiles==1.1.1
159
+ websocket-client==1.9.0
160
+ websockets==15.0.1
161
+ xxhash==3.6.0
162
+ zipp==3.23.0
163
+ zstandard==0.25.0
backend/services/resume_parser.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fitz # PyMuPDF
2
+ from fastapi import UploadFile
3
+
4
+ async def parse_resume(file: UploadFile) -> str:
5
+ """
6
+ Extracts text from a PDF file.
7
+ """
8
+ try:
9
+ content = await file.read()
10
+ doc = fitz.open(stream=content, filetype="pdf")
11
+ text = ""
12
+ for page in doc:
13
+ text += page.get_text()
14
+ return text
15
+ except Exception as e:
16
+ print(f"Error parsing resume: {e}")
17
+ return ""
backend/services/vector_store.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from chromadb.utils import embedding_functions
3
+ import os
4
+
5
+ # Initialize ChromaDB Client
6
+ # PersistentClient saves data to disk
7
+ chroma_client = chromadb.PersistentClient(path="./data/chroma_db")
8
+
9
+ # Use Google Generative AI Embeddings
10
+ def get_embedding_function(api_key):
11
+ return embedding_functions.GoogleGenerativeAiEmbeddingFunction(api_key=api_key)
12
+
13
+ def get_collection(name: str, api_key: str):
14
+ return chroma_client.get_or_create_collection(
15
+ name=name,
16
+ embedding_function=get_embedding_function(api_key)
17
+ )
18
+
19
+ def add_documents(collection_name: str, documents: list, metadatas: list, ids: list, api_key: str):
20
+ collection = get_collection(collection_name, api_key)
21
+ collection.add(
22
+ documents=documents,
23
+ metadatas=metadatas,
24
+ ids=ids
25
+ )
26
+
27
+ def query_documents(collection_name: str, query_text: str, n_results: int, api_key: str):
28
+ collection = get_collection(collection_name, api_key)
29
+ return collection.query(
30
+ query_texts=[query_text],
31
+ n_results=n_results
32
+ )
backend/start.sh ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Install dependencies is handled by Render's build command usually, but we ensure uvicorn is ready.
3
+ # Run Database Init (if we had migrations, we would run them here)
4
+ # For now, just start the app
5
+ echo "Starting TalentTalk Pro Backend..."
6
+ python -m uvicorn app.main:app --host 0.0.0.0 --port $PORT
backend/talenttalk.db ADDED
File without changes
backend/test.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ This is a dummy resume content for testing purposes.
2
+ Skills: Python, FastAPI, AI.
3
+ Experience: 5 years at Tech Corp.
backend/tests/list_openrouter_models.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import os
3
+ import sys
4
+
5
+ sys.path.append(os.getcwd())
6
+ from app.core.config import settings
7
+
8
+ def list_models():
9
+ print("Fetching OpenRouter models...")
10
+ key = settings.OPENROUTER_API_KEY
11
+ if not key:
12
+ print("No OPENROUTER_API_KEY found.")
13
+ return
14
+
15
+ headers = {
16
+ "Authorization": f"Bearer {key}",
17
+ }
18
+
19
+ try:
20
+ response = requests.get("https://openrouter.ai/api/v1/models", headers=headers)
21
+ if response.status_code == 200:
22
+ data = response.json()
23
+ # Filter for google models
24
+ google_models = [m['id'] for m in data['data'] if 'google' in m['id']]
25
+ print("Available Google Models:")
26
+ for m in google_models:
27
+ print(m)
28
+ else:
29
+ print(f"Failed: {response.status_code} - {response.text}")
30
+ except Exception as e:
31
+ print(f"Error: {e}")
32
+
33
+ if __name__ == "__main__":
34
+ list_models()
backend/tests/test_chat_audio.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+ import os
4
+
5
+ API_URL = "http://localhost:8000/api/v1"
6
+
7
+ def create_dummy_wav(filename="test_audio.wav"):
8
+ # Create a minimal valid WAV file header
9
+ import wave
10
+ import struct
11
+ with wave.open(filename, 'w') as wav_file:
12
+ wav_file.setnchannels(1)
13
+ wav_file.setsampwidth(2)
14
+ wav_file.setframerate(44100)
15
+ # Write 1 second of silence
16
+ data = struct.pack('<h', 0) * 44100
17
+ wav_file.writeframes(data)
18
+
19
+ def test_chat_audio():
20
+ create_dummy_wav()
21
+
22
+ # 1. Start Interview
23
+ print("Starting Interview...")
24
+ start_res = requests.post(f"{API_URL}/start", json={
25
+ "target_company": "Google",
26
+ "job_role": "Python Dev",
27
+ "interview_style": "Professional",
28
+ "difficulty": "Medium"
29
+ })
30
+
31
+ session_id = start_res.json()["session_id"]
32
+ print(f"Session ID: {session_id}")
33
+
34
+ # 2. Send Audio Message
35
+ print("Sending Audio Message...")
36
+
37
+ # We must send session_id as data, and file in files
38
+ data = {"session_id": session_id}
39
+ files = {"audio_file": ("test_audio.wav", open("test_audio.wav", "rb"), "audio/wav")}
40
+
41
+ try:
42
+ chat_res = requests.post(f"{API_URL}/chat", data=data, files=files)
43
+ print(f"Status: {chat_res.status_code}")
44
+ print(f"Response: {chat_res.text}")
45
+ except Exception as e:
46
+ print(f"Request Error: {e}")
47
+
48
+ if __name__ == "__main__":
49
+ test_chat_audio()
backend/tests/test_chat_error.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+
4
+ API_URL = "http://localhost:8000/api/v1"
5
+
6
+ def test_chat():
7
+ # 1. Start Interview
8
+ print("Starting Interview...")
9
+ start_res = requests.post(f"{API_URL}/start", json={
10
+ "target_company": "Google",
11
+ "job_role": "Python Dev",
12
+ "interview_style": "Professional",
13
+ "difficulty": "Medium"
14
+ })
15
+
16
+ if start_res.status_code != 200:
17
+ print(f"Start failed: {start_res.text}")
18
+ return
19
+
20
+ session_id = start_res.json()["session_id"]
21
+ print(f"Session ID: {session_id}")
22
+
23
+ # 2. Send Chat Message
24
+ print("Sending Chat Message...")
25
+ chat_payload = {
26
+ "session_id": session_id,
27
+ "text_input": "I have 5 years of experience with Python."
28
+ }
29
+
30
+ try:
31
+ chat_res = requests.post(f"{API_URL}/chat", data=chat_payload)
32
+ print(f"Status: {chat_res.status_code}")
33
+ print(f"Response: {chat_res.text}")
34
+ except Exception as e:
35
+ print(f"Request Error: {e}")
36
+
37
+ if __name__ == "__main__":
38
+ test_chat()
backend/tests/test_db_connection.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import asyncio
3
+ from sqlalchemy import text
4
+ from app.db.database import engine
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_database_connection():
8
+ try:
9
+ async with engine.connect() as conn:
10
+ result = await conn.execute(text("SELECT 1"))
11
+ assert result.scalar() == 1
12
+ print("Database connection successful!")
13
+ except Exception as e:
14
+ pytest.fail(f"Database connection failed: {e}")
15
+
16
+ if __name__ == "__main__":
17
+ asyncio.run(test_database_connection())
backend/tests/test_followup_logic.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import time
3
+
4
+ API_URL = "http://localhost:8000/api/v1"
5
+
6
+ def test_followup_flow():
7
+ print("🧪 Testing Follow-up Logic...")
8
+
9
+ # 1. Start Interview with max_follow_ups = 1
10
+ print("\n1. Starting Session (max_follow_ups=1)...")
11
+ payload = {
12
+ "target_company": "TestCorp",
13
+ "job_role": "Tester",
14
+ "interview_style": "Professional",
15
+ "difficulty": "Easy",
16
+ "max_follow_ups": 1
17
+ }
18
+
19
+ try:
20
+ res = requests.post(f"{API_URL}/start", json=payload)
21
+ res.raise_for_status()
22
+ data = res.json()
23
+ session_id = data["session_id"]
24
+ q1 = data["first_question"]
25
+ print(f"✅ Started. Session: {session_id}")
26
+ print(f" Q1: {q1}")
27
+ except Exception as e:
28
+ print(f"❌ Failed to start: {e}")
29
+ return
30
+
31
+ # 2. Answer Q1 -> Expect Follow-up
32
+ print("\n2. Answering Q1 (Expect Follow-up)...")
33
+ chat_payload = {"session_id": session_id, "text_input": "I use print statements for debugging."}
34
+
35
+ try:
36
+ res = requests.post(f"{API_URL}/chat", data=chat_payload)
37
+ res.raise_for_status()
38
+ data = res.json()
39
+
40
+ reply = data.get("question", "")
41
+ print(f" AI Reply: {reply}")
42
+
43
+ # We can't strictly know if it's a follow-up by text, but based on logic flow it should be.
44
+ # Check feedback to confirm analysis happened
45
+ if data.get("feedback"):
46
+ print(f" Feedback: {data['feedback']}")
47
+
48
+ print("✅ Received response.")
49
+ except Exception as e:
50
+ print(f"❌ Failed Step 2: {e}")
51
+ return
52
+
53
+ # 3. Answer Follow-up -> Expect Q2
54
+ print("\n3. Answering Follow-up (Expect Q2)...")
55
+ chat_payload = {"session_id": session_id, "text_input": "I also use logging sometimes."}
56
+
57
+ try:
58
+ res = requests.post(f"{API_URL}/chat", data=chat_payload)
59
+ res.raise_for_status()
60
+ data = res.json()
61
+
62
+ reply = data.get("question", "")
63
+ print(f" AI Reply: {reply}")
64
+ print("✅ Received response (Should be Q2).")
65
+
66
+ except Exception as e:
67
+ print(f"❌ Failed Step 3: {e}")
68
+ return
69
+
70
+ print("\n🎉 Logic Flow Test Complete.")
71
+
72
+ if __name__ == "__main__":
73
+ # Wait for server to be ready
74
+ time.sleep(3)
75
+ test_followup_flow()
backend/tests/test_gemini_direct.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ # Add backend to path
6
+ sys.path.append(os.getcwd())
7
+
8
+ from app.core.config import settings
9
+ from langchain_google_genai import ChatGoogleGenerativeAI
10
+
11
+ async def test_gemini():
12
+ print("Testing Gemini Direct...")
13
+ print(f"API Key present: {bool(settings.GOOGLE_API_KEY)}")
14
+
15
+ llm = ChatGoogleGenerativeAI(
16
+ model="gemini-pro",
17
+ google_api_key=settings.GOOGLE_API_KEY,
18
+ temperature=0.7
19
+ )
20
+
21
+ print("Trying gemini-pro...")
22
+
23
+ try:
24
+ response = await llm.ainvoke("Hello, this is a test.")
25
+ print(f"Response: {response.content}")
26
+ except Exception as e:
27
+ print(f"Gemini Failed: {e}")
28
+
29
+ if __name__ == "__main__":
30
+ asyncio.run(test_gemini())
backend/tests/test_genai_raw.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ import os
3
+ import sys
4
+
5
+ # Add backend to path
6
+ sys.path.append(os.getcwd())
7
+ from app.core.config import settings
8
+
9
+ def test_raw_genai():
10
+ print("Testing Raw GenAI...")
11
+ if not settings.GOOGLE_API_KEY:
12
+ print("No API Key found!")
13
+ return
14
+
15
+ genai.configure(api_key=settings.GOOGLE_API_KEY)
16
+
17
+ # Try listing models
18
+ print("Listing models...")
19
+ try:
20
+ for m in genai.list_models():
21
+ if 'generateContent' in m.supported_generation_methods:
22
+ print(m.name)
23
+ break
24
+ except Exception as e:
25
+ print(f"List Models Failed: {e}")
26
+
27
+ print(f"Loaded Key: {settings.GOOGLE_API_KEY[:5]}...{settings.GOOGLE_API_KEY[-5:]}")
28
+
29
+ # Try generation
30
+ print("Generating content with gemini-pro...")
31
+ try:
32
+ model = genai.GenerativeModel('gemini-pro')
33
+ response = model.generate_content("Hello")
34
+ print(f"Response Success: {response.text[:20]}...")
35
+ except Exception as e:
36
+ print(f"Generation Failed: {e}")
37
+ # Print full type of error
38
+ print(type(e))
39
+
40
+ if __name__ == "__main__":
41
+ test_raw_genai()
backend/tests/test_report_generation.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ # Add backend to path (Correctly)
6
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ from app.services.gemini_service import gemini_service
9
+
10
+ async def test_report():
11
+ print("Testing Final Report Generation...")
12
+
13
+ # Dummy Interview Data
14
+ interview_data = [
15
+ {
16
+ "question": "Explain the difference between a List and a Tuple in Python.",
17
+ "answer": "A list is mutable, meaning you can change it. A tuple is immutable. Lists use square brackets, tuples use parentheses.",
18
+ "analysis": {"feedback": "Good basic definition.", "sentiment_score": 0.8}
19
+ },
20
+ {
21
+ "question": "What is a decorator?",
22
+ "answer": "I am not sure, I think it decorates a function?",
23
+ "analysis": {"feedback": "Vague answer.", "sentiment_score": 0.3}
24
+ }
25
+ ]
26
+
27
+ import json
28
+ data_str = json.dumps(interview_data, indent=2)
29
+
30
+ try:
31
+ report = await gemini_service.generate_final_report(
32
+ target_company="Google",
33
+ job_role="Senior Python Developer",
34
+ interview_data=data_str
35
+ )
36
+
37
+ print("\n--- GENERATED REPORT ---\n")
38
+ print(report)
39
+ print("\n------------------------\n")
40
+
41
+ # Validation
42
+ if "Full Interview Transcript" in report:
43
+ print("✅ Section Found: Full Interview Transcript")
44
+ else:
45
+ print("❌ MISSING: Full Interview Transcript")
46
+
47
+ if "Actionable Suggestions" in report:
48
+ print("✅ Section Found: Actionable Suggestions")
49
+ else:
50
+ print("❌ MISSING: Actionable Suggestions")
51
+
52
+ except Exception as e:
53
+ print(f"Error: {e}")
54
+
55
+ if __name__ == "__main__":
56
+ asyncio.run(test_report())
backend/tests/test_resume_error.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import io
3
+
4
+ API_URL = "http://localhost:8000/api/v1"
5
+
6
+ def test_resume_upload():
7
+ print("Testing Resume Upload...")
8
+
9
+ # Create a dummy PDF file in memory
10
+ pdf_content = b"%PDF-1.5\n%..."
11
+ # Real minimal PDF header/trailer is better to avoid pypdf error if it validates
12
+ # But for a 500 server error, it might be even earlier.
13
+ # Let's try to use a real small valid pdf structure or just text if pypdf is lenient.
14
+ # Actually, let's create a minimal valid PDF using reportlab or fpdf if installed?
15
+ # No, let's just use a dummy text file renamed as .pdf and see if pypdf handles exception gracefully
16
+ # If pypdf crashes on invalid pdf, that might be the 500.
17
+
18
+ dummy_pdf = io.BytesIO(b"This is a dummy pdf content")
19
+
20
+ files = {'resume_file': ('test_resume.pdf', dummy_pdf, 'application/pdf')}
21
+ data = {
22
+ "target_company": "Test Corp",
23
+ "job_role": "Tester",
24
+ "interview_style": "Professional",
25
+ "difficulty": "Easy"
26
+ }
27
+
28
+ try:
29
+ response = requests.post(f"{API_URL}/start_with_resume", files=files, data=data)
30
+ print(f"Status Code: {response.status_code}")
31
+ print("Response Body:")
32
+ print(response.text) # Print full text
33
+ with open("error_log.txt", "w", encoding="utf-8") as f:
34
+ f.write(response.text)
35
+ except Exception as e:
36
+ print(f"Request Failed: {e}")
37
+
38
+ if __name__ == "__main__":
39
+ test_resume_upload()
backend/tests/test_service_only.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from fastapi import UploadFile
3
+ import io
4
+ import sys
5
+ import os
6
+
7
+ # Add backend to path
8
+ sys.path.append(os.getcwd())
9
+
10
+ from app.services.resume_service import resume_service
11
+
12
+ async def test_service():
13
+ print("Testing Resume Service Isolation...")
14
+ dummy_content = b"Draft Resume.\nName: John Doe.\nSkills: Python."
15
+ file_obj = io.BytesIO(dummy_content)
16
+ # UploadFile expects a 'file' attribute or we can mock it
17
+ # But wait, UploadFile has .read().
18
+ # Let's mock a class that behaves like UploadFile
19
+ class MockUploadFile:
20
+ filename = "test.pdf"
21
+ async def read(self):
22
+ return dummy_content
23
+
24
+ try:
25
+ text = await resume_service.extract_text(MockUploadFile())
26
+ print(f"Extraction Result: {text}")
27
+ except Exception as e:
28
+ print(f"Service Execution Failed: {e}")
29
+ import traceback
30
+ traceback.print_exc()
31
+
32
+ if __name__ == "__main__":
33
+ asyncio.run(test_service())
backend/tests/test_standard_start.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+
3
+ API_URL = "http://localhost:8000/api/v1"
4
+
5
+ def test_start():
6
+ print("Testing Standard Start...")
7
+ payload = {
8
+ "target_company": "Google",
9
+ "job_role": "Engineer",
10
+ "interview_style": "Professional",
11
+ "difficulty": "Medium"
12
+ }
13
+
14
+ try:
15
+ response = requests.post(f"{API_URL}/start", json=payload)
16
+ print(f"Status Code: {response.status_code}")
17
+ print(f"Response: {response.text}")
18
+ except Exception as e:
19
+ print(f"Request Failed: {e}")
20
+
21
+ if __name__ == "__main__":
22
+ test_start()
backend/tests/test_video_analysis.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import os
3
+
4
+ API_URL = "http://localhost:8000/api/v1"
5
+
6
+ def test_video_analysis():
7
+ print("Testing Video Analysis Endpoint...")
8
+
9
+ # Check if a dummy video exists, if not create a tiny dummy file
10
+ video_path = "dummy_video.mp4"
11
+ # Create dummy file if missing
12
+ if not os.path.exists(video_path):
13
+ with open(video_path, "wb") as f:
14
+ f.write(b"fake video content")
15
+
16
+ try:
17
+ # Open file in a with block to ensure it's closed before deletion cleanup
18
+ with open(video_path, 'rb') as f:
19
+ files = {'video_file': (video_path, f, 'video/mp4')}
20
+ response = requests.post(f"{API_URL}/analyze_video", files=files)
21
+
22
+ print(f"Status Code: {response.status_code}")
23
+ print(f"Response: {response.text}")
24
+
25
+ if response.status_code == 200:
26
+ print("✅ Video Analysis Endpoint reachable.")
27
+ else:
28
+ # It might fail analysis content-wise (fake video), but 500 or 200 is "handled"
29
+ # If API key is missing, it returns specific message
30
+ print(f"ℹ️ Endpoint Responded (Might be error from Gemini): {response.status_code}")
31
+
32
+ except Exception as e:
33
+ print(f"Error: {e}")
34
+ finally:
35
+ # Cleanup
36
+ if os.path.exists(video_path):
37
+ try:
38
+ os.remove(video_path)
39
+ except:
40
+ pass
41
+
42
+ if __name__ == "__main__":
43
+ test_video_analysis()
backend/tests/test_workflow.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ # Ensure we can find the app module
6
+ import sys
7
+ sys.path.append(os.path.join(os.getcwd(), '..'))
8
+
9
+ load_dotenv()
10
+
11
+ from app.agents.interview_graph import workflow, InterviewState
12
+ from langchain_core.messages import HumanMessage
13
+
14
+ async def simulate_interview():
15
+ print("--- Starting Simulation ---")
16
+
17
+ # Initialize State
18
+ initial_state = {
19
+ "messages": [],
20
+ "history": [],
21
+ "current_question": None,
22
+ "current_question_num": 0,
23
+ "total_questions": 2, # Short for testing
24
+ "target_company": "Google",
25
+ "interview_style": "Visual, Friendly", # Test new style
26
+ "job_role": "Senior Python Engineer",
27
+ "difficulty": "Medium",
28
+ "topic": "System Design",
29
+ "analysis_data": []
30
+ }
31
+
32
+ app = workflow.compile()
33
+
34
+ # 1. Generate First Question
35
+ print("\n[AI] Generating Q1...")
36
+ inputs = initial_state
37
+
38
+ # We run until the first interruption or completion
39
+ # Since we didn't add interrupts, we have to invoke nodes manually
40
+ # OR redefine the graph to interrupt.
41
+ # For this simulation, we will assume we can run step-by-step.
42
+
43
+ # Run the 'generate_question' node
44
+ result = await app.ainvoke(inputs)
45
+
46
+ # BUT, our graph has a loop: gen -> end. analyze -> route -> gen.
47
+ # The graph definition at the end:
48
+ # workflow.add_node("generate_question", generate_question_node)
49
+ # workflow.set_entry_point("generate_question")
50
+ # No edge from generate_question means it hits END.
51
+
52
+ # So `ainvoke` should run `generate_question` and stop.
53
+ state = result
54
+ print(f"\nAI: {state['current_question']}")
55
+
56
+ # 2. Simulate User Answer to Q1
57
+ answer1 = "I would design a distributed system using sharding and replication."
58
+ print(f"\nUser: {answer1}")
59
+
60
+ # Update state manually to inject answer (simulating API payload)
61
+ state["messages"].append(HumanMessage(content=answer1))
62
+
63
+ # 3. Analyze Answer 1
64
+ # We need to continue the graph.
65
+ # Since we hit END, we start a new run? No, that resets state.
66
+ # We should probably use `memory` (LangGraph checkpointer) if we want persistence.
67
+ # For now, let's treat the graph as a single-turn processor if possible,
68
+ # OR run separate nodes directly for testing.
69
+
70
+ # Let's run the 'analyze_answer' node directly on the current state
71
+ from app.agents.interview_graph import analyze_answer_node, route_interview, generate_question_node, generate_report_node
72
+
73
+ print("\n[AI] Analyzing Q1...")
74
+ state = await analyze_answer_node(state)
75
+ print("Feedback:", state["history"][-1])
76
+
77
+ # 4. Route
78
+ next_step = route_interview(state)
79
+ print(f"Next step: {next_step}")
80
+
81
+ if next_step == "generate_question":
82
+ print("\n[AI] Generating Q2...")
83
+ state = await generate_question_node(state)
84
+ print(f"\nAI: {state['current_question']}")
85
+
86
+ # 5. User Answer Q2
87
+ answer2 = "I'm not sure, maybe hash maps?"
88
+ print(f"\nUser: {answer2}")
89
+ state["messages"].append(HumanMessage(content=answer2))
90
+
91
+ print("\n[AI] Analyzing Q2...")
92
+ state = await analyze_answer_node(state)
93
+ print("Feedback:", state["history"][-1])
94
+
95
+ next_step = route_interview(state)
96
+ print(f"Next step: {next_step}")
97
+
98
+ if next_step == "generate_report":
99
+ print("\n[AI] Generating Report...")
100
+ state = await generate_report_node(state)
101
+ print("\n--- FINAL REPORT ---\n")
102
+ print(state["final_report"])
103
+
104
+ if __name__ == "__main__":
105
+ asyncio.run(simulate_interview())
frontend/app.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import json
4
+ import time
5
+
6
+ import os
7
+
8
+ # Configuration
9
+ API_URL = "http://localhost:8000/api/v1"
10
+
11
+ # Try to get from Streamlit Secrets (Cloud)
12
+ try:
13
+ if "API_URL" in st.secrets:
14
+ API_URL = st.secrets["API_URL"]
15
+ except FileNotFoundError:
16
+ pass
17
+ except Exception:
18
+ pass
19
+
20
+ # Try OS Env (Docker/Render) - Overrides default
21
+ if "API_URL" in os.environ:
22
+ API_URL = os.environ["API_URL"]
23
+
24
+ st.set_page_config(
25
+ page_title="TalentTalk Pro",
26
+ page_icon="🎙️",
27
+ layout="wide",
28
+ initial_sidebar_state="expanded"
29
+ )
30
+
31
+ # Custom CSS
32
+ st.markdown("""
33
+ <style>
34
+ .main {
35
+ background-color: #f5f7f9;
36
+ }
37
+ .stChatMessage {
38
+ padding: 1rem;
39
+ border-radius: 0.5rem;
40
+ margin-bottom: 1rem;
41
+ }
42
+ .user-message {
43
+ background-color: #e3f2fd;
44
+ }
45
+ .ai-message {
46
+ background-color: #ffffff;
47
+ border: 1px solid #e0e0e0;
48
+ }
49
+ .stButton>button {
50
+ width: 100%;
51
+ border-radius: 20px;
52
+ }
53
+ </style>
54
+ """, unsafe_allow_html=True)
55
+
56
+ # Session State Initialization
57
+ if "session_id" not in st.session_state:
58
+ st.session_state.session_id = None
59
+ if "messages" not in st.session_state:
60
+ st.session_state.messages = []
61
+ if "interview_active" not in st.session_state:
62
+ st.session_state.interview_active = False
63
+
64
+ def start_interview(company, role, style, difficulty, max_follow_ups):
65
+ payload = {
66
+ "target_company": company,
67
+ "job_role": role,
68
+ "interview_style": style,
69
+ "difficulty": difficulty,
70
+ "max_follow_ups": max_follow_ups
71
+ }
72
+ try:
73
+ response = requests.post(f"{API_URL}/start", json=payload)
74
+ response.raise_for_status()
75
+ data = response.json()
76
+
77
+ st.session_state.session_id = data["session_id"]
78
+ st.session_state.interview_active = True
79
+ st.session_state.messages = []
80
+
81
+ # Add AI greeting
82
+ st.session_state.messages.append({"role": "assistant", "content": data["first_question"]})
83
+ st.rerun()
84
+ except Exception as e:
85
+ st.error(f"Failed to start interview: {e}")
86
+
87
+ def send_response(text_input, audio_file=None):
88
+ if not st.session_state.session_id:
89
+ return
90
+
91
+ # Add user message to UI immediately for responsiveness
92
+ if text_input:
93
+ st.session_state.messages.append({"role": "user", "content": text_input})
94
+ elif audio_file:
95
+ st.session_state.messages.append({"role": "user", "content": "🎤 Audio Response Sent"})
96
+
97
+ with st.spinner("Interviewer is thinking..."):
98
+ try:
99
+ files = None
100
+ data = {"session_id": st.session_state.session_id}
101
+
102
+ if audio_file:
103
+ files = {"audio_file": ("answer.wav", audio_file, "audio/wav")}
104
+ if text_input:
105
+ data["text_input"] = text_input
106
+
107
+ response = requests.post(f"{API_URL}/chat", data=data, files=files)
108
+ response.raise_for_status()
109
+ result = response.json()
110
+
111
+ # Update User Message with Transcript
112
+ if result.get("user_transcript"):
113
+ # If the last message was the placeholder, update it
114
+ if st.session_state.messages and st.session_state.messages[-1]["role"] == "user":
115
+ st.session_state.messages[-1]["content"] = f"🎤 {result['user_transcript']}"
116
+
117
+ # Display Feedback from Interviewer
118
+ if result.get("feedback"):
119
+ feedback_data = result["feedback"]
120
+ # feedback_data is likely a dict or string depending on Gemini's JSON output
121
+ # Let's extract a friendly message.
122
+ feedback_text = ""
123
+ if isinstance(feedback_data, dict):
124
+ feedback_text = feedback_data.get("feedback", "")
125
+ else:
126
+ feedback_text = str(feedback_data)
127
+
128
+ if feedback_text:
129
+ st.session_state.messages.append({"role": "assistant", "content": f"**Feedback:** {feedback_text}"})
130
+
131
+ if result.get("question"):
132
+ st.session_state.messages.append({"role": "assistant", "content": result["question"], "audio_url": result.get("audio_url")})
133
+
134
+ if result.get("is_finished"):
135
+ st.session_state.interview_active = False
136
+ st.session_state.messages.append({"role": "system", "content": "Interview Complete. Generating Report..."})
137
+ # Fetch Report
138
+ report_res = requests.get(f"{API_URL}/report/{st.session_state.session_id}")
139
+ if report_res.status_code == 200:
140
+ report_data = report_res.json()
141
+ st.session_state.final_report = report_data.get("report")
142
+
143
+ st.rerun()
144
+
145
+ except requests.exceptions.HTTPError as err:
146
+ # Try to get detailed error from backend
147
+ try:
148
+ error_detail = err.response.json().get("detail", err.response.text)
149
+ st.error(f"Backend Error: {error_detail}")
150
+ except:
151
+ st.error(f"HTTP Error: {err}")
152
+ except Exception as e:
153
+ st.error(f"Error sending message: {e}")
154
+
155
+ # --- Sidebar ---
156
+ with st.sidebar:
157
+ st.title("TalentTalk Pro 🚀")
158
+ st.header("Setup Interview")
159
+
160
+ target_company = st.text_input("Target Company", "Google")
161
+ job_role = st.text_input("Job Role", "Senior Python Developer")
162
+
163
+ col1, col2 = st.columns(2)
164
+ with col1:
165
+ difficulty = st.selectbox("Difficulty", ["Easy", "Medium", "Hard"])
166
+ with col2:
167
+ style = st.selectbox("Style", ["Professional", "Friendly", "HR", "Technical"])
168
+
169
+ # Follow-up Depth Slider
170
+ max_follow_ups = st.slider("Step-by-step Follow-ups (Depth)", 0, 3, 1,
171
+ help="How many follow-up questions to ask per main topic.")
172
+
173
+ # Resume Upload
174
+ resume_file = st.file_uploader("📄 Upload Resume (PDF)", type=["pdf"])
175
+
176
+ if not st.session_state.interview_active:
177
+ if st.button("Start Interview", type="primary"):
178
+ if resume_file:
179
+ # Start with resume
180
+ try:
181
+ with st.spinner("Analyzing Resume..."):
182
+ files = {"resume_file": ("resume.pdf", resume_file, "application/pdf")}
183
+ data = {
184
+ "target_company": target_company,
185
+ "job_role": job_role,
186
+ "interview_style": style,
187
+ "job_role": job_role,
188
+ "interview_style": style,
189
+ "difficulty": difficulty,
190
+ "max_follow_ups": max_follow_ups
191
+ }
192
+ response = requests.post(f"{API_URL}/start_with_resume", data=data, files=files)
193
+ response.raise_for_status()
194
+ res_data = response.json()
195
+
196
+ st.session_state.session_id = res_data["session_id"]
197
+ st.session_state.interview_active = True
198
+ st.session_state.messages = [{"role": "assistant", "content": res_data["first_question"]}]
199
+ st.rerun()
200
+ except requests.exceptions.HTTPError as e:
201
+ error_msg = "Unknown Error"
202
+ try:
203
+ error_msg = e.response.json().get("detail", str(e))
204
+ except:
205
+ error_msg = str(e)
206
+ st.error(f"Failed to start with resume: {error_msg}")
207
+ except Exception as e:
208
+ st.error(f"Failed to start with resume: {e}")
209
+ else:
210
+ # Standard Start
211
+ start_interview(target_company, job_role, style, difficulty, max_follow_ups)
212
+ else:
213
+ if st.button("End Interview", type="secondary"):
214
+ st.session_state.interview_active = False
215
+ st.rerun()
216
+
217
+ st.markdown("---")
218
+ if st.session_state.get("final_report"):
219
+ st.success("Report Generated!")
220
+ with st.expander("View Final Report", expanded=True):
221
+ st.markdown(st.session_state.final_report)
222
+
223
+ # --- Main Interaction Area ---
224
+
225
+ st.title("AI Interview Session")
226
+
227
+ # Chat Container
228
+ chat_container = st.container()
229
+
230
+ with chat_container:
231
+ for msg in st.session_state.messages:
232
+ with st.chat_message(msg["role"]):
233
+ st.write(msg["content"])
234
+ if msg.get("audio_url"):
235
+ # Construct full URL - ensure backend port is reachable
236
+ # In docker/prod this needs proper handling.
237
+ # For local: http://localhost:8000 + url
238
+ audio_full_url = f"http://localhost:8000{msg['audio_url']}"
239
+ st.audio(audio_full_url, autoplay=True)
240
+
241
+ # Input Area (Fixed at bottom)
242
+ if st.session_state.interview_active:
243
+ st.markdown("---")
244
+ col_text, col_audio = st.columns([0.8, 0.2])
245
+
246
+ with col_text:
247
+ text_input = st.chat_input("Type your answer here...")
248
+ if text_input:
249
+ send_response(text_input)
250
+
251
+ with col_audio:
252
+ # Media Uploader
253
+ media_type = st.radio("Input Type", ["Audio", "Video"], horizontal=True, label_visibility="collapsed")
254
+
255
+ if media_type == "Audio":
256
+ # Using native Streamlit audio input (requires Streamlit 1.40+)
257
+ audio_value = st.audio_input("🎤 Record your answer")
258
+ if audio_value:
259
+ # Automatically send when recording stops? Or require button?
260
+ # st.audio_input returns a file-like object.
261
+ if st.button("Send Audio Answer", type="primary"):
262
+ send_response(None, audio_value)
263
+ else:
264
+ uploaded_video = st.file_uploader("📹 Upload Video", type=["mp4", "mov"], key="video_uploader")
265
+ if uploaded_video:
266
+ if st.button("Analyze Video Behavior"):
267
+ with st.spinner("Analyzing Video..."):
268
+ try:
269
+ video_files = {"video_file": ("video.mp4", uploaded_video, "video/mp4")}
270
+ res = requests.post(f"{API_URL}/analyze_video", files=video_files)
271
+ if res.status_code == 200:
272
+ analysis = res.json().get("analysis")
273
+ st.success("Video Analyzed!")
274
+ st.info(analysis)
275
+ # Append to chat as system note
276
+ st.session_state.messages.append({"role": "system", "content": f"**Video Analysis:** {analysis}"})
277
+ else:
278
+ st.error("Analysis Failed")
279
+ except Exception as e:
280
+ st.error(f"Error: {e}")
281
+
282
+ elif st.session_state.get("final_report"):
283
+ st.balloons()
284
+ st.markdown("## 📊 Interview Performance Report")
285
+ st.markdown(st.session_state.final_report)
frontend/requirements.txt ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ aiosqlite==0.22.1
2
+ altair==6.0.0
3
+ annotated-doc==0.0.4
4
+ annotated-types==0.7.0
5
+ anyio==4.12.1
6
+ assemblyai==0.48.4
7
+ asyncpg==0.31.0
8
+ attrs==25.4.0
9
+ backoff==2.2.1
10
+ bcrypt==5.0.0
11
+ blinker==1.9.0
12
+ build==1.3.0
13
+ cachetools==6.2.4
14
+ certifi==2026.1.4
15
+ charset-normalizer==3.4.4
16
+ chromadb==1.4.0
17
+ click==8.1.8
18
+ colorama==0.4.6
19
+ coloredlogs==15.0.1
20
+ distro==1.9.0
21
+ durationpy==0.10
22
+ elevenlabs==2.28.0
23
+ fastapi==0.128.0
24
+ filelock==3.20.2
25
+ filetype==1.2.0
26
+ flatbuffers==25.12.19
27
+ fsspec==2025.12.0
28
+ gitdb==4.0.12
29
+ GitPython==3.1.46
30
+ google-ai-generativelanguage==0.6.15
31
+ google-api-core==2.28.1
32
+ google-api-python-client==2.187.0
33
+ google-auth==2.47.0
34
+ google-auth-httplib2==0.3.0
35
+ google-genai==1.57.0
36
+ google-generativeai==0.8.6
37
+ googleapis-common-protos==1.72.0
38
+ greenlet==3.3.0
39
+ grpcio==1.76.0
40
+ grpcio-status==1.71.2
41
+ gTTS==2.5.4
42
+ h11==0.16.0
43
+ httpcore==1.0.9
44
+ httplib2==0.31.0
45
+ httptools==0.7.1
46
+ httpx==0.28.1
47
+ huggingface-hub==0.36.0
48
+ humanfriendly==10.0
49
+ idna==3.11
50
+ importlib_metadata==8.7.1
51
+ importlib_resources==6.5.2
52
+ iniconfig==2.3.0
53
+ Jinja2==3.1.6
54
+ jiter==0.12.0
55
+ joblib==1.5.3
56
+ jsonpatch==1.33
57
+ jsonpointer==3.0.0
58
+ jsonschema==4.26.0
59
+ jsonschema-specifications==2025.9.1
60
+ kubernetes==34.1.0
61
+ langchain==1.2.2
62
+ langchain-core==1.2.6
63
+ langchain-google-genai==4.1.3
64
+ langchain-openai==1.1.7
65
+ langgraph==1.0.5
66
+ langgraph-checkpoint==3.0.1
67
+ langgraph-prebuilt==1.0.5
68
+ langgraph-sdk==0.3.1
69
+ langsmith==0.6.1
70
+ markdown-it-py==4.0.0
71
+ MarkupSafe==3.0.3
72
+ mdurl==0.1.2
73
+ mmh3==5.2.0
74
+ mpmath==1.3.0
75
+ narwhals==2.15.0
76
+ networkx==3.6.1
77
+ numpy==2.4.0
78
+ oauthlib==3.3.1
79
+ onnxruntime==1.23.2
80
+ openai==2.14.0
81
+ opentelemetry-api==1.39.1
82
+ opentelemetry-exporter-otlp-proto-common==1.39.1
83
+ opentelemetry-exporter-otlp-proto-grpc==1.39.1
84
+ opentelemetry-proto==1.39.1
85
+ opentelemetry-sdk==1.39.1
86
+ opentelemetry-semantic-conventions==0.60b1
87
+ orjson==3.11.5
88
+ ormsgpack==1.12.1
89
+ overrides==7.7.0
90
+ packaging==25.0
91
+ pandas==2.3.3
92
+ pillow==12.1.0
93
+ pluggy==1.6.0
94
+ posthog==5.4.0
95
+ proto-plus==1.27.0
96
+ protobuf==5.29.5
97
+ pyarrow==22.0.0
98
+ pyasn1==0.6.1
99
+ pyasn1_modules==0.4.2
100
+ pybase64==1.4.3
101
+ pydantic==2.12.5
102
+ pydantic-settings==2.12.0
103
+ pydantic_core==2.41.5
104
+ pydeck==0.9.1
105
+ Pygments==2.19.2
106
+ PyMuPDF==1.26.7
107
+ pyparsing==3.3.1
108
+ pypdf==6.5.0
109
+ PyPika==0.48.9
110
+ pyproject_hooks==1.2.0
111
+ pyreadline3==3.5.4
112
+ pytest==9.0.2
113
+ python-dateutil==2.9.0.post0
114
+ python-dotenv==1.2.1
115
+ python-multipart==0.0.21
116
+ pytz==2025.2
117
+ PyYAML==6.0.3
118
+ referencing==0.37.0
119
+ regex==2025.11.3
120
+ requests==2.32.5
121
+ requests-oauthlib==2.0.0
122
+ requests-toolbelt==1.0.0
123
+ rich==14.2.0
124
+ rpds-py==0.30.0
125
+ rsa==4.9.1
126
+ safetensors==0.7.0
127
+ scikit-learn==1.8.0
128
+ scipy==1.16.3
129
+ sentence-transformers==5.2.0
130
+ setuptools==80.9.0
131
+ shellingham==1.5.4
132
+ six==1.17.0
133
+ smmap==5.0.2
134
+ sniffio==1.3.1
135
+ SQLAlchemy==2.0.45
136
+ sqlmodel==0.0.31
137
+ starlette==0.50.0
138
+ streamlit==1.52.2
139
+ sympy==1.14.0
140
+ tenacity==9.1.2
141
+ threadpoolctl==3.6.0
142
+ tiktoken==0.12.0
143
+ tokenizers==0.22.2
144
+ toml==0.10.2
145
+ torch==2.9.1
146
+ tornado==6.5.4
147
+ tqdm==4.67.1
148
+ transformers==4.57.3
149
+ typer==0.21.1
150
+ typing-inspection==0.4.2
151
+ typing_extensions==4.15.0
152
+ tzdata==2025.3
153
+ uritemplate==4.2.0
154
+ urllib3==2.3.0
155
+ uuid_utils==0.12.0
156
+ uvicorn==0.40.0
157
+ watchdog==6.0.0
158
+ watchfiles==1.1.1
159
+ websocket-client==1.9.0
160
+ websockets==15.0.1
161
+ xxhash==3.6.0
162
+ zipp==3.23.0
163
+ zstandard==0.25.0
render.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: talenttalk-backend
4
+ env: python
5
+ buildCommand: cd backend && pip install -r requirements.txt
6
+ startCommand: cd backend && bash start.sh
7
+ envVars:
8
+ - key: PYTHON_VERSION
9
+ value: 3.11.0
10
+ - key: OPENROUTER_API_KEY
11
+ fromGroup: talenttalk-secrets
12
+ - key: GOOGLE_API_KEY
13
+ fromGroup: talenttalk-secrets
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ streamlit
4
+ langgraph
5
+ langchain
6
+ langchain-google-genai
7
+ python-dotenv
8
+ pydantic
9
+ sqlmodel
10
+ chromadb
11
+ python-multipart
12
+ requests
13
+ google-generativeai
14
+ sentence_transformers
15
+ watchdog
run_backend.bat ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @echo off
2
+ call venv\Scripts\activate
3
+ uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
run_frontend.bat ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ @echo off
2
+ call venv\Scripts\activate
3
+ streamlit run frontend/app.py