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}")