Codingo / backend /services /interview_engine.py
husseinelsaadi
Fix interview scoring so strong answers score well
c3ec385
Raw
History Blame Contribute Delete
22.6 kB
import os
import json
import asyncio
import edge_tts
from faster_whisper import WhisperModel
from langchain_groq import ChatGroq
import logging
import tempfile
import shutil
import torch
from backend.services.interview_retrieval import (
extract_all_roles_from_qdrant,
retrieve_interview_data,
random_context_chunks,
get_role_questions, # 🔍 For sample questions
qdrant_client # 🔍 For collection info
)
try:
print("🔍 Qdrant Collections:", qdrant_client.get_collections())
info = qdrant_client.get_collection("interview_questions")
print("✅ Vector size:", info.config.params.vectors.size)
print("✅ Distance metric:", info.config.params.vectors.distance)
all_roles_debug = extract_all_roles_from_qdrant()
print(f"✅ Found {len(all_roles_debug)} roles:", all_roles_debug)
if all_roles_debug:
sample_questions_debug = get_role_questions(all_roles_debug[0])
print(f"✅ Sample questions for '{all_roles_debug[0]}': {len(sample_questions_debug)} found")
except Exception as e:
print("⚠️ Qdrant check failed:", e)
# Report GPU availability without assuming a GPU is present. Calling
# torch.cuda.get_device_name(0) on a CPU-only host raises and would crash
# the import (and therefore the whole app), so guard every GPU-only call.
if torch.cuda.is_available():
print("🔥 CUDA Available")
print("🧠 GPU:", torch.cuda.get_device_name(0))
print("💡 cuDNN version:", torch.backends.cudnn.version())
print("💥 cuDNN enabled:", torch.backends.cudnn.is_available())
else:
print("❌ CUDA Not Available — running on CPU")
# Initialize models
chat_groq_api = os.getenv("GROQ_API_KEY")
# Attempt to initialize the Groq LLM only if an API key is provided. When
# running in environments where the key is unavailable (such as local
# development or automated testing), fall back to a simple stub that
# generates generic responses. This avoids raising an exception at import
# time and allows the rest of the application to run without external
# dependencies. See the DummyGroq class defined below.
if chat_groq_api:
try:
groq_llm = ChatGroq(
temperature=0.7,
model_name="llama-3.3-70b-versatile",
api_key=chat_groq_api
)
except Exception as e:
logging.error(f"Error initializing ChatGroq LLM: {e}. Falling back to dummy model.")
groq_llm = None
else:
groq_llm = None
if groq_llm is None:
class DummyGroq:
"""A fallback language model used when no Groq API key is set.
The ``invoke`` method of this class returns a simple canned response
rather than calling an external API. This ensures that the
interview functionality still produces a sensible prompt, albeit
without advanced LLM behaviour.
"""
def invoke(self, prompt: str):
# Provide a very generic question based on the prompt. This
# implementation ignores the prompt contents entirely; in a more
# sophisticated fallback you could parse ``prompt`` to tailor
# responses.
return "Tell me about yourself and why you're interested in this position."
groq_llm = DummyGroq()
# Initialize Whisper model
#
# Loading the Whisper model can take several seconds on first use because the
# model weights must be downloaded from Hugging Face. This delay can cause
# the API call to ``/api/transcribe_audio`` to appear stuck while the model
# downloads. To mitigate this, we allow the model size to be configured via
# the ``WHISPER_MODEL_NAME`` environment variable and preload the model when
# this module is imported. Using a smaller model (e.g. "tiny" or "base.en")
# reduces download size and inference time considerably.
whisper_model = None
def load_whisper_model():
global whisper_model
if whisper_model is None:
try:
device = "cuda" if torch.cuda.is_available() else "cpu"
compute_type = "float16" if device == "cuda" else "int8"
# Allow overriding the model size via environment. Default to a
# lightweight model to improve startup times. Available options
# include: tiny, base, base.en, small, medium, large. See
# https://huggingface.co/ggerganov/whisper.cpp for details.
# Default to the English "small" model for noticeably better accuracy
# on technical vocabulary. Override with WHISPER_MODEL_NAME=base.en if
# transcription feels slow on the free CPU tier.
model_name = os.getenv("WHISPER_MODEL_NAME", "small.en")
whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
logging.info(f"Whisper model '{model_name}' loaded on {device} with {compute_type}")
except Exception as e:
logging.error(f"Error loading Whisper model: {e}")
# Fallback to CPU
whisper_model = WhisperModel(model_name if 'model_name' in locals() else "tiny", device="cpu", compute_type="int8")
return whisper_model
load_whisper_model()
def _most_recent_experience(profile):
"""Safely return the candidate's most recent experience entry, or ''.
``profile['experience']`` may be a list (possibly empty) or a string, so we
guard against IndexError which would otherwise drop the first question to a
canned fallback.
"""
exp = profile.get('experience')
if isinstance(exp, list):
return str(exp[0]).strip() if exp else ""
if isinstance(exp, str):
return exp.strip()
return ""
def _job_context(job):
"""Build a compact role-context block to inject into prompts."""
try:
skills = ", ".join(job.skills_list) if getattr(job, "skills_list", None) else ""
except Exception:
skills = ""
seniority = getattr(job, "seniority", "") or ""
description = (getattr(job, "description", "") or "").strip()
if len(description) > 600:
description = description[:600] + "…"
return (
f"- Role: {job.role} at {job.company}\n"
f"- Seniority: {seniority}\n"
f"- Required skills: {skills}\n"
f"- Role description: {description}"
)
def _format_history(conversation_history):
"""Render the running conversation into readable Q/A lines for the prompt.
Accepts either the list of interview_log dicts ({"question", "answer", ...})
or a plain string / list of strings, so callers can pass whatever they have.
"""
if not conversation_history:
return "(this is the first follow-up; no prior turns yet)"
if isinstance(conversation_history, str):
return conversation_history
lines = []
for i, turn in enumerate(conversation_history, 1):
if isinstance(turn, dict):
q = str(turn.get("question", "")).strip()
a = str(turn.get("answer", "")).strip()
if q or a:
lines.append(f"Q{i}: {q}\nA{i}: {a}")
else:
lines.append(str(turn).strip())
return "\n\n".join(lines) if lines else "(no prior turns yet)"
def generate_first_question(profile, job):
"""Generate the first interview question based on profile and job"""
all_roles = extract_all_roles_from_qdrant()
logging.info(f"[QDRANT DEBUG] Available Roles: {all_roles}")
retrieved_data = retrieve_interview_data(job.role.lower(), all_roles)
logging.info(f"[QDRANT DEBUG] Role requested: {job.role.lower()}")
logging.info(f"[QDRANT DEBUG] Questions retrieved: {len(retrieved_data)}")
if retrieved_data:
logging.info(f"[QDRANT DEBUG] Sample Q: {retrieved_data[0]['question']}")
else:
logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
recent_experience = _most_recent_experience(profile)
try:
prompt = f"""
You are LUNA, a warm, professional AI recruiter conducting a real interview.
Position context:
{_job_context(job)}
Candidate profile (from their CV):
- Skills: {profile.get('skills', [])}
- Experience: {profile.get('experience', [])}
- Education: {profile.get('education', [])}
Your task — write ONLY the opening line of the interview:
- Always begin with exactly: "Hi, how are you? I'm LUNA, your AI recruiter."
- Then warmly invite them to introduce themselves.
- If they have prior experience, reference their most recent role naturally
(most recent role: "{recent_experience}") and ask them to tell you about that
experience along with their background and education.
- If they have no prior experience, simply ask them to tell you about their
background, education, and what draws them to this {job.role} role.
- Keep it to 2-3 sentences, conversational and human.
Respond ONLY with the question text, no formatting or extra notes.
"""
response = groq_llm.invoke(prompt)
# Fix: Handle AIMessage object properly
if hasattr(response, 'content'):
question = response.content.strip()
elif isinstance(response, str):
question = response.strip()
else:
question = str(response).strip()
# Ensure we have a valid question
if not question or len(question) < 10:
question = "Tell me about yourself and why you're interested in this position."
logging.info(f"Generated question: {question}")
return question
except Exception as e:
logging.error(f"Error generating first question: {e}")
return "Tell me about yourself and why you're interested in this position."
def edge_tts_to_file_sync(text, output_path, voice="en-US-AriaNeural"):
"""Synchronous wrapper for edge-tts with better error handling"""
try:
# Ensure text is not empty
if not text or not text.strip():
logging.error("Empty text provided for TTS")
return None
# Ensure the directory exists and is writable
directory = os.path.dirname(output_path)
if not directory:
directory = "/tmp/audio"
output_path = os.path.join(directory, os.path.basename(output_path))
os.makedirs(directory, exist_ok=True)
# Test write permissions with a temporary file
test_file = os.path.join(directory, f"test_{os.getpid()}.tmp")
try:
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
logging.info(f"Directory {directory} is writable")
except (PermissionError, OSError) as e:
logging.error(f"Directory {directory} is not writable: {e}")
# Fallback to /tmp
directory = "/tmp/audio"
output_path = os.path.join(directory, os.path.basename(output_path))
os.makedirs(directory, exist_ok=True)
# Speak a little faster than the default so LUNA feels lively, not slow.
# Tunable via LUNA_TTS_RATE (e.g. "+10%", "+20%").
tts_rate = os.getenv("LUNA_TTS_RATE", "+15%")
async def generate_audio():
try:
communicate = edge_tts.Communicate(text, voice, rate=tts_rate)
await communicate.save(output_path)
logging.info(f"TTS audio saved to: {output_path}")
except Exception as e:
logging.error(f"Error in async TTS generation: {e}")
raise
# Run async function in sync context
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# If loop is already running, create a new one in a thread
import threading
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
new_loop.run_until_complete(generate_audio())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
future.result(timeout=30) # 30 second timeout
else:
loop.run_until_complete(generate_audio())
except RuntimeError:
# No event loop exists
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(generate_audio())
finally:
loop.close()
# Verify file was created and has content
if os.path.exists(output_path):
file_size = os.path.getsize(output_path)
if file_size > 1000: # At least 1KB for a valid audio file
logging.info(f"TTS file created successfully: {output_path} ({file_size} bytes)")
return output_path
else:
logging.error(f"TTS file is too small: {output_path} ({file_size} bytes)")
return None
else:
logging.error(f"TTS file was not created: {output_path}")
return None
except Exception as e:
logging.error(f"Error in TTS generation: {e}")
return None
def convert_webm_to_wav(webm_path, wav_path):
"""Convert WebM audio to WAV using ffmpeg if available"""
try:
import subprocess
result = subprocess.run([
'ffmpeg', '-i', webm_path, '-ar', '16000', '-ac', '1', '-y', wav_path
], capture_output=True, text=True, timeout=30)
if result.returncode == 0 and os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
logging.info(f"Successfully converted {webm_path} to {wav_path}")
return wav_path
else:
logging.error(f"FFmpeg conversion failed: {result.stderr}")
return None
except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e:
logging.error(f"Error converting audio: {e}")
return None
def generate_next_question(profile, job, conversation_history, last_answer):
"""Generate the next interview question based on profile, job, and conversation so far"""
all_roles = extract_all_roles_from_qdrant()
logging.info(f"[QDRANT DEBUG] Available Roles: {all_roles}")
retrieved_data = retrieve_interview_data(job.role.lower(), all_roles)
logging.info(f"[QDRANT DEBUG] Role requested: {job.role.lower()}")
logging.info(f"[QDRANT DEBUG] Questions retrieved: {len(retrieved_data)}")
if retrieved_data:
logging.info(f"[QDRANT DEBUG] Sample Next Q: {retrieved_data[0]['question']}")
else:
logging.warning("[QDRANT DEBUG] No questions retrieved, falling back to defaults")
context_data = random_context_chunks(retrieved_data, k=4) if retrieved_data else ""
history_text = _format_history(conversation_history)
try:
prompt = f"""
You are LUNA, a warm but sharp AI recruiter conducting a live interview. You behave like
a real human interviewer: you listen, react naturally, and keep the conversation flowing.
Position context:
{_job_context(job)}
Candidate's profile (from their CV):
- Skills: {profile.get('skills', [])}
- Experience: {profile.get('experience', [])}
- Education: {profile.get('education', [])}
Conversation so far (earlier questions and the candidate's answers):
{history_text}
The candidate just said:
"{last_answer}"
Example questions from this role's question bank (for inspiration on topic/difficulty — do NOT copy verbatim):
{context_data}
Write LUNA's next turn:
- Start with a brief, natural acknowledgement of their last answer (e.g. "That makes sense," "Great, thanks for sharing that.").
- Then ask exactly ONE focused follow-up question.
- Prefer SPECIFIC questions about the actual projects, responsibilities, and technologies in the
candidate's experience above (e.g. "You mentioned building X at Y — how did you handle Z?"),
rather than generic questions.
- Build on what they actually said and what's already been discussed — never repeat an earlier question.
- Stay anchored to the {job.role} role and its required skills; for technical roles, probe deeper into real skills/tools.
- Keep it concise, conversational, and human (1-2 sentences for the question).
Respond ONLY with LUNA's spoken text (acknowledgement + the one question), no labels or formatting.
"""
response = groq_llm.invoke(prompt)
if hasattr(response, 'content'):
question = response.content.strip()
elif isinstance(response, str):
question = response.strip()
else:
question = str(response).strip()
if not question or len(question) < 10:
question = "Could you elaborate more on your last point?"
logging.info(f"Generated next question: {question}")
return question
except Exception as e:
logging.error(f"Error generating next question: {e}")
return "Could you elaborate more on your last point?"
import subprocess # top of the file if not already imported
def whisper_stt(audio_path):
"""Speech-to-text using Faster-Whisper"""
try:
if not os.path.exists(audio_path) or os.path.getsize(audio_path) == 0:
logging.error(f"Audio file is empty or missing: {audio_path}")
return ""
# Convert webm to wav using ffmpeg
wav_path = audio_path.replace(".webm", ".wav")
cmd = [
"ffmpeg",
"-y", # overwrite
"-i", audio_path,
"-ar", "16000",
"-ac", "1",
"-f", "wav",
wav_path
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
logging.error(f"FFmpeg conversion failed or produced empty file: {wav_path}")
return ""
model = load_whisper_model()
# language="en" avoids misdetecting the language; vad_filter drops
# silence so Whisper doesn't hallucinate phrases on quiet gaps;
# condition_on_previous_text=False keeps each answer independent.
# initial_prompt biases decoding toward technical-interview vocabulary,
# which fixes homophone errors like "roles" -> "Rolls".
interview_vocab_prompt = (
"This is a technical job interview about software engineering, data science, "
"machine learning, AI, web development, cloud, and various job roles. "
"Common terms include Python, JavaScript, React, SQL, AWS, Docker, Kubernetes, "
"APIs, databases, algorithms, data roles, and frameworks."
)
segments, _ = model.transcribe(
wav_path,
language="en",
beam_size=5,
vad_filter=True,
condition_on_previous_text=False,
initial_prompt=interview_vocab_prompt,
)
transcript = " ".join(segment.text for segment in segments)
return transcript.strip()
except Exception as e:
logging.error(f"Error in STT: {e}")
return ""
def evaluate_answer(question, answer, job_role="Software Developer", seniority="Mid-level"):
"""Evaluate candidate's answer with better error handling"""
try:
if not answer or not answer.strip():
return {
"score": "Poor",
"feedback": "No answer provided."
}
prompt = f"""
You are LUNA, an experienced technical recruiter evaluating a candidate's spoken answer
for a {seniority} {job_role} position.
Question: {question}
Candidate Answer: {answer}
Rate the answer using this rubric:
- Excellent: correct, relevant and specific; shows clear understanding appropriate for a
{seniority} {job_role}. Small imperfections are fine.
- Good: mostly correct and relevant with reasonable detail, but missing some depth or specifics.
- Medium: partially correct or relevant, but vague, generic or incomplete.
- Poor: incorrect, off-topic, or no real answer.
Important:
- Judge the SUBSTANCE of the answer, not its grammar or wording — it was transcribed from
speech, so ignore filler words, punctuation and minor phrasing issues.
- A clear, correct, on-topic answer should be rated "Good" or "Excellent". Do NOT default to
"Medium" — use the full range and reward strong answers.
Respond in this exact format and nothing else:
Score: [Poor/Medium/Good/Excellent]
Feedback: [one or two specific sentences explaining the rating]
"""
response = groq_llm.invoke(prompt)
# Handle AIMessage object properly
if hasattr(response, 'content'):
response_text = response.content.strip()
elif isinstance(response, str):
response_text = response.strip()
else:
response_text = str(response).strip()
# Parse the response
lines = response_text.split('\n')
score = "Medium" # default
feedback = "Good answer, but could be more detailed." # default
for line in lines:
line = line.strip()
if line.startswith('Score:'):
score = line.replace('Score:', '').strip()
elif line.startswith('Feedback:'):
feedback = line.replace('Feedback:', '').strip()
# Ensure score is valid
valid_scores = ["Poor", "Medium", "Good", "Excellent"]
if score not in valid_scores:
score = "Medium"
return {
"score": score,
"feedback": feedback
}
except Exception as e:
logging.error(f"Error evaluating answer: {e}")
return {
"score": "Medium",
"feedback": "Unable to evaluate answer at this time."
}