Spaces:
Sleeping
Sleeping
Commit ·
3abfc90
0
Parent(s):
Refactor: Move project to root of repository
Browse files- .env.example +3 -0
- .gitignore +44 -0
- backend/agents/gemini_client.py +26 -0
- backend/agents/workflow.py +68 -0
- backend/api_routes.py +47 -0
- backend/app/agents/interview_graph.py +217 -0
- backend/app/api_routes.py +215 -0
- backend/app/core/config.py +43 -0
- backend/app/core/logging_config.py +43 -0
- backend/app/core/prompts.py +121 -0
- backend/app/db/database.py +29 -0
- backend/app/main.py +49 -0
- backend/app/models/models.py +77 -0
- backend/app/schemas.py +38 -0
- backend/app/services/gemini_service.py +161 -0
- backend/app/services/resume_service.py +31 -0
- backend/app/services/voice_service.py +123 -0
- backend/core/config.py +14 -0
- backend/core/database.py +17 -0
- backend/error_log.txt +1 -0
- backend/models/base.py +17 -0
- backend/models_list.txt +28 -0
- backend/output.txt +4 -0
- backend/requirements.txt +163 -0
- backend/services/resume_parser.py +17 -0
- backend/services/vector_store.py +32 -0
- backend/start.sh +6 -0
- backend/talenttalk.db +0 -0
- backend/test.pdf +3 -0
- backend/tests/list_openrouter_models.py +34 -0
- backend/tests/test_chat_audio.py +49 -0
- backend/tests/test_chat_error.py +38 -0
- backend/tests/test_db_connection.py +17 -0
- backend/tests/test_followup_logic.py +75 -0
- backend/tests/test_gemini_direct.py +30 -0
- backend/tests/test_genai_raw.py +41 -0
- backend/tests/test_report_generation.py +56 -0
- backend/tests/test_resume_error.py +39 -0
- backend/tests/test_service_only.py +33 -0
- backend/tests/test_standard_start.py +22 -0
- backend/tests/test_video_analysis.py +43 -0
- backend/tests/test_workflow.py +105 -0
- frontend/app.py +285 -0
- frontend/requirements.txt +163 -0
- render.yaml +13 -0
- requirements.txt +15 -0
- run_backend.bat +3 -0
- 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
|