import os
import torch
import numpy as np
import uuid
import requests
import time
import json
from pydub import AudioSegment
import wave
from nemo.collections.asr.models import EncDecSpeakerLabelModel
from pinecone import Pinecone, ServerlessSpec
import librosa
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.feature_extraction.text import TfidfVectorizer
import re
from typing import Dict, List, Tuple
import logging
import tempfile
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib import colors
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg')
from reportlab.platypus import Image
import io
from transformers import AutoTokenizer, AutoModel
import spacy
import google.generativeai as genai
import joblib
from concurrent.futures import ThreadPoolExecutor
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logging.getLogger("nemo_logging").setLevel(logging.INFO)
logging.getLogger("nemo").setLevel(logging.INFO)
# Configuration
AUDIO_DIR = "./Uploads"
OUTPUT_DIR = "./processed_audio"
os.makedirs(OUTPUT_DIR, exist_ok=True)
# API Keys
PINECONE_KEY = os.getenv("PINECONE_KEY")
ASSEMBLYAI_KEY = os.getenv("ASSEMBLYAI_KEY")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
def download_audio_from_url(url: str) -> str:
"""Downloads an audio file from a URL to a temporary local path."""
try:
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, f"{uuid.uuid4()}.tmp_audio")
logger.info(f"Downloading audio from {url} to {temp_path}")
with requests.get(url, stream=True) as r:
r.raise_for_status()
with open(temp_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return temp_path
except Exception as e:
logger.error(f"Failed to download audio from URL {url}: {e}")
raise
def initialize_services():
try:
pc = Pinecone(api_key=PINECONE_KEY)
index_name = "interview-speaker-embeddings"
if index_name not in pc.list_indexes().names():
pc.create_index(
name=index_name,
dimension=192,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
index = pc.Index(index_name)
genai.configure(api_key=GEMINI_API_KEY)
gemini_model = genai.GenerativeModel('gemini-1.5-flash')
return index, gemini_model
except Exception as e:
logger.error(f"Error initializing services: {str(e)}")
raise
index, gemini_model = initialize_services()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
logger.info(f"Using device: {device}")
def load_speaker_model():
try:
import torch
torch.set_num_threads(5)
model = EncDecSpeakerLabelModel.from_pretrained(
"nvidia/speakerverification_en_titanet_large",
map_location=torch.device('cpu')
)
model.eval()
return model
except Exception as e:
logger.error(f"Model loading failed: {str(e)}")
raise RuntimeError("Could not load speaker verification model")
def load_models():
speaker_model = load_speaker_model()
nlp = spacy.load("en_core_web_sm")
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
llm_model = AutoModel.from_pretrained("distilbert-base-uncased").to(device)
llm_model.eval()
return speaker_model, nlp, tokenizer, llm_model
speaker_model, nlp, tokenizer, llm_model = load_models()
def convert_to_wav(audio_path: str, output_dir: str = OUTPUT_DIR) -> str:
try:
audio = AudioSegment.from_file(audio_path)
if audio.channels > 1:
audio = audio.set_channels(1)
audio = audio.set_frame_rate(16000)
wav_file = os.path.join(output_dir, f"{uuid.uuid4()}.wav")
audio.export(wav_file, format="wav")
return wav_file
except Exception as e:
logger.error(f"Audio conversion failed: {str(e)}")
raise
def extract_prosodic_features(audio_path: str, start_ms: int, end_ms: int) -> Dict:
try:
audio = AudioSegment.from_file(audio_path)
segment = audio[start_ms:end_ms]
temp_path = os.path.join(OUTPUT_DIR, f"temp_{uuid.uuid4()}.wav")
segment.export(temp_path, format="wav")
y, sr = librosa.load(temp_path, sr=16000)
pitches = librosa.piptrack(y=y, sr=sr)[0]
pitches = pitches[pitches > 0]
features = {
'duration': (end_ms - start_ms) / 1000,
'mean_pitch': float(np.mean(pitches)) if len(pitches) > 0 else 0.0,
'min_pitch': float(np.min(pitches)) if len(pitches) > 0 else 0.0,
'max_pitch': float(np.max(pitches)) if len(pitches) > 0 else 0.0,
'pitch_sd': float(np.std(pitches)) if len(pitches) > 0 else 0.0,
'intensityMean': float(np.mean(librosa.feature.rms(y=y)[0])),
'intensityMin': float(np.min(librosa.feature.rms(y=y)[0])),
'intensityMax': float(np.max(librosa.feature.rms(y=y)[0])),
'intensitySD': float(np.std(librosa.feature.rms(y=y)[0])),
}
os.remove(temp_path)
return features
except Exception as e:
logger.error(f"Feature extraction failed: {str(e)}")
return {
'duration': (end_ms - start_ms) / 1000,
'mean_pitch': 0.0,
'min_pitch': 0.0,
'max_pitch': 0.0,
'pitch_sd': 0.0,
'intensityMean': 0.0,
'intensityMin': 0.0,
'intensityMax': 0.0,
'intensitySD': 0.0,
}
def transcribe(audio_path: str) -> Dict:
try:
with open(audio_path, 'rb') as f:
upload_response = requests.post(
"https://api.assemblyai.com/v2/upload",
headers={"authorization": ASSEMBLYAI_KEY},
data=f
)
audio_url = upload_response.json()['upload_url']
transcript_response = requests.post(
"https://api.assemblyai.com/v2/transcript",
headers={"authorization": ASSEMBLYAI_KEY},
json={
"audio_url": audio_url,
"speaker_labels": True,
"filter_profanity": True
}
)
transcript_id = transcript_response.json()['id']
while True:
result = requests.get(
f"https://api.assemblyai.com/v2/transcript/{transcript_id}",
headers={"authorization": ASSEMBLYAI_KEY}
).json()
if result['status'] == 'completed':
return result
elif result['status'] == 'error':
raise Exception(result['error'])
time.sleep(5)
except Exception as e:
logger.error(f"Transcription failed: {str(e)}")
raise
def process_utterance(utterance, full_audio, wav_file):
try:
start = utterance['start']
end = utterance['end']
segment = full_audio[start:end]
temp_path = os.path.join(OUTPUT_DIR, f"temp_{uuid.uuid4()}.wav")
segment.export(temp_path, format="wav")
with torch.no_grad():
embedding = speaker_model.get_embedding(temp_path).to(device)
query_result = index.query(
vector=embedding.cpu().numpy().tolist(),
top_k=1,
include_metadata=True
)
if query_result['matches'] and query_result['matches'][0]['score'] > 0.7:
speaker_id = query_result['matches'][0]['id']
speaker_name = query_result['matches'][0]['metadata']['speaker_name']
else:
speaker_id = f"unknown_{uuid.uuid4().hex[:6]}"
speaker_name = f"Speaker_{speaker_id[-4:]}"
index.upsert([(speaker_id, embedding.tolist(), {"speaker_name": speaker_name})])
os.remove(temp_path)
return {
**utterance,
'speaker': speaker_name,
'speaker_id': speaker_id,
'embedding': embedding.cpu().numpy().tolist()
}
except Exception as e:
logger.error(f"Utterance processing failed: {str(e)}")
return {
**utterance,
'speaker': 'Unknown',
'speaker_id': 'unknown',
'embedding': None
}
def identify_speakers(transcript: Dict, wav_file: str) -> List[Dict]:
try:
full_audio = AudioSegment.from_wav(wav_file)
utterances = transcript['utterances']
with ThreadPoolExecutor(max_workers=5) as executor: # Changed to 5 workers
futures = [
executor.submit(process_utterance, utterance, full_audio, wav_file)
for utterance in utterances
]
results = [f.result() for f in futures]
return results
except Exception as e:
logger.error(f"Speaker identification failed: {str(e)}")
raise
def train_role_classifier(utterances: List[Dict]):
try:
texts = [u['text'] for u in utterances]
vectorizer = TfidfVectorizer(max_features=500, ngram_range=(1, 2))
X_text = vectorizer.fit_transform(texts)
features = []
labels = []
for i, utterance in enumerate(utterances):
prosodic = utterance['prosodic_features']
feat = [
prosodic['duration'],
prosodic['mean_pitch'],
prosodic['min_pitch'],
prosodic['max_pitch'],
prosodic['pitch_sd'],
prosodic['intensityMean'],
prosodic['intensityMin'],
prosodic['intensityMax'],
prosodic['intensitySD'],
]
feat.extend(X_text[i].toarray()[0].tolist())
doc = nlp(utterance['text'])
feat.extend([
int(utterance['text'].endswith('?')),
len(re.findall(r'\b(why|how|what|when|where|who|which)\b', utterance['text'].lower())),
len(utterance['text'].split()),
sum(1 for token in doc if token.pos_ == 'VERB'),
sum(1 for token in doc if token.pos_ == 'NOUN')
])
features.append(feat)
labels.append(0 if i % 2 == 0 else 1)
scaler = StandardScaler()
X = scaler.fit_transform(features)
clf = RandomForestClassifier(
n_estimators=150,
max_depth=10,
random_state=42,
class_weight='balanced'
)
clf.fit(X, labels)
joblib.dump(clf, os.path.join(OUTPUT_DIR, 'role_classifier.pkl'))
joblib.dump(vectorizer, os.path.join(OUTPUT_DIR, 'text_vectorizer.pkl'))
joblib.dump(scaler, os.path.join(OUTPUT_DIR, 'feature_scaler.pkl'))
return clf, vectorizer, scaler
except Exception as e:
logger.error(f"Classifier training failed: {str(e)}")
raise
def classify_roles(utterances: List[Dict], clf, vectorizer, scaler):
try:
texts = [u['text'] for u in utterances]
X_text = vectorizer.transform(texts)
results = []
for i, utterance in enumerate(utterances):
prosodic = utterance['prosodic_features']
feat = [
prosodic['duration'],
prosodic['mean_pitch'],
prosodic['min_pitch'],
prosodic['max_pitch'],
prosodic['pitch_sd'],
prosodic['intensityMean'],
prosodic['intensityMin'],
prosodic['intensityMax'],
prosodic['intensitySD'],
]
feat.extend(X_text[i].toarray()[0].tolist())
doc = nlp(utterance['text'])
feat.extend([
int(utterance['text'].endswith('?')),
len(re.findall(r'\b(why|how|what|when|where|who|which)\b', utterance['text'].lower())),
len(utterance['text'].split()),
sum(1 for token in doc if token.pos_ == 'VERB'),
sum(1 for token in doc if token.pos_ == 'NOUN')
])
X = scaler.transform([feat])
role = 'Interviewer' if clf.predict(X)[0] == 0 else 'Interviewee'
results.append({**utterance, 'role': role})
return results
except Exception as e:
logger.error(f"Role classification failed: {str(e)}")
raise
def analyze_interviewee_voice(audio_path: str, utterances: List[Dict]) -> Dict:
try:
y, sr = librosa.load(audio_path, sr=16000)
interviewee_utterances = [u for u in utterances if u['role'] == 'Interviewee']
if not interviewee_utterances:
return {'error': 'No interviewee utterances found'}
segments = []
for u in interviewee_utterances:
start = int(u['start'] * sr / 1000)
end = int(u['end'] * sr / 1000)
segments.append(y[start:end])
combined_audio = np.concatenate(segments)
total_duration = sum(u['prosodic_features']['duration'] for u in interviewee_utterances)
total_words = sum(len(u['text'].split()) for u in interviewee_utterances)
speaking_rate = total_words / total_duration if total_duration > 0 else 0
filler_words = ['um', 'uh', 'like', 'you know', 'so', 'i mean']
filler_count = sum(
sum(u['text'].lower().count(fw) for fw in filler_words)
for u in interviewee_utterances
)
filler_ratio = filler_count / total_words if total_words > 0 else 0
all_words = ' '.join(u['text'].lower() for u in interviewee_utterances).split()
word_counts = {}
for i in range(len(all_words) - 1):
bigram = (all_words[i], all_words[i + 1])
word_counts[bigram] = word_counts.get(bigram, 0) + 1
repetition_score = sum(1 for count in word_counts.values() if count > 1) / len(
word_counts) if word_counts else 0
pitches = []
for segment in segments:
f0, voiced_flag, _ = librosa.pyin(segment, fmin=80, fmax=300, sr=sr)
pitches.extend(f0[voiced_flag])
pitch_mean = np.mean(pitches) if len(pitches) > 0 else 0
pitch_std = np.std(pitches) if len(pitches) > 0 else 0
jitter = np.mean(np.abs(np.diff(pitches))) / pitch_mean if len(pitches) > 1 and pitch_mean > 0 else 0
intensities = []
for segment in segments:
rms = librosa.feature.rms(y=segment)[0]
intensities.extend(rms)
intensity_mean = np.mean(intensities) if intensities else 0
intensity_std = np.std(intensities) if intensities else 0
shimmer = np.mean(np.abs(np.diff(intensities))) / intensity_mean if len(
intensities) > 1 and intensity_mean > 0 else 0
anxiety_score = 0.6 * (pitch_std / pitch_mean) + 0.4 * (jitter + shimmer) if pitch_mean > 0 else 0
confidence_score = 0.7 * (1 / (1 + intensity_std)) + 0.3 * (1 / (1 + filler_ratio))
hesitation_score = filler_ratio + repetition_score
anxiety_level = 'high' if anxiety_score > 0.15 else 'moderate' if anxiety_score > 0.07 else 'low'
confidence_level = 'high' if confidence_score > 0.7 else 'moderate' if confidence_score > 0.5 else 'low'
fluency_level = 'fluent' if (filler_ratio < 0.05 and repetition_score < 0.1) else 'moderate' if (
filler_ratio < 0.1 and repetition_score < 0.2) else 'disfluent'
return {
'speaking_rate': float(round(speaking_rate, 2)),
'filler_ratio': float(round(filler_ratio, 4)),
'repetition_score': float(round(repetition_score, 4)),
'pitch_analysis': {
'mean': float(round(pitch_mean, 2)),
'std_dev': float(round(pitch_std, 2)),
'jitter': float(round(jitter, 4))
},
'intensity_analysis': {
'mean': float(round(intensity_mean, 2)),
'std_dev': float(round(intensity_std, 2)),
'shimmer': float(round(shimmer, 4))
},
'composite_scores': {
'anxiety': float(round(anxiety_score, 4)),
'confidence': float(round(confidence_score, 4)),
'hesitation': float(round(hesitation_score, 4))
},
'interpretation': {
'anxiety_level': anxiety_level,
'confidence_level': confidence_level,
'fluency_level': fluency_level
}
}
except Exception as e:
logger.error(f"Voice analysis failed: {str(e)}")
return {'error': str(e)}
def generate_anxiety_confidence_chart(composite_scores: Dict, chart_path_or_buffer):
try:
labels = ['Anxiety', 'Confidence']
scores = [composite_scores.get('anxiety', 0), composite_scores.get('confidence', 0)]
fig, ax = plt.subplots(figsize=(5, 3.5))
bars = ax.bar(labels, scores, color=['#FF5252', '#26A69A'], edgecolor='black', width=0.45)
ax.set_ylabel('Score (Normalized)', fontsize=12)
ax.set_title('Vocal Dynamics: Anxiety vs. Confidence', fontsize=14, pad=15)
ax.set_ylim(0, 1.3)
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2, height + 0.05, f"{height:.2f}",
ha='center', color='black', fontweight='bold', fontsize=11)
ax.grid(True, axis='y', linestyle='--', alpha=0.7)
plt.tight_layout()
plt.savefig(chart_path_or_buffer, format='png', bbox_inches='tight', dpi=300)
plt.close(fig)
except Exception as e:
logger.error(f"Error generating chart: {str(e)}")
def calculate_acceptance_probability(analysis_data: Dict) -> float:
voice = analysis_data.get('voice_analysis', {})
if 'error' in voice: return 0.0
w_confidence, w_anxiety, w_fluency, w_speaking_rate, w_filler_repetition, w_content_strengths = 0.35, -0.25, 0.2, 0.15, -0.15, 0.25
confidence_score = voice.get('composite_scores', {}).get('confidence', 0.0)
anxiety_score = voice.get('composite_scores', {}).get('anxiety', 0.0)
fluency_level = voice.get('interpretation', {}).get('fluency_level', 'Disfluent')
speaking_rate = voice.get('speaking_rate', 0.0)
filler_ratio = voice.get('filler_ratio', 0.0)
repetition_score = voice.get('repetition_score', 0.0)
fluency_map = {'Fluent': 1.0, 'Moderate': 0.6, 'Disfluent': 0.2}
fluency_val = fluency_map.get(fluency_level, 0.2)
ideal_speaking_rate = 2.5
speaking_rate_deviation = abs(speaking_rate - ideal_speaking_rate)
speaking_rate_score = max(0, 1 - (speaking_rate_deviation / ideal_speaking_rate))
filler_repetition_composite = (filler_ratio + repetition_score) / 2
filler_repetition_score = max(0, 1 - filler_repetition_composite)
content_strength_val = 0.85 if analysis_data.get('text_analysis', {}).get('total_duration', 0) > 60 else 0.4
raw_score = (confidence_score * w_confidence + (1 - anxiety_score) * abs(w_anxiety) + fluency_val * w_fluency + speaking_rate_score * w_speaking_rate + filler_repetition_score * abs(w_filler_repetition) + content_strength_val * w_content_strengths)
max_possible_score = (w_confidence + abs(w_anxiety) + w_fluency + w_speaking_rate + abs(w_filler_repetition) + w_content_strengths)
if max_possible_score == 0: return 50.0
normalized_score = raw_score / max_possible_score
acceptance_probability = max(0.0, min(1.0, normalized_score))
return float(f"{acceptance_probability * 100:.2f}")
def generate_report(analysis_data: Dict) -> str:
try:
voice = analysis_data.get('voice_analysis', {})
voice_interpretation = generate_voice_interpretation(voice)
interviewee_responses = [f"Speaker {u['speaker']} ({u['role']}): {u['text']}" for u in analysis_data['transcript'] if u['role'] == 'Interviewee'][:6]
acceptance_prob = analysis_data.get('acceptance_probability', None)
acceptance_line = ""
if acceptance_prob is not None:
acceptance_line = f"\n**Hiring Suitability Score: {acceptance_prob:.2f}%**\n"
if acceptance_prob >= 80: acceptance_line += "HR Verdict: Outstanding candidate, highly recommended for immediate advancement."
elif acceptance_prob >= 60: acceptance_line += "HR Verdict: Strong candidate, suitable for further evaluation with targeted development."
elif acceptance_prob >= 40: acceptance_line += "HR Verdict: Moderate potential, requires additional assessment and skill-building."
else: acceptance_line += "HR Verdict: Limited fit, significant improvement needed for role alignment."
prompt = f"""
You are EvalBot, a senior HR consultant with 20+ years of experience, delivering a polished, concise, and engaging interview analysis report. Use a professional tone, clear headings, and bullet points ('- ') for readability. Avoid redundancy and ensure distinct sections for strengths, growth areas, and recommendations.
{acceptance_line}
**1. Executive Summary**
- Provide a concise overview of performance, key metrics, and hiring potential.
- Interview length: {analysis_data['text_analysis']['total_duration']:.2f} seconds
- Speaker turns: {analysis_data['text_analysis']['speaker_turns']}
- Participants: {', '.join(analysis_data['speakers'])}
**2. Communication and Vocal Dynamics**
- Evaluate vocal delivery (rate, fluency, confidence) and professional impact.
- Offer HR insights on workplace alignment.
{voice_interpretation}
**3. Competency and Content Evaluation**
- Assess competencies: leadership, problem-solving, communication, adaptability.
- List strengths and growth areas separately, with specific examples.
- Sample responses:
{chr(10).join(interviewee_responses)}
**4. Role Fit and Growth Potential**
- Analyze cultural fit, role readiness, and long-term potential.
- Highlight enthusiasm and scalability.
**5. Strategic HR Recommendations**
- Provide distinct, prioritized strategies for candidate growth.
- Target: Communication, Response Depth, Professional Presence.
- List clear next steps for hiring managers (e.g., advance, train, assess).
"""
response = gemini_model.generate_content(prompt)
return response.text
except Exception as e:
logger.error(f"Report generation failed: {str(e)}")
return f"Error generating report: {str(e)}"
def create_pdf_report(analysis_data: Dict, output_path: str, gemini_report_text: str):
try:
doc = SimpleDocTemplate(output_path, pagesize=letter,
rightMargin=0.7*inch, leftMargin=0.7*inch,
topMargin=0.9*inch, bottomMargin=0.9*inch)
styles = getSampleStyleSheet()
h1 = ParagraphStyle(name='Heading1', fontSize=22, leading=26, spaceAfter=20, alignment=1, textColor=colors.HexColor('#003087'), fontName='Helvetica-Bold')
h2 = ParagraphStyle(name='Heading2', fontSize=15, leading=18, spaceBefore=14, spaceAfter=8, textColor=colors.HexColor('#0050BC'), fontName='Helvetica-Bold')
h3 = ParagraphStyle(name='Heading3', fontSize=11, leading=14, spaceBefore=10, spaceAfter=6, textColor=colors.HexColor('#3F7CFF'), fontName='Helvetica')
body_text = ParagraphStyle(name='BodyText', fontSize=10, leading=13, spaceAfter=8, fontName='Helvetica', textColor=colors.HexColor('#333333'))
bullet_style = ParagraphStyle(name='Bullet', parent=body_text, leftIndent=20, bulletIndent=10, fontName='Helvetica', bulletFontName='Helvetica', bulletFontSize=10)
story = []
def header_footer(canvas, doc):
canvas.saveState()
canvas.setFont('Helvetica', 8)
canvas.setFillColor(colors.HexColor('#666666'))
canvas.drawString(doc.leftMargin, 0.4 * inch, f"Page {doc.page} | EvalBot HR Interview Report | Confidential")
canvas.setStrokeColor(colors.HexColor('#0050BC'))
canvas.setLineWidth(1)
canvas.line(doc.leftMargin, doc.height + 0.85*inch, doc.width + doc.leftMargin, doc.height + 0.85*inch)
canvas.setFont('Helvetica-Bold', 10)
canvas.drawString(doc.leftMargin, doc.height + 0.9*inch, "Candidate Interview Analysis")
canvas.drawRightString(doc.width + doc.leftMargin, doc.height + 0.9*inch, time.strftime('%B %d, %Y'))
canvas.restoreState()
# Title Page
story.append(Paragraph("Candidate Interview Analysis", h1))
story.append(Paragraph(f"Generated: {time.strftime('%B %d, %Y')}", ParagraphStyle(name='Date', alignment=1, fontSize=10, textColor=colors.HexColor('#666666'), fontName='Helvetica')))
story.append(Spacer(1, 0.5 * inch))
acceptance_prob = analysis_data.get('acceptance_probability')
if acceptance_prob is not None:
story.append(Paragraph("Hiring Suitability Snapshot", h2))
prob_color = colors.HexColor('#2E7D32') if acceptance_prob >= 80 else (colors.HexColor('#F57C00') if acceptance_prob >= 60 else colors.HexColor('#D32F2F'))
story.append(Paragraph(f"Suitability Score: {acceptance_prob:.2f}%",
ParagraphStyle(name='Prob', fontSize=12, spaceAfter=12, alignment=1, fontName='Helvetica-Bold')))
if acceptance_prob >= 80:
story.append(Paragraph("HR Verdict: Outstanding candidate, highly recommended for immediate advancement.", body_text))
elif acceptance_prob >= 60:
story.append(Paragraph("HR Verdict: Strong candidate, suitable for further evaluation with targeted development.", body_text))
elif acceptance_prob >= 40:
story.append(Paragraph("HR Verdict: Moderate potential, requires additional assessment and skill-building.", body_text))
else:
story.append(Paragraph("HR Verdict: Limited fit, significant improvement needed for role alignment.", body_text))
story.append(Spacer(1, 0.3 * inch))
table_data = [
['Metric', 'Value'],
['Interview Duration', f"{analysis_data['text_analysis']['total_duration']:.2f} seconds"],
['Speaker Turns', f"{analysis_data['text_analysis']['speaker_turns']}"],
['Participants', ', '.join(sorted(analysis_data['speakers']))]
]
table = Table(table_data, colWidths=[2.2*inch, 3.8*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#0050BC')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('ALIGN', (0,0), (-1,-1), 'LEFT'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,-1), 9),
('BOTTOMPADDING', (0,0), (-1,0), 10),
('TOPPADDING', (0,0), (-1,0), 10),
('BACKGROUND', (0,1), (-1,-1), colors.HexColor('#F5F6FA')),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#DDE4EB'))
]))
story.append(table)
story.append(Spacer(1, 0.4 * inch))
story.append(Paragraph("Prepared by: EvalBot - AI-Powered HR Analysis", body_text))
story.append(PageBreak())
# Detailed Analysis
story.append(Paragraph("Detailed Candidate Evaluation", h1))
# Communication and Vocal Dynamics
story.append(Paragraph("1. Communication & Vocal Dynamics", h2))
voice_analysis = analysis_data.get('voice_analysis', {})
if voice_analysis and 'error' not in voice_analysis:
table_data = [
['Metric', 'Value', 'HR Insight'],
['Speaking Rate', f"{voice_analysis.get('speaking_rate', 0):.2f} words/sec", 'Benchmark: 2.0-3.0 wps; impacts clarity'],
['Filler Words', f"{voice_analysis.get('filler_ratio', 0) * 100:.1f}%", 'High usage reduces credibility'],
['Anxiety', voice_analysis.get('interpretation', {}).get('anxiety_level', 'N/A'), f"Score: {voice_analysis.get('composite_scores', {}).get('anxiety', 0):.3f}; stress response"],
['Confidence', voice_analysis.get('interpretation', {}).get('confidence_level', 'N/A'), f"Score: {voice_analysis.get('composite_scores', {}).get('confidence', 0):.3f}; vocal strength"],
['Fluency', voice_analysis.get('interpretation', {}).get('fluency_level', 'N/A'), 'Drives engagement']
]
table = Table(table_data, colWidths=[1.7*inch, 1.2*inch, 3.1*inch])
table.setStyle(TableStyle([
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#0050BC')),
('TEXTCOLOR', (0,0), (-1,0), colors.white),
('ALIGN', (0,0), (-1,-1), 'LEFT'),
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'),
('FONTSIZE', (0,0), (-1,-1), 9),
('BOTTOMPADDING', (0,0), (-1,0), 10),
('TOPPADDING', (0,0), (-1,0), 10),
('BACKGROUND', (0,1), (-1,-1), colors.HexColor('#F5F6FA')),
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#DDE4EB'))
]))
story.append(table)
story.append(Spacer(1, 0.2 * inch))
chart_buffer = io.BytesIO()
generate_anxiety_confidence_chart(voice_analysis.get('composite_scores', {}), chart_buffer)
chart_buffer.seek(0)
img = Image(chart_buffer, width=4.8*inch, height=3.2*inch)
img.hAlign = 'CENTER'
story.append(img)
else:
story.append(Paragraph("Vocal analysis unavailable.", body_text))
story.append(Spacer(1, 0.3 * inch))
# Parse Gemini Report
sections = {
"Executive Summary": [],
"Communication and Vocal Dynamics": [],
"Competency and Content Evaluation": {"Strengths": [], "Growth Areas": []},
"Role Fit and Growth Potential": [],
"Strategic HR Recommendations": {"Development Priorities": [], "Next Steps": []}
}
report_parts = re.split(r'(\s*\*\*\s*\d\.\s*.*?\s*\*\*)', gemini_report_text)
current_section = None
for part in report_parts:
if not part.strip(): continue
is_heading = False
for title in sections.keys():
if title.lower() in part.lower():
current_section = title
is_heading = True
break
if not is_heading and current_section:
if current_section == "Competency and Content Evaluation":
if 'strength' in part.lower() or any(k in part.lower() for k in ['leadership', 'problem-solving', 'communication', 'adaptability']):
sections[current_section]["Strengths"].append(part.strip())
elif 'improve' in part.lower() or 'grow' in part.lower() or 'challenge' in part.lower():
sections[current_section]["Growth Areas"].append(part.strip())
elif current_section == "Strategic HR Recommendations":
if any(k in part.lower() for k in ['communication', 'depth', 'presence', 'improve']):
sections[current_section]["Development Priorities"].append(part.strip())
elif any(k in part.lower() for k in ['advance', 'train', 'assess', 'next step']):
sections[current_section]["Next Steps"].append(part.strip())
else:
sections[current_section].append(part.strip())
# Executive Summary
story.append(Paragraph("2. Executive Summary", h2))
if sections['Executive Summary']:
for line in sections['Executive Summary']:
if line.startswith(('-', '•', '*')):
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph(line, body_text))
else:
story.append(Paragraph("Summary unavailable.", body_text))
story.append(Spacer(1, 0.3 * inch))
# Competency and Content
story.append(Paragraph("3. Competency & Content", h2))
story.append(Paragraph("Strengths", h3))
if sections['Competency and Content Evaluation']['Strengths']:
for line in sections['Competency and Content Evaluation']['Strengths']:
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph("No strengths identified.", body_text))
story.append(Spacer(1, 0.2 * inch))
story.append(Paragraph("Growth Areas", h3))
if sections['Competency and Content Evaluation']['Growth Areas']:
for line in sections['Competency and Content Evaluation']['Growth Areas']:
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph("No growth areas identified.", body_text))
story.append(Spacer(1, 0.3 * inch))
# Role Fit
story.append(Paragraph("4. Role Fit & Potential", h2))
if sections['Role Fit and Growth Potential']:
for line in sections['Role Fit and Growth Potential']:
if line.startswith(('-', '•', '*')):
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph(line, body_text))
else:
story.append(Paragraph("Fit and potential analysis unavailable.", body_text))
story.append(Spacer(1, 0.3 * inch))
# Strategic Recommendations
story.append(Paragraph("5. Strategic Recommendations", h2))
story.append(Paragraph("Development Priorities", h3))
if sections['Strategic HR Recommendations']['Development Priorities']:
for line in sections['Strategic HR Recommendations']['Development Priorities']:
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph("No development priorities specified.", body_text))
story.append(Spacer(1, 0.2 * inch))
story.append(Paragraph("Next Steps for Managers", h3))
if sections['Strategic HR Recommendations']['Next Steps']:
for line in sections['Strategic HR Recommendations']['Next Steps']:
story.append(Paragraph(line.lstrip('-•* ').strip(), bullet_style))
else:
story.append(Paragraph("No next steps provided.", body_text))
story.append(Spacer(1, 0.3 * inch))
story.append(Paragraph("This report provides a data-driven evaluation to guide hiring and development decisions.", body_text))
doc.build(story, onFirstPage=header_footer, onLaterPages=header_footer)
return True
except Exception as e:
logger.error(f"PDF creation failed: {str(e)}", exc_info=True)
return False
def convert_to_serializable(obj):
if isinstance(obj, np.generic): return obj.item()
if isinstance(obj, dict): return {k: convert_to_serializable(v) for k, v in obj.items()}
if isinstance(obj, list): return [convert_to_serializable(i) for i in obj]
if isinstance(obj, np.ndarray): return obj.tolist()
return obj
def process_interview(audio_path_or_url: str):
local_audio_path = None
wav_file = None
is_downloaded = False
try:
logger.info(f"Starting processing for {audio_path_or_url}")
if audio_path_or_url.startswith(('http://', 'https://')):
local_audio_path = download_audio_from_url(audio_path_or_url)
is_downloaded = True
else:
local_audio_path = audio_path_or_url
wav_file = convert_to_wav(local_audio_path)
transcript = transcribe(wav_file)
for utterance in transcript['utterances']:
utterance['prosodic_features'] = extract_prosodic_features(wav_file, utterance['start'], utterance['end'])
utterances_with_speakers = identify_speakers(transcript, wav_file)
clf, vectorizer, scaler = None, None, None
if os.path.exists(os.path.join(OUTPUT_DIR, 'role_classifier.pkl')):
clf = joblib.load(os.path.join(OUTPUT_DIR, 'role_classifier.pkl'))
vectorizer = joblib.load(os.path.join(OUTPUT_DIR, 'text_vectorizer.pkl'))
scaler = joblib.load(os.path.join(OUTPUT_DIR, 'feature_scaler.pkl'))
else:
clf, vectorizer, scaler = train_role_classifier(utterances_with_speakers)
classified_utterances = classify_roles(utterances_with_speakers, clf, vectorizer, scaler)
voice_analysis = analyze_interviewee_voice(wav_file, classified_utterances)
analysis_data = {
'transcript': classified_utterances,
'speakers': list(set(u['speaker'] for u in classified_utterances)),
'voice_analysis': voice_analysis,
'text_analysis': {
'total_duration': sum(u['prosodic_features']['duration'] for u in classified_utterances),
'speaker_turns': len(classified_utterances)
}
}
analysis_data['acceptance_probability'] = calculate_acceptance_probability(analysis_data)
gemini_report_text = generate_report(analysis_data)
base_name = str(uuid.uuid4())
pdf_path = os.path.join(OUTPUT_DIR, f"{base_name}_report.pdf")
json_path = os.path.join(OUTPUT_DIR, f"{base_name}_analysis.json")
create_pdf_report(analysis_data, pdf_path, gemini_report_text=gemini_report_text)
with open(json_path, 'w') as f:
serializable_data = convert_to_serializable(analysis_data)
json.dump(serializable_data, f, indent=2)
logger.info(f"Processing completed for {audio_path_or_url}")
return {'pdf_path': pdf_path, 'json_path': json_path}
except Exception as e:
logger.error(f"Processing failed for {audio_path_or_url}: {str(e)}", exc_info=True)
raise
finally:
if wav_file and os.path.exists(wav_file):
os.remove(wav_file)
if is_downloaded and local_audio_path and os.path.exists(local_audio_path):
os.remove(local_audio_path)
logger.info(f"Cleaned up temporary downloaded file: {local_audio_path}")