norhan12 commited on
Commit
36d1f48
·
0 Parent(s):

EvalBot v2: interview analysis, practice mode, English/Arabic reports

Browse files
.dockerignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__
3
+ processed_audio
4
+ temp_files
5
+ static/outputs
6
+ models
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ assets/fonts/Amiri-Bold.ttf filter=lfs diff=lfs merge=lfs -text
2
+ assets/fonts/Amiri-Regular.ttf filter=lfs diff=lfs merge=lfs -text
3
+ assets/sample_interview.mp3 filter=lfs diff=lfs merge=lfs -text
4
+ assets/models/role_classifier.joblib filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ static/outputs/
4
+ processed_audio/
5
+ temp_files/
6
+ models/
7
+ .env
8
+ interview_transcripts_by_turkers.csv
9
+ *.avi
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ ffmpeg libsndfile1 sox curl build-essential \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ RUN useradd -m -u 1000 appuser
8
+ WORKDIR /app
9
+ RUN mkdir -p /app/temp_files /app/processed_audio /app/models /app/static/outputs /tmp/matplotlib \
10
+ && chown -R appuser:appuser /app /tmp/matplotlib
11
+
12
+ ENV MPLCONFIGDIR=/tmp/matplotlib \
13
+ HF_HOME=/app/.cache/huggingface \
14
+ PYTHONUNBUFFERED=1 \
15
+ PIP_NO_CACHE_DIR=1
16
+
17
+ USER appuser
18
+ ENV PATH="/home/appuser/.local/bin:${PATH}"
19
+
20
+ COPY --chown=appuser:appuser requirements.txt .
21
+ RUN pip install --user --upgrade pip \
22
+ && pip install --user "Cython==0.29.36" "setuptools<70" wheel \
23
+ && pip install --user --no-build-isolation "youtokentome==1.0.6" \
24
+ && pip install --user -r requirements.txt
25
+
26
+ COPY --chown=appuser:appuser . .
27
+
28
+ # Train the role classifier and download the speaker model at build time,
29
+ # so the Space is ready as soon as it starts
30
+ RUN python -c "import process_interview as p; p.get_role_classifier(); p._get_speaker_model()"
31
+
32
+ EXPOSE 7860
33
+ HEALTHCHECK --interval=30s --timeout=10s CMD curl -f http://localhost:7860/health || exit 1
34
+ CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EvalBot - Interview Analysis System
3
+ emoji: 🎤
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ short_description: AI interview analysis and practice coach (English/Arabic)
10
+ ---
11
+
12
+ # EvalBot: Interview Audio Analysis
13
+
14
+ Two ways to use it:
15
+
16
+ - **Analyze an interview:** upload a recorded job interview (or try the built-in sample) and get a full candidate evaluation: who said what, how the candidate sounded, a score and feedback for every question, and an AI-written assessment. Results appear on the page with a transcript synced to the audio, and as a PDF report and JSON.
17
+ - **Practice mode:** pick a question (or generate questions for any job title), listen to it, record your answer with the microphone, and get instant feedback: a score, what went well, what to improve, a delivery tip, and a stronger version of your answer.
18
+
19
+ The interface and the reports are available in **English and Arabic** (the Arabic PDF is fully right-to-left).
20
+
21
+ Part of **EvalBot**, a graduation project at the Faculty of Computers and Artificial Intelligence, Benha University. Backend API: [nourhan-shalaby/EvalBot-Backend-API](https://github.com/nourhan-shalaby/EvalBot-Backend-API).
22
+
23
+ ## Pipeline
24
+
25
+ | Step | What happens | Tools |
26
+ |---|---|---|
27
+ | 1. Conversion | Any audio/video file to 16 kHz mono WAV | ffmpeg |
28
+ | 2. Transcription | Speech-to-text with speaker diarization (filler words kept) | AssemblyAI |
29
+ | 3. Prosody | Pitch and intensity per utterance | librosa |
30
+ | 4. Speaker identification | One voice embedding per speaker, matched by cosine similarity against or enrolled into a vector index, so returning speakers are recognized | NVIDIA NeMo TitaNet; Pinecone, or a built-in NumPy vector index when Pinecone isn't configured |
31
+ | 5. Role classification | Decides which speaker is the interviewer and which is the candidate | Random Forest (TF-IDF + handcrafted features) trained on ~3,000 labeled utterances from the MIT Interview dataset; ~92% utterance accuracy on held-out interviews, aggregated per speaker |
32
+ | 6. Voice analysis | Speaking rate, filler words, repetition, pitch, jitter/shimmer, and confidence, anxiety and hesitation scores | librosa, NumPy |
33
+ | 7. Question pairing | Groups the transcript into question/answer pairs | Python |
34
+ | 8. Evaluation | Structured assessment: per-question score, what went well, what to improve and a stronger answer; summary, strengths with evidence, development areas, role fit, recommendations, next steps, content score. In English or Arabic | Gemini (JSON schema output) |
35
+ | 9. Report | Multi-page PDF (English, or right-to-left Arabic) with tables, charts, per-question feedback and an annotated transcript, plus a JSON export | ReportLab, Matplotlib, arabic-reshaper, python-bidi |
36
+
37
+ The **suitability score** (0-100) combines answer content (60%, from the AI evaluation) and vocal delivery (40%). If the AI evaluation is unavailable, the report is marked as provisional and based on voice only.
38
+
39
+ ## API
40
+
41
+ ### `POST /process-audio`
42
+ Form fields: `user_id`, either `file` (upload) or `file_url`, and optionally `lang` (`en` or `ar`).
43
+
44
+ ```bash
45
+ curl -X POST https://evalbot-audio-evalbot.hf.space/process-audio \
46
+ -F user_id=candidate_42 \
47
+ -F file=@interview.mp3
48
+ ```
49
+
50
+ ```json
51
+ {
52
+ "user_id": "candidate_42",
53
+ "suitability_score": 81.3,
54
+ "verdict": "Outstanding candidate, recommended for advancement.",
55
+ "summary": "...",
56
+ "pdf_url": "https://evalbot-audio-evalbot.hf.space/static/outputs/pdf/candidate_42_..._report.pdf",
57
+ "json_url": "https://evalbot-audio-evalbot.hf.space/static/outputs/json/candidate_42_..._analysis.json"
58
+ }
59
+ ```
60
+
61
+ ### Background jobs (used by the web page)
62
+ - `POST /jobs` (same form fields, or `sample=true` for the demo interview) returns `{"job_id": "..."}`
63
+ - `GET /jobs/{job_id}` returns status, the current step and, when done, the result
64
+
65
+ ### Practice mode
66
+ - `GET /practice/questions`: built-in question bank (General, Behavioral, Data Engineering, Software Engineering, AI / Machine Learning)
67
+ - `POST /practice/generate` (form field `role`): five AI-generated questions for a job title
68
+ - `POST /practice` (form fields `question`, `file`, optional `lang` and `role`): evaluates one recorded answer and returns the transcript, voice metrics and feedback, usually in 10-20 seconds
69
+
70
+ ### `GET /health`
71
+ Shows which external services are configured.
72
+
73
+ ## Configuration
74
+
75
+ Set these as **Secrets** in the Space settings. Never put keys in the code.
76
+
77
+ | Secret | Required | Used for |
78
+ |---|---|---|
79
+ | `ASSEMBLYAI_API_KEY` (or `ASSEMBLYAI_KEY`) | yes | Transcription and diarization |
80
+ | `GEMINI_API_KEY` | recommended | AI evaluation (without it, the report covers voice metrics only) |
81
+ | `PINECONE_API_KEY` | optional | Use Pinecone for speaker identification. Without it, a built-in vector index is used (saved to `/data` if the Space has persistent storage, otherwise reset on restart) |
82
+
83
+ Optional variables: `GEMINI_MODEL` (default `gemini-3.5-flash-lite`, with automatic fallback to other Flash models if it is slow or unavailable), `GEMINI_TIMEOUT_S` (default 60), `PINECONE_INDEX`, `BASE_URL`, `MAX_UPLOAD_MB`.
84
+
85
+ ## Run locally
86
+
87
+ ```bash
88
+ docker build -t evalbot-audio .
89
+ docker run -p 7860:7860 -e ASSEMBLYAI_API_KEY=... -e GEMINI_API_KEY=... -e PINECONE_API_KEY=... evalbot-audio
90
+ # open http://localhost:7860
91
+ ```
92
+
93
+ Or without Docker: `python process_interview.py interview.mp3 candidate_42`.
94
+
95
+ ## Notes and limitations
96
+ - Interviews and practice answers should be in English (the feedback and report can be in Arabic).
97
+ - Interviews with two speakers work best.
98
+ - The sample interview was generated with text-to-speech (Piper voices) for demonstration.
99
+ - The confidence, anxiety and hesitation scores are heuristics computed from the voice signal. They support a human reviewer and are not a hiring decision.
100
+ - The Space runs on free CPU hardware and sleeps when idle; the first request after a pause takes a minute to wake it up.
app.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EvalBot audio analysis service (FastAPI).
2
+
3
+ Endpoints
4
+ GET / Web demo: upload an interview recording and get the report
5
+ GET /health Health check (also reports which services are configured)
6
+ POST /process-audio Synchronous API used by the EvalBot backend/mobile app
7
+ POST /jobs Start an analysis in the background (used by the web demo; sample=true uses the demo interview)
8
+ GET /jobs/{job_id} Poll job status / result
9
+ GET /practice/questions Built-in practice question bank
10
+ POST /practice/generate AI-generated questions for a given role
11
+ POST /practice Evaluate one recorded answer to one question (instant feedback)
12
+ GET /sample.mp3 The demo interview recording
13
+ GET /static/outputs/pdf/{filename}, /static/outputs/json/{filename} Download reports
14
+ """
15
+ import logging
16
+ import os
17
+ import re
18
+ import shutil
19
+ import threading
20
+ import time
21
+ import uuid
22
+ from typing import Dict, Optional
23
+
24
+ import requests
25
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
26
+ from fastapi.responses import FileResponse, HTMLResponse
27
+ from pydantic import BaseModel
28
+
29
+ import process_interview as pipeline
30
+
31
+ logger = logging.getLogger("evalbot.app")
32
+
33
+ TEMP_DIR = "./temp_files"
34
+ OUTPUT_DIR = "./static/outputs"
35
+ JSON_DIR = os.path.join(OUTPUT_DIR, "json")
36
+ PDF_DIR = os.path.join(OUTPUT_DIR, "pdf")
37
+ for d in (TEMP_DIR, JSON_DIR, PDF_DIR):
38
+ os.makedirs(d, exist_ok=True)
39
+
40
+ BASE_URL = os.getenv("BASE_URL", "https://evalbot-audio-evalbot.hf.space").rstrip("/")
41
+ MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "100"))
42
+ ALLOWED_EXT = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm", ".mp4", ".aac", ".opus"}
43
+
44
+ app = FastAPI(title="EvalBot Audio Analysis", version="2.0")
45
+ _jobs: Dict[str, Dict] = {}
46
+ _job_lock = threading.Lock()
47
+ _pipeline_lock = threading.Semaphore(int(os.getenv("MAX_CONCURRENT_JOBS", "1")))
48
+
49
+
50
+ @app.on_event("startup")
51
+ def warm_up():
52
+ threading.Thread(target=pipeline.load_models, daemon=True).start()
53
+
54
+
55
+ SAMPLE_AUDIO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "sample_interview.mp3")
56
+
57
+
58
+ class ProcessResponse(BaseModel):
59
+ user_id: str
60
+ summary: str
61
+ suitability_score: float
62
+ verdict: str
63
+ json_url: str
64
+ pdf_url: str
65
+
66
+
67
+ def _safe_id(value: str) -> str:
68
+ cleaned = re.sub(r"[^A-Za-z0-9_-]", "_", value or "candidate")[:40]
69
+ return cleaned or "candidate"
70
+
71
+
72
+ def _save_input(user_id: str, file_url: Optional[str], file: Optional[UploadFile]) -> str:
73
+ suffix = ".audio"
74
+ if file is not None and file.filename:
75
+ suffix = os.path.splitext(file.filename)[1].lower() or suffix
76
+ if suffix not in ALLOWED_EXT:
77
+ raise HTTPException(400, f"Unsupported file type {suffix}. Use one of: {', '.join(sorted(ALLOWED_EXT))}")
78
+ local_path = os.path.join(TEMP_DIR, f"{user_id}_{uuid.uuid4().hex}{suffix}")
79
+ limit = MAX_UPLOAD_MB * 1024 * 1024
80
+ written = 0
81
+ try:
82
+ if file_url:
83
+ with requests.get(file_url, stream=True, timeout=60) as resp:
84
+ if resp.status_code != 200:
85
+ raise HTTPException(400, "Could not download the audio from the provided URL.")
86
+ with open(local_path, "wb") as out:
87
+ for chunk in resp.iter_content(1024 * 1024):
88
+ written += len(chunk)
89
+ if written > limit:
90
+ raise HTTPException(413, f"File is larger than {MAX_UPLOAD_MB} MB.")
91
+ out.write(chunk)
92
+ elif file is not None:
93
+ with open(local_path, "wb") as out:
94
+ while chunk := file.file.read(1024 * 1024):
95
+ written += len(chunk)
96
+ if written > limit:
97
+ raise HTTPException(413, f"File is larger than {MAX_UPLOAD_MB} MB.")
98
+ out.write(chunk)
99
+ else:
100
+ raise HTTPException(400, "Provide an audio file or a file_url.")
101
+ except Exception:
102
+ if os.path.exists(local_path):
103
+ os.remove(local_path)
104
+ raise
105
+ return local_path
106
+
107
+
108
+ def _lang(value: Optional[str]) -> str:
109
+ return "ar" if (value or "").lower().startswith("ar") else "en"
110
+
111
+
112
+ def _copy_sample(user_id: str) -> str:
113
+ local_path = os.path.join(TEMP_DIR, f"{user_id}_{uuid.uuid4().hex}.mp3")
114
+ shutil.copy(SAMPLE_AUDIO, local_path)
115
+ return local_path
116
+
117
+
118
+ def _run(local_path: str, user_id: str, progress=None, lang: str = "en") -> Dict:
119
+ with _pipeline_lock:
120
+ try:
121
+ result = pipeline.process_interview(local_path, user_id=user_id, progress=progress, lang=lang)
122
+ finally:
123
+ if os.path.exists(local_path):
124
+ os.remove(local_path)
125
+ pdf_name = os.path.basename(result["pdf_path"])
126
+ json_name = os.path.basename(result["json_path"])
127
+ shutil.move(result["pdf_path"], os.path.join(PDF_DIR, pdf_name))
128
+ shutil.move(result["json_path"], os.path.join(JSON_DIR, json_name))
129
+ return {
130
+ "user_id": user_id,
131
+ "summary": " ".join(result["summary"]) if result["summary"] else "Interview analyzed successfully.",
132
+ "executive_summary": result["summary"],
133
+ "suitability_score": result["suitability_score"],
134
+ "verdict": result["verdict"],
135
+ "voice": result["voice"],
136
+ "pdf_url": f"{BASE_URL}/static/outputs/pdf/{pdf_name}",
137
+ "json_url": f"{BASE_URL}/static/outputs/json/{json_name}",
138
+ "pdf_path": f"/static/outputs/pdf/{pdf_name}",
139
+ "json_path": f"/static/outputs/json/{json_name}",
140
+ "lang": result["lang"],
141
+ }
142
+
143
+
144
+ @app.get("/health")
145
+ def health():
146
+ return {"status": "ok", "services": pipeline.validate_env_vars()}
147
+
148
+
149
+ @app.post("/process-audio", response_model=ProcessResponse)
150
+ def process_audio(user_id: str = Form(...), file_url: Optional[str] = Form(None),
151
+ file: Optional[UploadFile] = File(None), lang: str = Form("en")):
152
+ user_id = _safe_id(user_id)
153
+ local_path = _save_input(user_id, file_url, file)
154
+ try:
155
+ return _run(local_path, user_id, lang=_lang(lang))
156
+ except HTTPException:
157
+ raise
158
+ except Exception as e:
159
+ logger.exception("Processing failed")
160
+ raise HTTPException(500, f"Processing failed: {e}")
161
+
162
+
163
+ @app.post("/jobs")
164
+ def create_job(user_id: str = Form("candidate"), file_url: Optional[str] = Form(None),
165
+ file: Optional[UploadFile] = File(None), lang: str = Form("en"), sample: bool = Form(False)):
166
+ user_id = _safe_id(user_id)
167
+ lang = _lang(lang)
168
+ local_path = _copy_sample(user_id) if sample else _save_input(user_id, file_url, file)
169
+ job_id = uuid.uuid4().hex
170
+ with _job_lock:
171
+ _jobs[job_id] = {"status": "queued", "step": "Waiting to start", "created": time.time()}
172
+
173
+ def progress(msg):
174
+ with _job_lock:
175
+ _jobs[job_id].update(status="running", step=msg)
176
+
177
+ def worker():
178
+ try:
179
+ result = _run(local_path, user_id, progress, lang)
180
+ with _job_lock:
181
+ _jobs[job_id].update(status="done", step="Done", result=result)
182
+ except Exception as e:
183
+ logger.exception("Job %s failed", job_id)
184
+ with _job_lock:
185
+ _jobs[job_id].update(status="error", step="Failed", error=str(e))
186
+
187
+ threading.Thread(target=worker, daemon=True).start()
188
+ return {"job_id": job_id}
189
+
190
+
191
+ @app.get("/jobs/{job_id}")
192
+ def get_job(job_id: str):
193
+ job = _jobs.get(job_id)
194
+ if not job:
195
+ raise HTTPException(404, "Job not found")
196
+ return job
197
+
198
+
199
+ @app.get("/sample.mp3")
200
+ def sample_audio():
201
+ return FileResponse(SAMPLE_AUDIO, media_type="audio/mpeg")
202
+
203
+
204
+ @app.get("/practice/questions")
205
+ def practice_questions():
206
+ return pipeline.QUESTION_BANK
207
+
208
+
209
+ @app.post("/practice/generate")
210
+ def practice_generate(role: str = Form(...)):
211
+ questions = pipeline.generate_practice_questions(role)
212
+ if not questions:
213
+ raise HTTPException(503, "Could not generate questions right now. Try one of the built-in categories.")
214
+ return {"role": role, "questions": questions}
215
+
216
+
217
+ @app.post("/practice")
218
+ def practice(question: str = Form(...), file: UploadFile = File(...), lang: str = Form("en"),
219
+ role: str = Form("")):
220
+ if not question.strip():
221
+ raise HTTPException(400, "Missing question")
222
+ if file.filename and "." not in file.filename:
223
+ file.filename += ".webm"
224
+ local_path = _save_input("practice", None, file)
225
+ try:
226
+ with _pipeline_lock:
227
+ return pipeline.evaluate_practice_answer(local_path, question.strip()[:500], _lang(lang), role.strip()[:80])
228
+ except ValueError as e:
229
+ raise HTTPException(400, str(e))
230
+ except Exception as e:
231
+ logger.exception("Practice evaluation failed")
232
+ raise HTTPException(500, f"Could not evaluate the answer: {e}")
233
+ finally:
234
+ if os.path.exists(local_path):
235
+ os.remove(local_path)
236
+
237
+
238
+ @app.get("/static/outputs/{kind}/{filename}")
239
+ def get_output(kind: str, filename: str):
240
+ if kind not in ("pdf", "json"):
241
+ raise HTTPException(404)
242
+ safe = os.path.basename(filename)
243
+ path = os.path.join(PDF_DIR if kind == "pdf" else JSON_DIR, safe)
244
+ if not os.path.isfile(path):
245
+ raise HTTPException(404, "File not found")
246
+ return FileResponse(path, media_type="application/pdf" if kind == "pdf" else "application/json",
247
+ filename=safe)
248
+
249
+
250
+ @app.get("/", response_class=HTMLResponse)
251
+ def index():
252
+ with open(os.path.join(os.path.dirname(__file__), "static_ui.html"), encoding="utf-8") as f:
253
+ return f.read()
assets/fonts/Amiri-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dae72d335a403759e882f66bcb0396f3a6ea92650496fd09eac55c5c620fe502
3
+ size 587152
assets/fonts/Amiri-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:30ea8514fb4d7cc867e799a05bd501cf123ba358745557a46c10737549b5e9c9
3
+ size 606292
assets/fonts/LICENSE-Amiri.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Amiri font (Amiri-Regular.ttf, Amiri-Bold.ttf)
2
+ Copyright (c) 2010-2022 Khaled Hosny <khaledhosny@eglug.org>
3
+ Licensed under the SIL Open Font License, Version 1.1 (https://openfontlicense.org).
4
+ Used to render Arabic text in EvalBot PDF reports.
assets/models/role_classifier.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7baf5dfa8ad489819801f8d6b5d106526dc5d83b61d5353ccf743baa6243dcdb
3
+ size 5679730
assets/sample_interview.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:58f5bf2314c2705d1dd9f305311fd5d1d543ed0ba625d8d8f315d5be8d71039a
3
+ size 1107217
process_interview.py ADDED
@@ -0,0 +1,1540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EvalBot audio analysis pipeline.
2
+
3
+ Recorded interview in -> PDF + JSON candidate report out.
4
+
5
+ Steps:
6
+ 1. Convert any audio/video to 16 kHz mono WAV (ffmpeg)
7
+ 2. Transcribe with speaker diarization (AssemblyAI)
8
+ 3. Extract prosodic features per utterance (librosa)
9
+ 4. Identify speakers with NVIDIA NeMo TitaNet embeddings + Pinecone vector index
10
+ 5. Classify each speaker as Interviewer / Interviewee (Random Forest trained on
11
+ the labeled MIT interview transcripts)
12
+ 6. Analyze the interviewee's voice (rate, fillers, repetition, pitch, intensity,
13
+ confidence / anxiety / hesitation)
14
+ 7. Generate a structured evaluation with Gemini
15
+ 8. Render a multi-page PDF report and a JSON export
16
+ """
17
+ import csv
18
+ import io
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import threading
25
+ import time
26
+ import uuid
27
+ from typing import Dict, List, Optional
28
+ from xml.sax.saxutils import escape
29
+
30
+ import joblib
31
+ import librosa
32
+ import numpy as np
33
+ import requests
34
+ import soundfile as sf
35
+
36
+ import matplotlib
37
+
38
+ matplotlib.use("Agg")
39
+ import matplotlib.pyplot as plt # noqa: E402
40
+ from reportlab.lib import colors # noqa: E402
41
+ from reportlab.lib.pagesizes import letter # noqa: E402
42
+ from reportlab.lib.styles import ParagraphStyle # noqa: E402
43
+ from reportlab.lib.units import inch # noqa: E402
44
+ from reportlab.platypus import ( # noqa: E402
45
+ Image, KeepTogether, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
46
+ )
47
+ from sklearn.ensemble import RandomForestClassifier # noqa: E402
48
+ from sklearn.feature_extraction.text import TfidfVectorizer # noqa: E402
49
+ from sklearn.model_selection import GroupShuffleSplit # noqa: E402
50
+ from sklearn.pipeline import FeatureUnion, Pipeline # noqa: E402
51
+ from sklearn.preprocessing import FunctionTransformer # noqa: E402
52
+
53
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
54
+ logger = logging.getLogger("evalbot")
55
+ logging.getLogger("nemo_logger").setLevel(logging.ERROR)
56
+ logging.getLogger("nemo").setLevel(logging.ERROR)
57
+
58
+ # --------------------------------------------------------------------------------------
59
+ # Configuration (all secrets come from environment variables / Space secrets)
60
+ # --------------------------------------------------------------------------------------
61
+ OUTPUT_DIR = os.getenv("EVALBOT_WORK_DIR", "./processed_audio")
62
+ MODEL_DIR = os.getenv("EVALBOT_MODEL_DIR", "./models")
63
+ TRAINING_CSV = os.getenv("EVALBOT_TRAINING_CSV", "interview_transcripts_by_turkers.csv")
64
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
65
+ os.makedirs(MODEL_DIR, exist_ok=True)
66
+
67
+ ASSEMBLYAI_API_KEY = os.getenv("ASSEMBLYAI_API_KEY") or os.getenv("ASSEMBLYAI_KEY")
68
+ PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
69
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
70
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.5-flash-lite")
71
+ GEMINI_TIMEOUT_S = int(os.getenv("GEMINI_TIMEOUT_S", "60"))
72
+ # Tried in order if the configured model is unavailable (Google retires model IDs regularly)
73
+ GEMINI_FALLBACK_MODELS = [m.strip() for m in os.getenv(
74
+ "GEMINI_FALLBACK_MODELS", "gemini-3.6-flash,gemini-flash-latest,gemini-2.5-flash").split(",") if m.strip()]
75
+ PINECONE_INDEX = os.getenv("PINECONE_INDEX", "interview-speaker-embeddings")
76
+ SPEAKER_MATCH_THRESHOLD = float(os.getenv("SPEAKER_MATCH_THRESHOLD", "0.7"))
77
+ TRANSCRIPTION_TIMEOUT_S = int(os.getenv("TRANSCRIPTION_TIMEOUT_S", "900"))
78
+
79
+ ASSEMBLYAI_BASE = "https://api.assemblyai.com/v2"
80
+ FILLER_PATTERN = re.compile(
81
+ r"\b(um+|uh+|uhm+|erm*|ah+|hmm+|mhm|you know|i mean|kind of|sort of|basically|actually|like)\b"
82
+ )
83
+ QUESTION_WORDS = re.compile(r"\b(why|how|what|when|where|who|which|tell me|describe|can you|could you)\b")
84
+
85
+
86
+ def validate_env_vars() -> Dict[str, bool]:
87
+ """Report which external services are configured. Only AssemblyAI is mandatory."""
88
+ status = {
89
+ "assemblyai": bool(ASSEMBLYAI_API_KEY),
90
+ "pinecone": bool(PINECONE_API_KEY), # optional: falls back to the built-in local index
91
+ "gemini": bool(GEMINI_API_KEY),
92
+ }
93
+ for name, ok in status.items():
94
+ if not ok:
95
+ level = logging.ERROR if name == "assemblyai" else logging.WARNING
96
+ logger.log(level, "%s API key not set; %s", name,
97
+ "transcription will fail" if name == "assemblyai" else "using the built-in local speaker index" if name == "pinecone" else "this step will be skipped")
98
+ return status
99
+
100
+
101
+ # --------------------------------------------------------------------------------------
102
+ # Lazy service / model initialization (loaded once, on first use)
103
+ # --------------------------------------------------------------------------------------
104
+ _init_lock = threading.Lock()
105
+ _services: Dict[str, object] = {}
106
+
107
+
108
+ def _get_speaker_model():
109
+ with _init_lock:
110
+ if "speaker_model" not in _services:
111
+ import torch
112
+ from nemo.collections.asr.models import EncDecSpeakerLabelModel
113
+
114
+ torch.set_num_threads(max(1, os.cpu_count() or 1))
115
+ logger.info("Loading NVIDIA TitaNet speaker verification model...")
116
+ model = EncDecSpeakerLabelModel.from_pretrained(
117
+ "nvidia/speakerverification_en_titanet_large", map_location=torch.device("cpu")
118
+ )
119
+ model.eval()
120
+ _services["speaker_model"] = model
121
+ return _services["speaker_model"]
122
+
123
+
124
+ class LocalSpeakerIndex:
125
+ """Small built-in vector store used when Pinecone is not configured.
126
+
127
+ Stores L2-normalized speaker embeddings in a .npz file and matches by cosine
128
+ similarity. Mirrors the subset of the Pinecone Index API that the pipeline uses.
129
+ """
130
+
131
+ def __init__(self, path: str):
132
+ self.path = path
133
+ self._lock = threading.Lock()
134
+ self.ids: List[str] = []
135
+ self.names: List[str] = []
136
+ self.vectors = np.zeros((0, 192), dtype=np.float32)
137
+ if os.path.exists(path):
138
+ try:
139
+ data = np.load(path, allow_pickle=False)
140
+ self.ids, self.names = list(data["ids"]), list(data["names"])
141
+ self.vectors = data["vectors"].astype(np.float32)
142
+ except Exception as e:
143
+ logger.warning("Could not load local speaker index (%s); starting empty", e)
144
+
145
+ def query(self, vector, top_k=1, include_metadata=True):
146
+ with self._lock:
147
+ if not len(self.ids):
148
+ return {"matches": []}
149
+ v = np.asarray(vector, dtype=np.float32)
150
+ v = v / (np.linalg.norm(v) + 1e-9)
151
+ scores = self.vectors @ v
152
+ order = np.argsort(-scores)[:top_k]
153
+ return {"matches": [{"id": self.ids[i], "score": float(scores[i]),
154
+ "metadata": {"speaker_name": self.names[i]}} for i in order]}
155
+
156
+ def upsert(self, vectors):
157
+ with self._lock:
158
+ for item in vectors:
159
+ v = np.asarray(item["values"], dtype=np.float32)
160
+ v = v / (np.linalg.norm(v) + 1e-9)
161
+ self.ids.append(item["id"])
162
+ self.names.append(item.get("metadata", {}).get("speaker_name", item["id"]))
163
+ self.vectors = np.vstack([self.vectors, v[None, :]])
164
+ tmp = self.path + ".tmp.npz"
165
+ np.savez(tmp, ids=np.array(self.ids), names=np.array(self.names), vectors=self.vectors)
166
+ os.replace(tmp, self.path)
167
+
168
+
169
+ def _local_index_path() -> str:
170
+ # /data is the persistent-storage mount on Hugging Face Spaces (if enabled)
171
+ base = os.getenv("SPEAKER_INDEX_DIR") or ("/data" if os.path.isdir("/data") and os.access("/data", os.W_OK) else MODEL_DIR)
172
+ os.makedirs(base, exist_ok=True)
173
+ return os.path.join(base, "speaker_index.npz")
174
+
175
+
176
+ def _get_pinecone_index():
177
+ """Speaker vector index: Pinecone if a key is configured, otherwise the built-in local index."""
178
+ if not PINECONE_API_KEY:
179
+ with _init_lock:
180
+ if "local_index" not in _services:
181
+ _services["local_index"] = LocalSpeakerIndex(_local_index_path())
182
+ logger.info("Using built-in local speaker index at %s", _services["local_index"].path)
183
+ return _services["local_index"]
184
+ with _init_lock:
185
+ if "pinecone_index" not in _services:
186
+ from pinecone import Pinecone, ServerlessSpec
187
+
188
+ pc = Pinecone(api_key=PINECONE_API_KEY)
189
+ existing = [i["name"] for i in pc.list_indexes()]
190
+ if PINECONE_INDEX not in existing:
191
+ logger.info("Creating Pinecone index %s", PINECONE_INDEX)
192
+ pc.create_index(
193
+ name=PINECONE_INDEX, dimension=192, metric="cosine",
194
+ spec=ServerlessSpec(cloud="aws", region="us-east-1"),
195
+ )
196
+ while not pc.describe_index(PINECONE_INDEX).status["ready"]:
197
+ time.sleep(1)
198
+ _services["pinecone_index"] = pc.Index(PINECONE_INDEX)
199
+ return _services["pinecone_index"]
200
+
201
+
202
+ def _get_gemini_client():
203
+ if not GEMINI_API_KEY:
204
+ return None
205
+ with _init_lock:
206
+ if "gemini" not in _services:
207
+ from google import genai
208
+ from google.genai import types
209
+
210
+ _services["gemini"] = genai.Client(
211
+ api_key=GEMINI_API_KEY, http_options=types.HttpOptions(timeout=GEMINI_TIMEOUT_S * 1000))
212
+ return _services["gemini"]
213
+
214
+
215
+ def load_models() -> None:
216
+ """Warm up everything at startup so the first request is not slow."""
217
+ validate_env_vars()
218
+ get_role_classifier()
219
+ _get_speaker_model()
220
+ try:
221
+ _get_pinecone_index()
222
+ except Exception as e: # Pinecone is optional
223
+ logger.warning("Pinecone unavailable: %s", e)
224
+
225
+
226
+ # --------------------------------------------------------------------------------------
227
+ # 1. Audio conversion
228
+ # --------------------------------------------------------------------------------------
229
+ def convert_to_wav(input_path: str, output_dir: str = OUTPUT_DIR) -> str:
230
+ os.makedirs(output_dir, exist_ok=True)
231
+ output_path = os.path.join(output_dir, f"{uuid.uuid4().hex}.wav")
232
+ command = ["ffmpeg", "-y", "-loglevel", "error", "-i", input_path, "-vn",
233
+ "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", output_path]
234
+ result = subprocess.run(command, capture_output=True, text=True)
235
+ if result.returncode != 0:
236
+ raise ValueError(f"Could not read the audio file: {result.stderr.strip()[:300]}")
237
+ logger.info("Converted to WAV (%.2f MB)", os.path.getsize(output_path) / 1024 / 1024)
238
+ return output_path
239
+
240
+
241
+ # --------------------------------------------------------------------------------------
242
+ # 2. Transcription (AssemblyAI, with speaker labels and disfluencies kept)
243
+ # --------------------------------------------------------------------------------------
244
+ def transcribe(audio_path: str, speakers_expected: Optional[int] = 2, diarize: bool = True) -> Dict:
245
+ if not ASSEMBLYAI_API_KEY:
246
+ raise RuntimeError("ASSEMBLYAI_API_KEY is not configured")
247
+ headers = {"authorization": ASSEMBLYAI_API_KEY}
248
+
249
+ with open(audio_path, "rb") as f:
250
+ upload = requests.post(f"{ASSEMBLYAI_BASE}/upload", headers=headers, data=f, timeout=300)
251
+ upload.raise_for_status()
252
+ audio_url = upload.json()["upload_url"]
253
+
254
+ payload = {
255
+ "audio_url": audio_url,
256
+ "speaker_labels": diarize,
257
+ "disfluencies": True, # keep "um"/"uh" so filler-word analysis is meaningful
258
+ "speech_models": ["universal-2"],
259
+ "language_code": "en",
260
+ }
261
+ if diarize and speakers_expected:
262
+ payload["speakers_expected"] = speakers_expected
263
+ job = requests.post(f"{ASSEMBLYAI_BASE}/transcript", headers=headers, json=payload, timeout=60)
264
+ if job.status_code != 200:
265
+ raise RuntimeError(f"AssemblyAI rejected the request: {job.status_code} {job.text[:300]}")
266
+ transcript_id = job.json()["id"]
267
+
268
+ deadline = time.time() + TRANSCRIPTION_TIMEOUT_S
269
+ while time.time() < deadline:
270
+ poll = requests.get(f"{ASSEMBLYAI_BASE}/transcript/{transcript_id}", headers=headers, timeout=60)
271
+ poll.raise_for_status()
272
+ result = poll.json()
273
+ if result["status"] == "completed":
274
+ return result
275
+ if result["status"] == "error":
276
+ raise RuntimeError(f"AssemblyAI error: {result.get('error')}")
277
+ time.sleep(3)
278
+ raise TimeoutError("Transcription timed out")
279
+
280
+
281
+ # --------------------------------------------------------------------------------------
282
+ # 3. Prosodic features
283
+ # --------------------------------------------------------------------------------------
284
+ EMPTY_PROSODY = {k: 0.0 for k in ("duration", "mean_pitch", "min_pitch", "max_pitch", "pitch_sd",
285
+ "intensityMean", "intensityMin", "intensityMax", "intensitySD")}
286
+
287
+
288
+ def extract_prosodic_features(y: np.ndarray, sr: int, start_ms: int, end_ms: int) -> Dict:
289
+ segment = y[int(start_ms * sr / 1000): int(end_ms * sr / 1000)]
290
+ if len(segment) < sr // 10:
291
+ return {**EMPTY_PROSODY, "duration": (end_ms - start_ms) / 1000}
292
+ try:
293
+ pitches = librosa.piptrack(y=segment, sr=sr, fmin=60, fmax=400)[0]
294
+ pitches = pitches[pitches > 0]
295
+ rms = librosa.feature.rms(y=segment)[0]
296
+ has_pitch = len(pitches) > 0
297
+ return {
298
+ "duration": (end_ms - start_ms) / 1000,
299
+ "mean_pitch": float(np.mean(pitches)) if has_pitch else 0.0,
300
+ "min_pitch": float(np.min(pitches)) if has_pitch else 0.0,
301
+ "max_pitch": float(np.max(pitches)) if has_pitch else 0.0,
302
+ "pitch_sd": float(np.std(pitches)) if has_pitch else 0.0,
303
+ "intensityMean": float(np.mean(rms)),
304
+ "intensityMin": float(np.min(rms)),
305
+ "intensityMax": float(np.max(rms)),
306
+ "intensitySD": float(np.std(rms)),
307
+ }
308
+ except Exception as e:
309
+ logger.warning("Prosodic feature extraction failed: %s", e)
310
+ return {**EMPTY_PROSODY, "duration": (end_ms - start_ms) / 1000}
311
+
312
+
313
+ # --------------------------------------------------------------------------------------
314
+ # 4. Speaker identification (TitaNet embeddings + Pinecone)
315
+ # --------------------------------------------------------------------------------------
316
+ def _speaker_embedding(y: np.ndarray, sr: int, utterances: List[Dict], max_seconds: float = 45.0) -> np.ndarray:
317
+ """One embedding per diarized speaker, from up to `max_seconds` of their speech."""
318
+ chunks, total = [], 0.0
319
+ for u in sorted(utterances, key=lambda u: u["end"] - u["start"], reverse=True):
320
+ seg = y[int(u["start"] * sr / 1000): int(u["end"] * sr / 1000)]
321
+ chunks.append(seg)
322
+ total += len(seg) / sr
323
+ if total >= max_seconds:
324
+ break
325
+ audio = np.concatenate(chunks) if chunks else np.zeros(sr, dtype=np.float32)
326
+ tmp = os.path.join(OUTPUT_DIR, f"spk_{uuid.uuid4().hex}.wav")
327
+ sf.write(tmp, audio, sr)
328
+ try:
329
+ import torch
330
+
331
+ with torch.no_grad():
332
+ emb = _get_speaker_model().get_embedding(tmp).cpu().numpy().flatten()
333
+ return emb / (np.linalg.norm(emb) + 1e-9)
334
+ finally:
335
+ os.remove(tmp)
336
+
337
+
338
+ def identify_speakers(transcript: Dict, y: np.ndarray, sr: int, enroll: bool = True) -> Dict[str, Dict]:
339
+ """Map each diarization label (A, B, ...) to a persistent speaker identity."""
340
+ by_label: Dict[str, List[Dict]] = {}
341
+ for u in transcript["utterances"]:
342
+ by_label.setdefault(u["speaker"], []).append(u)
343
+
344
+ index = None
345
+ try:
346
+ index = _get_pinecone_index()
347
+ except Exception as e:
348
+ logger.warning("Pinecone unavailable, skipping speaker matching: %s", e)
349
+
350
+ speakers = {}
351
+ for label, utts in by_label.items():
352
+ info = {"label": label, "speaker_id": f"session_{label}", "speaker_name": f"Speaker {label}",
353
+ "matched_existing": False, "match_score": None}
354
+ try:
355
+ emb = _speaker_embedding(y, sr, utts)
356
+ if index is not None:
357
+ res = index.query(vector=emb.tolist(), top_k=1, include_metadata=True)
358
+ matches = [m if isinstance(m, dict) else m.to_dict() for m in (res["matches"] or [])]
359
+ if matches and matches[0]["score"] >= SPEAKER_MATCH_THRESHOLD:
360
+ info.update(speaker_id=matches[0]["id"],
361
+ speaker_name=matches[0]["metadata"].get("speaker_name", matches[0]["id"]),
362
+ matched_existing=True, match_score=round(float(matches[0]["score"]), 3))
363
+ elif enroll:
364
+ new_id = f"spk_{uuid.uuid4().hex[:8]}"
365
+ index.upsert(vectors=[{"id": new_id, "values": emb.tolist(),
366
+ "metadata": {"speaker_name": f"Speaker {new_id[-4:]}",
367
+ "enrolled_at": time.strftime("%Y-%m-%d")}}])
368
+ info.update(speaker_id=new_id, speaker_name=f"Speaker {new_id[-4:]}")
369
+ except Exception as e:
370
+ logger.warning("Speaker embedding failed for %s: %s", label, e)
371
+ speakers[label] = info
372
+ return speakers
373
+
374
+
375
+ # --------------------------------------------------------------------------------------
376
+ # 5. Role classification (Random Forest trained on labeled MIT interview transcripts)
377
+ # --------------------------------------------------------------------------------------
378
+ def _handcrafted_features(texts: List[str]) -> np.ndarray:
379
+ rows = []
380
+ for t in texts:
381
+ low = t.lower()
382
+ words = low.split()
383
+ n = max(len(words), 1)
384
+ rows.append([
385
+ int(t.strip().endswith("?")),
386
+ t.count("?"),
387
+ len(QUESTION_WORDS.findall(low)),
388
+ np.log1p(len(words)),
389
+ sum(1 for w in words if w in ("i", "i'm", "my", "me", "i've")) / n,
390
+ sum(1 for w in words if w in ("you", "your", "you're")) / n,
391
+ len(FILLER_PATTERN.findall(low)) / n,
392
+ ])
393
+ return np.array(rows, dtype=float)
394
+
395
+
396
+ def _load_training_data(path: str):
397
+ texts, labels, groups = [], [], []
398
+ with open(path, encoding="utf-8", errors="replace") as f:
399
+ for row in csv.reader(f):
400
+ if len(row) < 2:
401
+ continue
402
+ for turn in row[1].split("|"):
403
+ if ":" not in turn:
404
+ continue
405
+ who, text = turn.split(":", 1)
406
+ who = who.strip().lower()
407
+ text = re.sub(r"\[.*?\]", " ", text).strip()
408
+ if not text:
409
+ continue
410
+ if who.startswith("interviewer") or who.startswith("intrviewer"):
411
+ labels.append("Interviewer")
412
+ elif who.startswith("interview") or who.startswith("inerviewee"):
413
+ labels.append("Interviewee")
414
+ else:
415
+ continue
416
+ texts.append(text)
417
+ groups.append(row[0])
418
+ return texts, labels, groups
419
+
420
+
421
+ def _build_role_pipeline() -> Pipeline:
422
+ return Pipeline([
423
+ ("features", FeatureUnion([
424
+ ("tfidf", TfidfVectorizer(max_features=3000, ngram_range=(1, 2), sublinear_tf=True)),
425
+ ("handcrafted", FunctionTransformer(_handcrafted_features)),
426
+ ])),
427
+ ("clf", RandomForestClassifier(n_estimators=300, min_samples_leaf=2, class_weight="balanced",
428
+ random_state=42, n_jobs=-1)),
429
+ ])
430
+
431
+
432
+ def train_role_classifier(csv_path: str = TRAINING_CSV) -> Pipeline:
433
+ texts, labels, groups = _load_training_data(csv_path)
434
+ # Hold out whole interviews to get an honest accuracy estimate
435
+ split = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
436
+ train_idx, test_idx = next(split.split(texts, labels, groups))
437
+ model = _build_role_pipeline()
438
+ model.fit([texts[i] for i in train_idx], [labels[i] for i in train_idx])
439
+ acc = model.score([texts[i] for i in test_idx], [labels[i] for i in test_idx])
440
+ logger.info("Role classifier: %d utterances, held-out utterance accuracy %.3f", len(texts), acc)
441
+
442
+ model = _build_role_pipeline().fit(texts, labels)
443
+ model.heldout_accuracy_ = float(acc)
444
+ joblib.dump(model, os.path.join(MODEL_DIR, "role_classifier.joblib"))
445
+ return model
446
+
447
+
448
+ BUNDLED_ROLE_MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "models", "role_classifier.joblib")
449
+
450
+
451
+ def get_role_classifier() -> Pipeline:
452
+ """Load the pre-trained role classifier.
453
+
454
+ The model ships with the app (assets/models). The training transcripts come from the
455
+ MIT Interview Dataset, whose license does not allow redistribution, so they are not
456
+ included; retraining only happens if EVALBOT_TRAINING_CSV points to a local copy.
457
+ """
458
+ with _init_lock:
459
+ if "role_clf" not in _services:
460
+ model = None
461
+ for path in (os.path.join(MODEL_DIR, "role_classifier.joblib"), BUNDLED_ROLE_MODEL):
462
+ if os.path.exists(path):
463
+ try:
464
+ model = joblib.load(path)
465
+ break
466
+ except Exception as e:
467
+ logger.warning("Could not load role classifier from %s (%s)", path, e)
468
+ _services["role_clf"] = model
469
+ if _services["role_clf"] is None:
470
+ if not os.path.exists(TRAINING_CSV):
471
+ raise RuntimeError("Role classifier model not found and no training data available")
472
+ _services["role_clf"] = train_role_classifier()
473
+ return _services["role_clf"]
474
+
475
+
476
+ def classify_roles(utterances: List[Dict]) -> Dict[str, str]:
477
+ """Assign one role per speaker by aggregating utterance-level probabilities."""
478
+ model = get_role_classifier()
479
+ texts = [u["text"] for u in utterances]
480
+ probs = model.predict_proba(texts)
481
+ interviewer_col = list(model.classes_).index("Interviewer")
482
+
483
+ score: Dict[str, float] = {}
484
+ weight: Dict[str, float] = {}
485
+ for u, p in zip(utterances, probs):
486
+ w = np.log1p(len(u["text"].split()))
487
+ score[u["speaker"]] = score.get(u["speaker"], 0.0) + w * p[interviewer_col]
488
+ weight[u["speaker"]] = weight.get(u["speaker"], 0.0) + w
489
+ interviewer_prob = {s: score[s] / weight[s] for s in score if weight[s] > 0}
490
+
491
+ if len(interviewer_prob) == 1:
492
+ only = next(iter(interviewer_prob))
493
+ return {only: "Interviewer" if interviewer_prob[only] > 0.5 else "Interviewee"}
494
+ interviewer = max(interviewer_prob, key=interviewer_prob.get)
495
+ return {s: ("Interviewer" if s == interviewer else "Interviewee") for s in interviewer_prob}
496
+
497
+
498
+ # --------------------------------------------------------------------------------------
499
+ # 6. Interviewee voice analysis
500
+ # --------------------------------------------------------------------------------------
501
+ def analyze_interviewee_voice(y: np.ndarray, sr: int, utterances: List[Dict]) -> Dict:
502
+ interviewee = [u for u in utterances if u["role"] == "Interviewee"]
503
+ if not interviewee:
504
+ return {"error": "No interviewee speech detected"}
505
+
506
+ segments = [y[int(u["start"] * sr / 1000): int(u["end"] * sr / 1000)] for u in interviewee]
507
+ segments = [s for s in segments if len(s) > sr // 10]
508
+
509
+ total_duration = sum(u["prosodic_features"]["duration"] for u in interviewee)
510
+ words = " ".join(u["text"].lower() for u in interviewee)
511
+ word_list = re.findall(r"[a-z']+", words)
512
+ total_words = len(word_list)
513
+ speaking_rate = total_words / total_duration if total_duration > 0 else 0.0
514
+
515
+ filler_hits = FILLER_PATTERN.findall(words)
516
+ filler_ratio = len(filler_hits) / total_words if total_words else 0.0
517
+ filler_breakdown: Dict[str, int] = {}
518
+ for f in filler_hits:
519
+ filler_breakdown[f] = filler_breakdown.get(f, 0) + 1
520
+
521
+ bigrams = list(zip(word_list, word_list[1:]))
522
+ immediate_repeats = sum(1 for a, b in zip(word_list, word_list[1:]) if a == b)
523
+ bigram_counts: Dict[tuple, int] = {}
524
+ for bg in bigrams:
525
+ bigram_counts[bg] = bigram_counts.get(bg, 0) + 1
526
+ repeated_bigrams = sum(c - 1 for c in bigram_counts.values() if c > 1)
527
+ repetition_score = (immediate_repeats + 0.25 * repeated_bigrams) / total_words if total_words else 0.0
528
+
529
+ pitches: List[float] = []
530
+ for seg in segments:
531
+ f0, voiced, _ = librosa.pyin(seg, fmin=70, fmax=350, sr=sr, frame_length=1024)
532
+ pitches.extend(f0[voiced & ~np.isnan(f0)].tolist())
533
+ pitches_arr = np.array(pitches)
534
+ pitch_mean = float(pitches_arr.mean()) if len(pitches_arr) else 0.0
535
+ pitch_std = float(pitches_arr.std()) if len(pitches_arr) else 0.0
536
+ jitter = float(np.mean(np.abs(np.diff(pitches_arr))) / pitch_mean) if len(pitches_arr) > 1 and pitch_mean else 0.0
537
+
538
+ intensities = np.concatenate([librosa.feature.rms(y=s)[0] for s in segments]) if segments else np.array([])
539
+ intensity_mean = float(intensities.mean()) if len(intensities) else 0.0
540
+ intensity_std = float(intensities.std()) if len(intensities) else 0.0
541
+ shimmer = float(np.mean(np.abs(np.diff(intensities))) / intensity_mean) if len(intensities) > 1 and intensity_mean else 0.0
542
+
543
+ # Heuristic composite scores, clamped to [0, 1]
544
+ pitch_cv = pitch_std / pitch_mean if pitch_mean else 0.0
545
+ anxiety = float(np.clip(0.6 * pitch_cv + 0.4 * (jitter + shimmer) / 2, 0, 1))
546
+ rate_penalty = min(abs(speaking_rate - 2.5) / 2.5, 1.0)
547
+ intensity_cv = intensity_std / intensity_mean if intensity_mean else 1.0
548
+ confidence = float(np.clip(1 - (0.35 * min(intensity_cv, 1) + 0.35 * min(filler_ratio * 8, 1)
549
+ + 0.15 * rate_penalty + 0.15 * anxiety), 0, 1))
550
+ hesitation = float(np.clip(filler_ratio * 5 + repetition_score * 5, 0, 1))
551
+
552
+ def level(v, hi, mid, labels=("high", "moderate", "low")):
553
+ return labels[0] if v > hi else labels[1] if v > mid else labels[2]
554
+
555
+ fluency = ("fluent" if filler_ratio < 0.03 and repetition_score < 0.02 else
556
+ "moderate" if filler_ratio < 0.07 and repetition_score < 0.05 else "disfluent")
557
+
558
+ return {
559
+ "speaking_rate": round(speaking_rate, 2),
560
+ "speaking_time_seconds": round(total_duration, 1),
561
+ "word_count": total_words,
562
+ "filler_ratio": round(filler_ratio, 4),
563
+ "filler_words": dict(sorted(filler_breakdown.items(), key=lambda kv: -kv[1])),
564
+ "repetition_score": round(repetition_score, 4),
565
+ "pitch_analysis": {"mean_hz": round(pitch_mean, 1), "std_dev_hz": round(pitch_std, 1),
566
+ "jitter": round(jitter, 4)},
567
+ "intensity_analysis": {"mean_rms": round(intensity_mean, 4), "std_dev_rms": round(intensity_std, 4),
568
+ "shimmer": round(shimmer, 4)},
569
+ "composite_scores": {"anxiety": round(anxiety, 3), "confidence": round(confidence, 3),
570
+ "hesitation": round(hesitation, 3)},
571
+ "interpretation": {"anxiety_level": level(anxiety, 0.35, 0.18),
572
+ "confidence_level": level(confidence, 0.7, 0.5),
573
+ "fluency_level": fluency},
574
+ }
575
+
576
+
577
+ # --------------------------------------------------------------------------------------
578
+ # Question / answer pairing
579
+ # --------------------------------------------------------------------------------------
580
+ def build_qa_pairs(utterances: List[Dict], max_pairs: int = 25) -> List[Dict]:
581
+ """Merge consecutive turns by role, then pair each interviewer block with the answer after it."""
582
+ blocks: List[Dict] = []
583
+ for u in utterances:
584
+ if blocks and blocks[-1]["role"] == u["role"]:
585
+ blocks[-1]["text"] += " " + u["text"]
586
+ blocks[-1]["end"] = u["end"]
587
+ else:
588
+ blocks.append({"role": u["role"], "text": u["text"], "start": u["start"], "end": u["end"]})
589
+ pairs = []
590
+ for q, a in zip(blocks, blocks[1:]):
591
+ if q["role"] == "Interviewer" and a["role"] == "Interviewee" and len(a["text"].split()) >= 4:
592
+ pairs.append({"index": len(pairs) + 1, "question": q["text"], "answer": a["text"],
593
+ "question_start": q["start"], "answer_start": a["start"], "answer_end": a["end"],
594
+ "answer_seconds": round((a["end"] - a["start"]) / 1000, 1)})
595
+ if len(pairs) >= max_pairs:
596
+ break
597
+ return pairs
598
+
599
+
600
+ # --------------------------------------------------------------------------------------
601
+ # 7. Evaluation with Gemini (structured JSON output)
602
+ # --------------------------------------------------------------------------------------
603
+ EVALUATION_SCHEMA = {
604
+ "type": "object",
605
+ "properties": {
606
+ "content_score": {"type": "integer", "description": "0-100 quality of the answers' content"},
607
+ "executive_summary": {"type": "array", "items": {"type": "string"}},
608
+ "communication": {"type": "array", "items": {"type": "string"}},
609
+ "communication_tip": {"type": "string"},
610
+ "strengths": {"type": "array", "items": {"type": "object", "properties": {
611
+ "competency": {"type": "string"}, "explanation": {"type": "string"},
612
+ "evidence": {"type": "string"}}, "required": ["competency", "explanation", "evidence"]}},
613
+ "weaknesses": {"type": "array", "items": {"type": "object", "properties": {
614
+ "area": {"type": "string"}, "feedback": {"type": "string"}},
615
+ "required": ["area", "feedback"]}},
616
+ "questions": {"type": "array", "items": {"type": "object", "properties": {
617
+ "index": {"type": "integer"},
618
+ "score": {"type": "integer", "description": "0-10"},
619
+ "what_went_well": {"type": "string"},
620
+ "what_to_improve": {"type": "string"},
621
+ "improved_answer": {"type": "string"}},
622
+ "required": ["index", "score", "what_went_well", "what_to_improve", "improved_answer"]}},
623
+ "role_fit": {"type": "array", "items": {"type": "string"}},
624
+ "recommendations": {"type": "array", "items": {"type": "object", "properties": {
625
+ "category": {"type": "string", "enum": ["Communication Skills", "Content Delivery",
626
+ "Professional Presentation"]},
627
+ "recommendation": {"type": "string"}, "how_to": {"type": "string"}},
628
+ "required": ["category", "recommendation", "how_to"]}},
629
+ "next_steps": {"type": "array", "items": {"type": "string"}},
630
+ },
631
+ "required": ["content_score", "executive_summary", "communication", "communication_tip", "strengths",
632
+ "weaknesses", "questions", "role_fit", "recommendations", "next_steps"],
633
+ }
634
+
635
+
636
+ def _language_rule(lang: str) -> str:
637
+ if lang == "ar":
638
+ return ("- Write ALL free-text values in clear Modern Standard Arabic. Keep technical terms "
639
+ "(e.g. API, Spark, SQL) and direct quotes from the transcript in English.\n"
640
+ "- improved_answer must stay in ENGLISH, because the interview itself is in English.\n")
641
+ return "- Write all text in English.\n"
642
+
643
+
644
+ def _gemini_json(prompt: str, schema: Dict, temperature: float = 0.4) -> Optional[Dict]:
645
+ client = _get_gemini_client()
646
+ if client is None:
647
+ return None
648
+ from google.genai import types
649
+
650
+ global GEMINI_MODEL
651
+ models = [GEMINI_MODEL] + [m for m in GEMINI_FALLBACK_MODELS if m != GEMINI_MODEL]
652
+ config = types.GenerateContentConfig(response_mime_type="application/json",
653
+ response_schema=schema, temperature=temperature)
654
+ for model in models:
655
+ for attempt in range(3):
656
+ try:
657
+ t0 = time.time()
658
+ response = client.models.generate_content(model=model, contents=prompt, config=config)
659
+ logger.info("Gemini %s answered in %.1fs", model, time.time() - t0)
660
+ if model != GEMINI_MODEL:
661
+ logger.info("Switching Gemini model to %s", model)
662
+ GEMINI_MODEL = model
663
+ return json.loads(response.text)
664
+ except Exception as e:
665
+ msg = str(e).lower()
666
+ unavailable = any(k in msg for k in ("not found", "404", "is not supported", "no longer available",
667
+ "deprecated", "permission_denied", "not available"))
668
+ overloaded = any(k in msg for k in ("503", "unavailable", "high demand", "overloaded", "429",
669
+ "resource_exhausted", "timed out", "timeout", "deadline"))
670
+ logger.warning("Gemini (%s) attempt %d failed: %s", model, attempt + 1, str(e)[:300])
671
+ if unavailable or (overloaded and attempt >= 1):
672
+ break # try the next model
673
+ time.sleep(1.5 * (attempt + 1))
674
+ return None
675
+
676
+
677
+ def _format_transcript(utterances: List[Dict], limit_chars: int = 50000) -> str:
678
+ return "\n".join(f"{u['role']}: {u['text']}" for u in utterances)[:limit_chars]
679
+
680
+
681
+ def generate_evaluation(analysis: Dict, lang: str = "en") -> Optional[Dict]:
682
+ voice = analysis["voice_analysis"]
683
+ metrics = ({k: voice.get(k) for k in ("speaking_rate", "filler_ratio", "repetition_score",
684
+ "composite_scores", "interpretation")}
685
+ if "error" not in voice else voice)
686
+ qa_text = "\n\n".join(f"[{p['index']}] Q: {p['question']}\n A: {p['answer']}" for p in analysis["qa_pairs"])
687
+ prompt = f"""You are EvalBot, a senior HR analyst and interview coach. Evaluate the INTERVIEWEE.
688
+
689
+ Rules:
690
+ - Base every point on what the interviewee actually said (quote or paraphrase as evidence) or on the metrics.
691
+ - Be specific, professional and constructive. No generic filler. Do not invent facts about the candidate.
692
+ - 4-8 strengths, 3-8 weaknesses, exactly 5 recommendations, 5 next steps, 3 bullets each for
693
+ executive summary, communication and role fit.
694
+ - content_score: 0-100 for the quality, relevance and depth of the answers (not the voice).
695
+ - questions: one entry per numbered question-answer pair below, using the same index.
696
+ score is 0-10 for that answer. improved_answer is a stronger version of the candidate's answer
697
+ (3-5 sentences, first person, STAR structure where it fits) that reuses only facts the candidate
698
+ mentioned; where a concrete detail would help, write a placeholder like [number] or [tool].
699
+ {_language_rule(lang)}
700
+ Vocal metrics (computed from audio):
701
+ {json.dumps(metrics, indent=1)}
702
+ Interview duration: {analysis['interview']['duration_seconds']:.0f} s, speaker turns: {analysis['interview']['speaker_turns']}
703
+
704
+ Question-answer pairs:
705
+ {qa_text or '(none detected)'}
706
+
707
+ Full transcript:
708
+ {_format_transcript(analysis['transcript'])}
709
+ """
710
+ return _gemini_json(prompt, EVALUATION_SCHEMA)
711
+
712
+
713
+ def fallback_evaluation(analysis: Dict, lang: str = "en") -> Dict:
714
+ """Metric-only evaluation used when Gemini is not configured or unavailable."""
715
+ v = analysis["voice_analysis"]
716
+ empty = {"content_score": None, "communication": [], "communication_tip": "", "strengths": [],
717
+ "weaknesses": [], "questions": [], "role_fit": [], "recommendations": [], "next_steps": [],
718
+ "ai_generated": False}
719
+ if "error" in v:
720
+ return {**empty, "executive_summary": [v["error"]]}
721
+ i = v["interpretation"]
722
+ if lang == "ar":
723
+ comm = [
724
+ f"معدل الكلام {v['speaking_rate']} كلمة في الثانية (المعدل الطبيعي للكلام الواضح من 2 إلى 3).",
725
+ f"كلمات الحشو تمثل {v['filler_ratio'] * 100:.1f}% من كلام المرشّح، ومستوى الطلاقة: {L('ar', i['fluency_level'])}.",
726
+ f"الثقة الصوتية: {L('ar', i['confidence_level'])}، والقلق: {L('ar', i['anxiety_level'])}، بناءً على ثبات طبقة الصوت وشدته.",
727
+ ]
728
+ note = "لم يكن تقييم المحتوى بالذكاء الاصطناعي متاحاً في هذه المرة، فالتقرير مبني على مقاييس الصوت فقط."
729
+ tip = "توقّف لحظة بدل استخدام كلمات الحشو، ورتّب إجاباتك: الموقف، ثم ما فعلته، ثم النتيجة."
730
+ else:
731
+ comm = [
732
+ f"Speaking rate of {v['speaking_rate']} words/sec "
733
+ f"({'within' if 2.0 <= v['speaking_rate'] <= 3.0 else 'outside'} the 2-3 words/sec range typical of clear speech).",
734
+ f"Filler words make up {v['filler_ratio'] * 100:.1f}% of the interviewee's words; fluency is {i['fluency_level']}.",
735
+ f"Vocal confidence is {i['confidence_level']} and anxiety is {i['anxiety_level']}, based on pitch and loudness stability.",
736
+ ]
737
+ note = "Automated content evaluation was unavailable for this run; this report is based on vocal metrics only."
738
+ tip = "Pause briefly instead of using filler words, and keep answers structured (situation, action, result)."
739
+ return {**empty, "executive_summary": [note, comm[0]], "communication": comm, "communication_tip": tip}
740
+
741
+
742
+ # --------------------------------------------------------------------------------------
743
+ # Suitability score
744
+ # --------------------------------------------------------------------------------------
745
+ def calculate_acceptance_probability(voice: Dict, content_score: Optional[int]) -> float:
746
+ """Heuristic 0-100 suitability score: 60% answer content (if available), 40% delivery."""
747
+ if "error" in voice:
748
+ return float(content_score) if content_score is not None else 0.0
749
+ cs = voice["composite_scores"]
750
+ fluency_val = {"fluent": 1.0, "moderate": 0.6, "disfluent": 0.25}[voice["interpretation"]["fluency_level"]]
751
+ rate_score = max(0.0, 1 - abs(voice["speaking_rate"] - 2.5) / 2.5)
752
+ delivery = (0.35 * cs["confidence"] + 0.25 * (1 - cs["anxiety"]) + 0.25 * fluency_val + 0.15 * rate_score)
753
+ if content_score is None:
754
+ return round(delivery * 100, 1)
755
+ return round(0.6 * content_score + 0.4 * delivery * 100, 1)
756
+
757
+
758
+ def verdict_for(score: Optional[float], lang: str = "en", provisional: bool = False) -> str:
759
+ if provisional:
760
+ return L(lang, "verdict_provisional")
761
+ key = ("verdict_top" if score >= 80 else "verdict_strong" if score >= 65
762
+ else "verdict_moderate" if score >= 50 else "verdict_low")
763
+ return L(lang, key)
764
+
765
+
766
+ # --------------------------------------------------------------------------------------
767
+ # Labels (English / Arabic)
768
+ # --------------------------------------------------------------------------------------
769
+ LABELS = {
770
+ "en": {
771
+ "report_title": "Candidate Interview Analysis", "header": "EvalBot | Interview Analysis Report",
772
+ "footer": "Confidential - generated automatically by EvalBot", "page": "Page",
773
+ "candidate_id": "Candidate ID", "generated": "Generated",
774
+ "suitability": "Suitability score", "score_basis": "Content {content} (60%) + vocal delivery (40%)",
775
+ "interview": "Interview", "duration": "Duration", "minutes": "minutes", "speaker_turns": "Speaker turns",
776
+ "speakers": "Speakers", "interviewee_time": "Interviewee speaking time", "words": "words",
777
+ "questions_answered": "Questions answered",
778
+ "exec_summary": "Executive Summary", "comm_title": "Communication & Vocal Dynamics",
779
+ "metric": "Metric", "value": "Value", "meaning": "What it means",
780
+ "speaking_rate": "Speaking rate", "wps": "words/sec", "fillers": "Filler words", "of_words": "of words",
781
+ "most_frequent": "Most frequent", "none": "none", "repetition": "Repetition", "fluency": "Fluency",
782
+ "confidence": "Confidence", "anxiety": "Anxiety", "hesitation": "Hesitation", "pitch": "Pitch",
783
+ "m_rate": "2.0-3.0 words/sec is typical of clear, paced speech.",
784
+ "m_rep": "Repeated words and phrases; lower is better.", "m_flu": "Combines filler words and repetition.",
785
+ "m_conf": "Steady loudness, few fillers, comfortable pace.",
786
+ "m_anx": "Pitch variability and voice instability (jitter / shimmer).",
787
+ "m_pitch": "Average voice pitch and its variation.",
788
+ "observations": "Observations", "tip": "Tip for workplace communication",
789
+ "qa_title": "Question-by-Question Feedback", "question": "Question", "answer": "Answer (excerpt)",
790
+ "went_well": "What went well", "to_improve": "What to improve", "better_answer": "A stronger answer",
791
+ "competency_title": "Competency Evaluation", "strengths": "Strengths", "competency": "Competency",
792
+ "assessment": "Assessment and evidence", "evidence": "Evidence", "dev_areas": "Development areas",
793
+ "area": "Area", "feedback": "Actionable feedback", "role_fit": "Role Fit & Potential",
794
+ "recs": "Recommendations for the Candidate", "rec_area": "Area", "recommendation": "Recommendation",
795
+ "how_to": "How to practice", "next_steps": "Next Steps for Hiring Managers",
796
+ "appendix": "Appendix: Annotated Transcript",
797
+ "appendix_note": "Speaker roles were assigned automatically by the role classifier.",
798
+ "time": "Time", "role": "Role", "text": "Text", "Interviewer": "Interviewer", "Interviewee": "Interviewee",
799
+ "ai_unavailable": "Note: AI content evaluation was unavailable for this run, so sections that depend on it were omitted.",
800
+ "chart_vocal": "Vocal dynamics (0 = low, 1 = high)", "chart_talk": "Talk time",
801
+ "chart_q": "Score per question (0-10)",
802
+ "high": "High", "moderate": "Moderate", "low": "Low", "fluent": "Fluent", "disfluent": "Disfluent",
803
+ "Communication Skills": "Communication Skills", "Content Delivery": "Content Delivery",
804
+ "Professional Presentation": "Professional Presentation",
805
+ "verdict_top": "Outstanding candidate, recommended for advancement.",
806
+ "verdict_strong": "Strong candidate, suitable for further evaluation.",
807
+ "verdict_moderate": "Moderate potential, needs additional assessment.",
808
+ "verdict_low": "Limited fit at this stage; significant improvement needed.",
809
+ "verdict_provisional": "Provisional: based on vocal delivery only (answer content was not evaluated).",
810
+ },
811
+ "ar": {
812
+ "report_title": "تحليل مقابلة المرشّح", "header": "EvalBot | تقرير تحليل المقابلة",
813
+ "footer": "سري - تم إنشاؤه تلقائياً بواسطة EvalBot", "page": "صفحة",
814
+ "candidate_id": "رقم المرشّح", "generated": "تاريخ الإنشاء",
815
+ "suitability": "درجة الملاءمة", "score_basis": "المحتوى {content} (60%) + الأداء الصوتي (40%)",
816
+ "interview": "المقابلة", "duration": "المدة", "minutes": "دقيقة", "speaker_turns": "عدد المداخلات",
817
+ "speakers": "المتحدثون", "interviewee_time": "مدة كلام المرشّح", "words": "كلمة",
818
+ "questions_answered": "عدد الأسئلة المُجاب عليها",
819
+ "exec_summary": "الملخص التنفيذي", "comm_title": "التواصل والأداء الصوتي",
820
+ "metric": "المقياس", "value": "القيمة", "meaning": "المعنى",
821
+ "speaking_rate": "معدل الكلام", "wps": "كلمة/ثانية", "fillers": "كلمات الحشو", "of_words": "من الكلمات",
822
+ "most_frequent": "الأكثر تكراراً", "none": "لا يوجد", "repetition": "التكرار", "fluency": "الطلاقة",
823
+ "confidence": "الثقة", "anxiety": "القلق", "hesitation": "التردد", "pitch": "طبقة الصوت",
824
+ "m_rate": "من 2 إلى 3 كلمات في الثانية هو المعدل المعتاد للكلام الواضح.",
825
+ "m_rep": "تكرار الكلمات والعبارات، وكلما قلّ كان أفضل.", "m_flu": "يجمع بين كلمات الحشو والتكرار.",
826
+ "m_conf": "ثبات شدة الصوت، وقلة كلمات الحشو، وسرعة مريحة.",
827
+ "m_anx": "تذبذب طبقة الصوت وعدم ثباته.", "m_pitch": "متوسط طبقة الصوت ومدى تغيّرها.",
828
+ "observations": "ملاحظات", "tip": "نصيحة للتواصل في بيئة العمل",
829
+ "qa_title": "تقييم كل سؤال على حدة", "question": "السؤال", "answer": "الإجابة (مقتطف)",
830
+ "went_well": "ما كان جيداً", "to_improve": "ما يحتاج إلى تحسين", "better_answer": "إجابة أقوى",
831
+ "competency_title": "تقييم الكفاءات", "strengths": "نقاط القوة", "competency": "الكفاءة",
832
+ "assessment": "التقييم والدليل", "evidence": "الدليل", "dev_areas": "مجالات التطوير",
833
+ "area": "المجال", "feedback": "ملاحظات قابلة للتطبيق", "role_fit": "الملاءمة للدور والإمكانات",
834
+ "recs": "توصيات للمرشّح", "rec_area": "المجال", "recommendation": "التوصية",
835
+ "how_to": "طريقة التدريب", "next_steps": "الخطوات التالية لمسؤولي التوظيف",
836
+ "appendix": "ملحق: نص المقابلة",
837
+ "appendix_note": "تم تحديد دور كل متحدث تلقائياً بواسطة نموذج تصنيف الأدوار.",
838
+ "time": "الوقت", "role": "الدور", "text": "النص", "Interviewer": "المحاوِر", "Interviewee": "المرشّح",
839
+ "ai_unavailable": "ملاحظة: لم يكن تقييم المحتوى بالذكاء الاصطناعي متاحاً، فتم حذف الأقسام المعتمدة عليه.",
840
+ "chart_vocal": "الأداء الصوتي (0 = منخفض، 1 = مرتفع)", "chart_talk": "توزيع وقت الكلام",
841
+ "chart_q": "درجة كل سؤال (من 10)",
842
+ "high": "مرتفع", "moderate": "متوسط", "low": "منخفض", "fluent": "طليق", "disfluent": "متقطع",
843
+ "Communication Skills": "مهارات التواصل", "Content Delivery": "تقديم المحتوى",
844
+ "Professional Presentation": "الحضور المهني",
845
+ "verdict_top": "مرشّح متميز، يُنصح بانتقاله للمرحلة التالية.",
846
+ "verdict_strong": "مرشّح قوي، مناسب لمزيد من التقييم.",
847
+ "verdict_moderate": "إمكانات متوسطة، يحتاج إلى تقييم إضافي.",
848
+ "verdict_low": "ملاءمة محدودة حالياً، ويحتاج إلى تحسين كبير.",
849
+ "verdict_provisional": "مبدئي: مبني على الأداء الصوتي فقط (لم يتم تقييم محتوى الإجابات).",
850
+ },
851
+ }
852
+
853
+
854
+ def L(lang: str, key: str) -> str:
855
+ return LABELS.get(lang, LABELS["en"]).get(key, LABELS["en"].get(key, key))
856
+
857
+
858
+ # --------------------------------------------------------------------------------------
859
+ # 8. Charts + PDF report (English left-to-right, Arabic right-to-left)
860
+ # --------------------------------------------------------------------------------------
861
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT # noqa: E402
862
+ from reportlab.pdfbase import pdfmetrics # noqa: E402
863
+ from reportlab.pdfbase.ttfonts import TTFont # noqa: E402
864
+ from reportlab.platypus import Flowable # noqa: E402
865
+
866
+ FONT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "fonts")
867
+ BRAND = colors.HexColor("#0B3D91")
868
+ ACCENT = colors.HexColor("#1F6FEB")
869
+ MUTED = colors.HexColor("#5B6573")
870
+ INK = colors.HexColor("#222222")
871
+ GRID = colors.HexColor("#DDE4EE")
872
+ SOFT = colors.HexColor("#F4F7FB")
873
+ GOOD_BG = colors.HexColor("#EEF8EF")
874
+ BAD_BG = colors.HexColor("#FDF1F1")
875
+ ARABIC_RE = re.compile(r"[؀-ۿ]")
876
+ _fonts_registered = False
877
+
878
+
879
+ def _register_fonts() -> bool:
880
+ global _fonts_registered
881
+ if _fonts_registered:
882
+ return True
883
+ try:
884
+ pdfmetrics.registerFont(TTFont("Amiri", os.path.join(FONT_DIR, "Amiri-Regular.ttf")))
885
+ pdfmetrics.registerFont(TTFont("Amiri-Bold", os.path.join(FONT_DIR, "Amiri-Bold.ttf")))
886
+ _fonts_registered = True
887
+ except Exception as e:
888
+ logger.warning("Arabic font not available: %s", e)
889
+ return _fonts_registered
890
+
891
+
892
+ def _get_display(text: str) -> str:
893
+ # bidi.algorithm implements the full Unicode bidi algorithm, including bracket mirroring
894
+ try:
895
+ from bidi.algorithm import get_display
896
+ except ImportError:
897
+ from bidi import get_display
898
+ return get_display(text)
899
+
900
+
901
+ def _shape(text: str) -> str:
902
+ """Arabic letter shaping + bidi reordering for one visual line."""
903
+ import arabic_reshaper
904
+ return _get_display(arabic_reshaper.reshape(text))
905
+
906
+
907
+ class RTLParagraph(Flowable):
908
+ """Right-to-left paragraph: wraps in logical order, then reorders each line for display."""
909
+
910
+ def __init__(self, text, font="Amiri", size=10, leading=None, color=INK, align="right",
911
+ space_before=0, space_after=4, lines=None):
912
+ super().__init__()
913
+ self.text = str(text)
914
+ self.font, self.size = font, size
915
+ self.leading = leading or size * 1.55
916
+ self.color, self.align = color, align
917
+ self.space_before, self.space_after = space_before, space_after
918
+ self._lines = lines
919
+ self._width = 0
920
+
921
+ def _break(self, width):
922
+ import arabic_reshaper
923
+ words = arabic_reshaper.reshape(self.text.replace("\n", " ")).split()
924
+ lines, cur = [], ""
925
+ for w in words:
926
+ trial = f"{cur} {w}".strip()
927
+ if pdfmetrics.stringWidth(trial, self.font, self.size) <= width or not cur:
928
+ cur = trial
929
+ else:
930
+ lines.append(cur)
931
+ cur = w
932
+ if cur:
933
+ lines.append(cur)
934
+ return lines or [""]
935
+
936
+ def wrap(self, aw, ah):
937
+ self._width = aw
938
+ if self._lines is None:
939
+ self._lines = self._break(aw)
940
+ return aw, self.space_before + len(self._lines) * self.leading + self.space_after
941
+
942
+ def split(self, aw, ah):
943
+ lines = self._lines if self._lines is not None else self._break(aw)
944
+ fit = int((ah - self.space_before) // self.leading)
945
+ if fit <= 0:
946
+ return []
947
+ if fit >= len(lines):
948
+ return [self]
949
+ kw = dict(font=self.font, size=self.size, leading=self.leading, color=self.color, align=self.align)
950
+ return [RTLParagraph(self.text, space_before=self.space_before, space_after=0, lines=lines[:fit], **kw),
951
+ RTLParagraph(self.text, space_before=0, space_after=self.space_after, lines=lines[fit:], **kw)]
952
+
953
+ def draw(self):
954
+ c = self.canv
955
+ c.setFont(self.font, self.size)
956
+ c.setFillColor(self.color)
957
+ total = self.space_before + len(self._lines) * self.leading + self.space_after
958
+ for i, line in enumerate(self._lines):
959
+ y = total - self.space_before - (i + 1) * self.leading + (self.leading - self.size) * 0.55
960
+ visual = _get_display(line)
961
+ if self.align == "center":
962
+ c.drawCentredString(self._width / 2, y, visual)
963
+ else:
964
+ c.drawRightString(self._width, y, visual)
965
+
966
+
967
+ class ReportBuilder:
968
+ """Creates paragraphs/tables in the right direction and font for the report language."""
969
+
970
+ STYLES = { # size, leading, color, bold, space_before, space_after
971
+ "title": (24, 30, BRAND, True, 0, 4), "subtitle": (11, 15, MUTED, False, 0, 4),
972
+ "h1": (15, 20, BRAND, True, 6, 10), "h2": (11.5, 15, ACCENT, True, 10, 6),
973
+ "body": (9.5, 13.5, INK, False, 0, 4), "small": (8.5, 11.5, MUTED, False, 0, 4),
974
+ "bullet": (9.5, 13.5, INK, False, 0, 3), "th": (9.5, 12, colors.white, True, 0, 0),
975
+ "td": (9.5, 13, INK, False, 0, 0), "tdb": (9.5, 13, INK, True, 0, 0),
976
+ }
977
+
978
+ def __init__(self, lang: str):
979
+ self.lang = lang
980
+ self.rtl = lang == "ar" and _register_fonts()
981
+ if lang == "ar" and not self.rtl:
982
+ self.lang = "en"
983
+ self._pstyles = {}
984
+ for name, (size, leading, color, bold, sb, sa) in self.STYLES.items():
985
+ self._pstyles[name] = ParagraphStyle(
986
+ name, fontName="Helvetica-Bold" if bold else "Helvetica", fontSize=size, leading=leading,
987
+ textColor=color, spaceBefore=sb, spaceAfter=sa,
988
+ alignment=TA_CENTER if name in ("title", "subtitle") else TA_LEFT,
989
+ leftIndent=14 if name == "bullet" else 0, bulletIndent=4 if name == "bullet" else 0)
990
+
991
+ def t(self, key):
992
+ return L(self.lang, key)
993
+
994
+ def p(self, text, kind="body", ltr=False):
995
+ """Paragraph; English content inside an Arabic report (e.g. the transcript) can be forced LTR."""
996
+ text = "" if text is None else str(text)
997
+ if self.rtl and not ltr and (ARABIC_RE.search(text) or kind in ("title", "subtitle", "h1", "h2")):
998
+ size, leading, color, bold, sb, sa = self.STYLES[kind]
999
+ if kind == "bullet":
1000
+ text = "• " + text
1001
+ return RTLParagraph(text, font="Amiri-Bold" if bold else "Amiri", size=size + 1, leading=leading * 1.25,
1002
+ color=color, align="center" if kind in ("title", "subtitle") else "right",
1003
+ space_before=sb, space_after=sa)
1004
+ clean = escape(text.encode("latin-1", "replace").decode("latin-1"))
1005
+ style = self._pstyles[kind]
1006
+ if self.rtl and not ltr and kind != "bullet": # numbers/English inside an Arabic table cell
1007
+ style = ParagraphStyle(kind + "_r", parent=style, alignment=2)
1008
+ if kind == "bullet":
1009
+ return Paragraph(clean, style, bulletText="•")
1010
+ return Paragraph(clean, style)
1011
+
1012
+ def table(self, rows, widths, header=True, zebra=True, bg=None):
1013
+ if self.rtl:
1014
+ rows = [list(reversed(r)) for r in rows]
1015
+ widths = list(reversed(widths))
1016
+ t = Table(rows, colWidths=widths, repeatRows=1 if header else 0)
1017
+ style = [("VALIGN", (0, 0), (-1, -1), "TOP"), ("GRID", (0, 0), (-1, -1), 0.5, GRID),
1018
+ ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
1019
+ ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6)]
1020
+ if header:
1021
+ style.append(("BACKGROUND", (0, 0), (-1, 0), BRAND))
1022
+ if bg is not None:
1023
+ style.append(("BACKGROUND", (0, 1 if header else 0), (-1, -1), bg))
1024
+ elif zebra:
1025
+ style += [("BACKGROUND", (0, r), (-1, r), SOFT) for r in range(1 if header else 0, len(rows)) if r % 2 == 0]
1026
+ t.setStyle(TableStyle(style))
1027
+ return t
1028
+
1029
+
1030
+ def _chart_text(text: str, lang: str) -> str:
1031
+ return _shape(text) if lang == "ar" and ARABIC_RE.search(text) else text
1032
+
1033
+
1034
+ def _chart_font(lang: str):
1035
+ if lang != "ar":
1036
+ return None
1037
+ from matplotlib.font_manager import FontProperties
1038
+ return FontProperties(fname=os.path.join(FONT_DIR, "Amiri-Regular.ttf"))
1039
+
1040
+
1041
+ def _fig_to_buffer(fig) -> io.BytesIO:
1042
+ buf = io.BytesIO()
1043
+ fig.tight_layout()
1044
+ fig.savefig(buf, format="png", dpi=200)
1045
+ plt.close(fig)
1046
+ buf.seek(0)
1047
+ return buf
1048
+
1049
+
1050
+ def _chart_scores(composite: Dict, lang: str) -> io.BytesIO:
1051
+ fp = _chart_font(lang)
1052
+ labels = [_chart_text(L(lang, k), lang) for k in ("confidence", "anxiety", "hesitation")]
1053
+ values = [composite["confidence"], composite["anxiety"], composite["hesitation"]]
1054
+ fig, ax = plt.subplots(figsize=(5.2, 2.6))
1055
+ bars = ax.barh(labels, values, color=["#2E7D32", "#C62828", "#EF6C00"], height=0.55)
1056
+ ax.set_xlim(0, 1)
1057
+ ax.invert_yaxis()
1058
+ if fp:
1059
+ for lbl in ax.get_yticklabels():
1060
+ lbl.set_fontproperties(fp)
1061
+ lbl.set_fontsize(12)
1062
+ for b, v in zip(bars, values):
1063
+ ax.text(min(v + 0.02, 0.9), b.get_y() + b.get_height() / 2, f"{v:.2f}", va="center", fontsize=10)
1064
+ ax.set_title(_chart_text(L(lang, "chart_vocal"), lang), fontsize=11 if not fp else 13, fontproperties=fp)
1065
+ for s in ("top", "right"):
1066
+ ax.spines[s].set_visible(False)
1067
+ ax.grid(axis="x", alpha=0.3)
1068
+ return _fig_to_buffer(fig)
1069
+
1070
+
1071
+ def _chart_talk_time(utterances: List[Dict], lang: str) -> io.BytesIO:
1072
+ fp = _chart_font(lang)
1073
+ totals: Dict[str, float] = {}
1074
+ for u in utterances:
1075
+ totals[u["role"]] = totals.get(u["role"], 0.0) + (u["end"] - u["start"]) / 1000
1076
+ fig, ax = plt.subplots(figsize=(3.2, 2.6))
1077
+ _, texts, _ = ax.pie(list(totals.values()), labels=[_chart_text(L(lang, r), lang) for r in totals],
1078
+ autopct="%1.0f%%", colors=["#1F6FEB", "#9DB9E8", "#CCCCCC"][: len(totals)],
1079
+ startangle=90, wedgeprops={"linewidth": 1, "edgecolor": "white"})
1080
+ if fp:
1081
+ for tx in texts:
1082
+ tx.set_fontproperties(fp)
1083
+ tx.set_fontsize(12)
1084
+ ax.set_title(_chart_text(L(lang, "chart_talk"), lang), fontsize=11 if not fp else 13, fontproperties=fp)
1085
+ return _fig_to_buffer(fig)
1086
+
1087
+
1088
+ def _chart_questions(qa: List[Dict], lang: str) -> Optional[io.BytesIO]:
1089
+ scored = [q for q in qa if q.get("score") is not None]
1090
+ if not scored:
1091
+ return None
1092
+ fp = _chart_font(lang)
1093
+ fig, ax = plt.subplots(figsize=(6.6, 2.2))
1094
+ xs = [f"Q{q['index']}" for q in scored]
1095
+ ys = [q["score"] for q in scored]
1096
+ cols = ["#2E7D32" if s >= 7 else "#EF6C00" if s >= 5 else "#C62828" for s in ys]
1097
+ ax.bar(xs, ys, color=cols, width=0.6)
1098
+ ax.set_ylim(0, 10)
1099
+ for x, y in zip(xs, ys):
1100
+ ax.text(x, y + 0.2, str(y), ha="center", fontsize=9)
1101
+ ax.set_title(_chart_text(L(lang, "chart_q"), lang), fontsize=11 if not fp else 13, fontproperties=fp)
1102
+ for s in ("top", "right"):
1103
+ ax.spines[s].set_visible(False)
1104
+ ax.grid(axis="y", alpha=0.3)
1105
+ return _fig_to_buffer(fig)
1106
+
1107
+
1108
+ def create_pdf_report(analysis: Dict, output_path: str, lang: str = "en") -> None:
1109
+ rb = ReportBuilder(lang)
1110
+ lang = rb.lang
1111
+ T, P = rb.t, rb.p
1112
+ doc = SimpleDocTemplate(output_path, pagesize=letter, rightMargin=0.75 * inch, leftMargin=0.75 * inch,
1113
+ topMargin=0.9 * inch, bottomMargin=0.8 * inch,
1114
+ title="EvalBot Interview Analysis Report", author="EvalBot")
1115
+ ev = analysis["evaluation"]
1116
+ voice = analysis["voice_analysis"]
1117
+ score = analysis["suitability_score"]
1118
+ score_color = "#2E7D32" if score >= 65 else "#EF6C00" if score >= 50 else "#C62828"
1119
+ W = doc.width
1120
+ story: List = []
1121
+
1122
+ def bullets(items):
1123
+ for it in items:
1124
+ story.append(P(it, "bullet"))
1125
+
1126
+ def header_footer(canvas, d):
1127
+ canvas.saveState()
1128
+ top = letter[1] - 0.6 * inch
1129
+ canvas.setStrokeColor(ACCENT)
1130
+ canvas.setLineWidth(0.8)
1131
+ canvas.line(d.leftMargin, top, letter[0] - d.rightMargin, top)
1132
+ left_x, right_x = d.leftMargin, letter[0] - d.rightMargin
1133
+ header = T("header")
1134
+ footer = T("footer")
1135
+ page = f"{T('page')} {d.page}"
1136
+ if rb.rtl:
1137
+ canvas.setFont("Amiri-Bold", 9.5)
1138
+ canvas.setFillColor(BRAND)
1139
+ canvas.drawRightString(right_x, top + 6, _shape(header))
1140
+ canvas.setFont("Amiri", 9)
1141
+ canvas.setFillColor(MUTED)
1142
+ canvas.drawString(left_x, top + 6, analysis["generated_at"])
1143
+ canvas.drawRightString(right_x, 0.5 * inch, _shape(footer))
1144
+ canvas.drawString(left_x, 0.5 * inch, _shape(page))
1145
+ else:
1146
+ canvas.setFont("Helvetica-Bold", 8.5)
1147
+ canvas.setFillColor(BRAND)
1148
+ canvas.drawString(left_x, top + 6, header)
1149
+ canvas.setFont("Helvetica", 8)
1150
+ canvas.setFillColor(MUTED)
1151
+ canvas.drawRightString(right_x, top + 6, analysis["generated_at"])
1152
+ canvas.drawString(left_x, 0.5 * inch, footer)
1153
+ canvas.drawRightString(right_x, 0.5 * inch, page)
1154
+ canvas.restoreState()
1155
+
1156
+ # ---- Page 1: overview ------------------------------------------------------------
1157
+ story += [Spacer(1, 0.25 * inch), P(T("report_title"), "title"), Spacer(1, 4),
1158
+ P(f"{T('candidate_id')}: {analysis['user_id']} | {T('generated')}: {analysis['generated_at']}", "subtitle"),
1159
+ Spacer(1, 0.3 * inch)]
1160
+ num = Paragraph(f"<font size=30 color='{score_color}'><b>{score:.0f}</b></font>"
1161
+ f"<font size=12 color='#5B6573'> / 100</font>", ParagraphStyle("s", alignment=1, leading=36))
1162
+ content = ev.get("content_score") if ev.get("content_score") is not None else "n/a"
1163
+ detail = [P(T("suitability"), "tdb"), P(analysis["verdict"], "td"),
1164
+ P(T("score_basis").format(content=content), "small")]
1165
+ cells = [num, detail]
1166
+ widths = [1.8 * inch, W - 1.8 * inch]
1167
+ if rb.rtl:
1168
+ cells, widths = cells[::-1], widths[::-1]
1169
+ score_tbl = Table([cells], colWidths=widths)
1170
+ score_tbl.setStyle(TableStyle([("BOX", (0, 0), (-1, -1), 1, GRID), ("BACKGROUND", (0, 0), (-1, -1), SOFT),
1171
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("TOPPADDING", (0, 0), (-1, -1), 12),
1172
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 12), ("RIGHTPADDING", (0, 0), (-1, -1), 10)]))
1173
+ story += [score_tbl, Spacer(1, 0.25 * inch)]
1174
+
1175
+ info = analysis["interview"]
1176
+ rows = [[P(T("interview"), "th"), P("", "th")],
1177
+ [P(T("duration"), "tdb"), P(f"{info['duration_seconds'] / 60:.1f} {T('minutes')}", "td")],
1178
+ [P(T("speaker_turns"), "tdb"), P(str(info["speaker_turns"]), "td")],
1179
+ [P(T("questions_answered"), "tdb"), P(str(len(analysis["qa_pairs"])), "td")],
1180
+ [P(T("speakers"), "tdb"), P(" / ".join(f"{s['speaker_name']} ({T(s['role'])})"
1181
+ for s in analysis["speakers"].values()), "td")]]
1182
+ if "error" not in voice:
1183
+ rows.append([P(T("interviewee_time"), "tdb"),
1184
+ P(f"{voice['speaking_time_seconds'] / 60:.1f} {T('minutes')}, {voice['word_count']} {T('words')}", "td")])
1185
+ story += [rb.table(rows, [2.3 * inch, W - 2.3 * inch]), Spacer(1, 0.2 * inch)]
1186
+ story.append(P(T("exec_summary"), "h1"))
1187
+ bullets(ev.get("executive_summary", []))
1188
+ story.append(PageBreak())
1189
+
1190
+ # ---- Communication & vocal dynamics ------------------------------------------------
1191
+ story.append(P(T("comm_title"), "h1"))
1192
+ if "error" not in voice:
1193
+ it = voice["interpretation"]
1194
+ cs = voice["composite_scores"]
1195
+ top_f = ", ".join(f"'{k}' x{v}" for k, v in list(voice["filler_words"].items())[:5]) or T("none")
1196
+ mrows = [
1197
+ [P(T("metric"), "th"), P(T("value"), "th"), P(T("meaning"), "th")],
1198
+ [P(T("speaking_rate"), "tdb"), P(f"{voice['speaking_rate']:.2f} {T('wps')}", "td"), P(T("m_rate"), "td")],
1199
+ [P(T("fillers"), "tdb"), P(f"{voice['filler_ratio'] * 100:.1f}% {T('of_words')}", "td"),
1200
+ P(f"{T('most_frequent')}: {top_f}", "td")],
1201
+ [P(T("repetition"), "tdb"), P(f"{voice['repetition_score']:.3f}", "td"), P(T("m_rep"), "td")],
1202
+ [P(T("fluency"), "tdb"), P(T(it["fluency_level"]), "td"), P(T("m_flu"), "td")],
1203
+ [P(T("confidence"), "tdb"), P(f"{T(it['confidence_level'])} ({cs['confidence']:.2f})", "td"), P(T("m_conf"), "td")],
1204
+ [P(T("anxiety"), "tdb"), P(f"{T(it['anxiety_level'])} ({cs['anxiety']:.2f})", "td"), P(T("m_anx"), "td")],
1205
+ [P(T("pitch"), "tdb"), P(f"{voice['pitch_analysis']['mean_hz']:.0f} Hz (sd {voice['pitch_analysis']['std_dev_hz']:.0f})", "td"),
1206
+ P(T("m_pitch"), "td")],
1207
+ ]
1208
+ story += [rb.table(mrows, [1.5 * inch, 1.7 * inch, W - 3.2 * inch]), Spacer(1, 0.15 * inch)]
1209
+ imgs = [Image(_chart_scores(cs, lang), width=3.9 * inch, height=1.95 * inch),
1210
+ Image(_chart_talk_time(analysis["transcript"], lang), width=2.5 * inch, height=2.03 * inch)]
1211
+ cw = [4.1 * inch, W - 4.1 * inch]
1212
+ if rb.rtl:
1213
+ imgs, cw = imgs[::-1], cw[::-1]
1214
+ story += [Table([imgs], colWidths=cw), Spacer(1, 0.1 * inch)]
1215
+ else:
1216
+ story.append(P(voice["error"], "body"))
1217
+ if ev.get("communication"):
1218
+ story.append(P(T("observations"), "h2"))
1219
+ bullets(ev["communication"])
1220
+ if ev.get("communication_tip"):
1221
+ story.append(P(T("tip"), "h2"))
1222
+ story.append(P(ev["communication_tip"], "body"))
1223
+
1224
+ # ---- Question-by-question feedback --------------------------------------------------
1225
+ qa = analysis["qa_pairs"]
1226
+ if qa:
1227
+ story.append(PageBreak())
1228
+ story.append(P(T("qa_title"), "h1"))
1229
+ chart = _chart_questions(qa, lang)
1230
+ if chart:
1231
+ story += [Image(chart, width=W, height=W * 2.2 / 6.6), Spacer(1, 0.1 * inch)]
1232
+ for q in qa:
1233
+ badge = f"{q['score']}/10" if q.get("score") is not None else ""
1234
+ head_color = (colors.HexColor("#2E7D32") if (q.get("score") or 0) >= 7 else
1235
+ colors.HexColor("#EF6C00") if (q.get("score") or 0) >= 5 else colors.HexColor("#C62828"))
1236
+ answer = q["answer"] if len(q["answer"]) < 420 else q["answer"][:400].rsplit(" ", 1)[0] + " ..."
1237
+ rows = [[P(f"Q{q['index']} {badge}", "th"), P(q["question"], "th", ltr=True)],
1238
+ [P(T("answer"), "tdb"), P(answer, "td", ltr=True)]]
1239
+ if q.get("what_went_well"):
1240
+ rows.append([P(T("went_well"), "tdb"), P(q["what_went_well"], "td")])
1241
+ if q.get("what_to_improve"):
1242
+ rows.append([P(T("to_improve"), "tdb"), P(q["what_to_improve"], "td")])
1243
+ if q.get("improved_answer"):
1244
+ rows.append([P(T("better_answer"), "tdb"), P(q["improved_answer"], "td", ltr=True)])
1245
+ t = rb.table(rows, [1.35 * inch, W - 1.35 * inch], zebra=False)
1246
+ t.setStyle(TableStyle([("BACKGROUND", (0, 0), (-1, 0), head_color)]))
1247
+ if q.get("what_went_well"):
1248
+ t.setStyle(TableStyle([("BACKGROUND", (0, 2), (-1, 2), GOOD_BG)]))
1249
+ if q.get("what_to_improve"):
1250
+ t.setStyle(TableStyle([("BACKGROUND", (0, 3), (-1, 3), BAD_BG)]))
1251
+ story += [KeepTogether([t]), Spacer(1, 0.14 * inch)]
1252
+
1253
+ # ---- Competencies ------------------------------------------------------------------
1254
+ if ev.get("strengths") or ev.get("weaknesses"):
1255
+ story.append(PageBreak())
1256
+ story.append(P(T("competency_title"), "h1"))
1257
+ if ev.get("strengths"):
1258
+ story.append(P(T("strengths"), "h2"))
1259
+ rows = [[P(T("competency"), "th"), P(T("assessment"), "th")]]
1260
+ for s in ev["strengths"]:
1261
+ rows.append([P(s["competency"], "tdb"),
1262
+ [P(s["explanation"], "td"), P(f"{T('evidence')}: {s['evidence']}", "small")]])
1263
+ story += [rb.table(rows, [1.7 * inch, W - 1.7 * inch], bg=GOOD_BG), Spacer(1, 0.15 * inch)]
1264
+ if ev.get("weaknesses"):
1265
+ story.append(P(T("dev_areas"), "h2"))
1266
+ rows = [[P(T("area"), "th"), P(T("feedback"), "th")]]
1267
+ rows += [[P(w["area"], "tdb"), P(w["feedback"], "td")] for w in ev["weaknesses"]]
1268
+ story += [rb.table(rows, [1.7 * inch, W - 1.7 * inch], bg=BAD_BG), Spacer(1, 0.15 * inch)]
1269
+
1270
+ # ---- Role fit, recommendations, next steps -----------------------------------------
1271
+ if ev.get("role_fit") or ev.get("recommendations") or ev.get("next_steps"):
1272
+ story.append(PageBreak())
1273
+ if ev.get("role_fit"):
1274
+ story.append(P(T("role_fit"), "h1"))
1275
+ bullets(ev["role_fit"])
1276
+ if ev.get("recommendations"):
1277
+ story.append(P(T("recs"), "h1"))
1278
+ rows = [[P(T("rec_area"), "th"), P(T("recommendation"), "th"), P(T("how_to"), "th")]]
1279
+ rows += [[P(T(r["category"]), "tdb"), P(r["recommendation"], "td"), P(r["how_to"], "td")]
1280
+ for r in ev["recommendations"]]
1281
+ story += [rb.table(rows, [1.4 * inch, 2.6 * inch, W - 4.0 * inch]), Spacer(1, 0.15 * inch)]
1282
+ if ev.get("next_steps"):
1283
+ story.append(P(T("next_steps"), "h1"))
1284
+ bullets(ev["next_steps"])
1285
+
1286
+ # ---- Appendix: transcript (always left-to-right, it's the original English) ----------
1287
+ story.append(PageBreak())
1288
+ story.append(P(T("appendix"), "h1"))
1289
+ story.append(P(T("appendix_note"), "small"))
1290
+ rows = [[P(T("time"), "th"), P(T("role"), "th"), P(T("text"), "th")]]
1291
+ for u in analysis["transcript"]:
1292
+ m, s = divmod(int(u["start"] / 1000), 60)
1293
+ rows.append([P(f"{m:02d}:{s:02d}", "td"), P(T(u["role"]), "tdb"), P(u["text"], "td", ltr=True)])
1294
+ story.append(rb.table(rows, [0.6 * inch, 1.0 * inch, W - 1.6 * inch]))
1295
+ if not ev.get("ai_generated", True):
1296
+ story += [Spacer(1, 0.2 * inch), P(T("ai_unavailable"), "small")]
1297
+
1298
+ doc.build(story, onFirstPage=header_footer, onLaterPages=header_footer)
1299
+ logger.info("PDF report written to %s", output_path)
1300
+
1301
+
1302
+ # --------------------------------------------------------------------------------------
1303
+ # Main pipeline
1304
+ # --------------------------------------------------------------------------------------
1305
+ def convert_to_serializable(obj):
1306
+ if isinstance(obj, np.generic):
1307
+ return obj.item()
1308
+ if isinstance(obj, np.ndarray):
1309
+ return obj.tolist()
1310
+ if isinstance(obj, dict):
1311
+ return {k: convert_to_serializable(v) for k, v in obj.items()}
1312
+ if isinstance(obj, (list, tuple)):
1313
+ return [convert_to_serializable(v) for v in obj]
1314
+ return obj
1315
+
1316
+
1317
+ def process_interview(audio_path: str, user_id: str = "candidate", output_dir: str = OUTPUT_DIR,
1318
+ progress=None, lang: str = "en") -> Dict:
1319
+ """Run the full pipeline. Returns paths to the PDF and JSON reports plus a short summary."""
1320
+ lang = lang if lang in LABELS else "en"
1321
+
1322
+ def step(msg):
1323
+ logger.info(msg)
1324
+ if progress:
1325
+ progress(msg)
1326
+
1327
+ wav_file = None
1328
+ try:
1329
+ step("Converting audio")
1330
+ wav_file = convert_to_wav(audio_path)
1331
+ y, sr = librosa.load(wav_file, sr=16000)
1332
+
1333
+ step("Transcribing with speaker diarization")
1334
+ transcript = transcribe(wav_file)
1335
+ utterances = transcript.get("utterances") or []
1336
+ if not utterances:
1337
+ raise ValueError("No speech was detected in the recording")
1338
+
1339
+ step("Extracting prosodic features")
1340
+ for u in utterances:
1341
+ u["prosodic_features"] = extract_prosodic_features(y, sr, u["start"], u["end"])
1342
+
1343
+ step("Identifying speakers")
1344
+ speakers = identify_speakers(transcript, y, sr)
1345
+
1346
+ step("Classifying interviewer and interviewee")
1347
+ roles = classify_roles(utterances)
1348
+ for label, info in speakers.items():
1349
+ info["role"] = roles.get(label, "Interviewee")
1350
+ clean_utts = [{
1351
+ "speaker": speakers[u["speaker"]]["speaker_name"], "label": u["speaker"],
1352
+ "role": speakers[u["speaker"]]["role"], "start": u["start"], "end": u["end"], "text": u["text"],
1353
+ "confidence": round(float(u.get("confidence", 0)), 3), "prosodic_features": u["prosodic_features"],
1354
+ } for u in utterances]
1355
+
1356
+ step("Analyzing the interviewee's voice")
1357
+ voice = analyze_interviewee_voice(y, sr, clean_utts)
1358
+
1359
+ analysis = {
1360
+ "user_id": user_id, "language": lang,
1361
+ "generated_at": time.strftime("%Y-%m-%d"),
1362
+ "interview": {"duration_seconds": round(len(y) / sr, 1), "speaker_turns": len(clean_utts),
1363
+ "language": transcript.get("language_code", "en")},
1364
+ "speakers": speakers, "voice_analysis": voice, "transcript": clean_utts,
1365
+ "qa_pairs": build_qa_pairs(clean_utts),
1366
+ "models": {
1367
+ "transcription": "AssemblyAI universal-2",
1368
+ "speaker_embeddings": "nvidia/speakerverification_en_titanet_large",
1369
+ "speaker_index": "pinecone" if PINECONE_API_KEY else "built-in",
1370
+ "role_classifier": "RandomForest (TF-IDF + handcrafted), trained on MIT interview transcripts",
1371
+ "role_classifier_heldout_accuracy": getattr(get_role_classifier(), "heldout_accuracy_", None),
1372
+ "evaluation": GEMINI_MODEL if GEMINI_API_KEY else None,
1373
+ },
1374
+ }
1375
+
1376
+ step("Generating AI evaluation")
1377
+ evaluation = generate_evaluation(analysis, lang)
1378
+ if evaluation is None:
1379
+ evaluation = fallback_evaluation(analysis, lang)
1380
+ else:
1381
+ evaluation["ai_generated"] = True
1382
+ by_index = {q.get("index"): q for q in evaluation.get("questions", [])}
1383
+ for p in analysis["qa_pairs"]:
1384
+ fb = by_index.get(p["index"], {})
1385
+ p.update({k: fb.get(k) for k in ("score", "what_went_well", "what_to_improve", "improved_answer")})
1386
+ if p.get("score") is not None:
1387
+ p["score"] = int(max(0, min(10, p["score"])))
1388
+ evaluation.pop("questions", None)
1389
+ analysis["evaluation"] = evaluation
1390
+ provisional = evaluation.get("content_score") is None
1391
+ analysis["suitability_score"] = calculate_acceptance_probability(voice, evaluation.get("content_score"))
1392
+ analysis["verdict"] = verdict_for(analysis["suitability_score"], lang, provisional)
1393
+
1394
+ step("Building PDF report")
1395
+ base = f"{user_id}_{uuid.uuid4().hex[:8]}"
1396
+ os.makedirs(output_dir, exist_ok=True)
1397
+ pdf_path = os.path.abspath(os.path.join(output_dir, f"{base}_report.pdf"))
1398
+ json_path = os.path.abspath(os.path.join(output_dir, f"{base}_analysis.json"))
1399
+ create_pdf_report(analysis, pdf_path, lang)
1400
+ with open(json_path, "w", encoding="utf-8") as f:
1401
+ json.dump(convert_to_serializable(analysis), f, indent=2, ensure_ascii=False)
1402
+
1403
+ step("Done")
1404
+ return {"pdf_path": pdf_path, "json_path": json_path, "suitability_score": analysis["suitability_score"],
1405
+ "verdict": analysis["verdict"], "summary": evaluation.get("executive_summary", []),
1406
+ "voice": voice.get("interpretation", {}), "lang": lang}
1407
+ finally:
1408
+ if wav_file and os.path.exists(wav_file):
1409
+ os.remove(wav_file)
1410
+
1411
+
1412
+ # --------------------------------------------------------------------------------------
1413
+ # Practice mode: one question, one recorded answer, instant feedback
1414
+ # --------------------------------------------------------------------------------------
1415
+ QUESTION_BANK = {
1416
+ "General": [
1417
+ "Tell me about yourself.",
1418
+ "Why are you interested in this role?",
1419
+ "Where do you see yourself in five years?",
1420
+ "What are your greatest strengths?",
1421
+ "What is your biggest weakness, and how are you working on it?",
1422
+ "Why should we hire you?",
1423
+ ],
1424
+ "Behavioral": [
1425
+ "Tell me about a time you faced a difficult challenge at work or university. How did you handle it?",
1426
+ "Describe a situation where you disagreed with a teammate. What did you do?",
1427
+ "Tell me about a time you made a mistake. What did you learn?",
1428
+ "Give an example of a time you led a team or took initiative.",
1429
+ "Describe a time you had to meet a tight deadline.",
1430
+ "Tell me about a time you had to learn something new quickly.",
1431
+ ],
1432
+ "Data Engineering": [
1433
+ "Explain the difference between batch and stream processing, and when you would use each.",
1434
+ "How would you design a data pipeline that ingests data from several sources into a data warehouse?",
1435
+ "What is the difference between a data lake and a data warehouse?",
1436
+ "How do you make sure the data in your pipeline is accurate and of good quality?",
1437
+ "Explain how Apache Spark processes data in a distributed way.",
1438
+ "What role does Apache Kafka play in a real-time data architecture?",
1439
+ ],
1440
+ "Software Engineering": [
1441
+ "How do you design a REST API that other teams will rely on?",
1442
+ "Describe how you debug a problem that only happens in production.",
1443
+ "What is the difference between a process and a thread?",
1444
+ "How do you approach writing tests for your code?",
1445
+ "Tell me about a technical decision you made and the trade-offs you considered.",
1446
+ "How would you scale a web service that is getting slow under load?",
1447
+ ],
1448
+ "AI / Machine Learning": [
1449
+ "Explain overfitting and how you prevent it.",
1450
+ "How would you evaluate a classification model on an imbalanced dataset?",
1451
+ "Walk me through a machine learning project you built from data to deployment.",
1452
+ "What is the difference between supervised and unsupervised learning?",
1453
+ "How would you explain a model's prediction to a non-technical stakeholder?",
1454
+ "What are embeddings and where have you used them?",
1455
+ ],
1456
+ }
1457
+
1458
+ PRACTICE_SCHEMA = {
1459
+ "type": "object",
1460
+ "properties": {
1461
+ "score": {"type": "integer", "description": "0-10 overall quality of the answer"},
1462
+ "relevance": {"type": "string", "enum": ["on_topic", "partially", "off_topic"]},
1463
+ "summary": {"type": "string"},
1464
+ "strengths": {"type": "array", "items": {"type": "string"}},
1465
+ "improvements": {"type": "array", "items": {"type": "string"}},
1466
+ "delivery_tip": {"type": "string"},
1467
+ "improved_answer": {"type": "string"},
1468
+ },
1469
+ "required": ["score", "relevance", "summary", "strengths", "improvements", "delivery_tip", "improved_answer"],
1470
+ }
1471
+
1472
+
1473
+ def generate_practice_questions(role: str, count: int = 5) -> List[str]:
1474
+ role = (role or "").strip()[:80]
1475
+ if not role:
1476
+ return []
1477
+ result = _gemini_json(
1478
+ f"Write {count} realistic job interview questions for a candidate applying as: {role}.\n"
1479
+ "Mix behavioral and role-specific technical questions, suitable for an entry-level to mid-level "
1480
+ "candidate. Each question should be answerable in 1-2 minutes. Write them in English.",
1481
+ {"type": "object", "properties": {"questions": {"type": "array", "items": {"type": "string"}}},
1482
+ "required": ["questions"]}, temperature=0.8)
1483
+ return [q.strip() for q in (result or {}).get("questions", []) if q.strip()][:count]
1484
+
1485
+
1486
+ def evaluate_practice_answer(audio_path: str, question: str, lang: str = "en", role: str = "") -> Dict:
1487
+ lang = lang if lang in LABELS else "en"
1488
+ wav_file = convert_to_wav(audio_path)
1489
+ try:
1490
+ y, sr = librosa.load(wav_file, sr=16000)
1491
+ if len(y) < sr:
1492
+ raise ValueError("The recording is too short. Please answer for at least a few seconds.")
1493
+ transcript = transcribe(wav_file, speakers_expected=None, diarize=False)
1494
+ words = transcript.get("words") or []
1495
+ text = (transcript.get("text") or "").strip()
1496
+ if not words or not text:
1497
+ raise ValueError("No speech was detected. Check your microphone and try again.")
1498
+ start, end = words[0]["start"], words[-1]["end"]
1499
+ utt = {"role": "Interviewee", "start": start, "end": end, "text": text,
1500
+ "prosodic_features": extract_prosodic_features(y, sr, start, end)}
1501
+ voice = analyze_interviewee_voice(y, sr, [utt])
1502
+ metrics = {k: voice.get(k) for k in ("speaking_rate", "filler_ratio", "filler_words", "interpretation")}
1503
+ feedback = _gemini_json(
1504
+ f"""You are an encouraging but honest interview coach. A candidate{f' applying for: {role}' if role else ''}
1505
+ answered one interview question out loud. Evaluate the answer.
1506
+
1507
+ - Judge relevance, structure (e.g. STAR), specificity and impact. Be concrete and kind.
1508
+ - 2-4 strengths and 2-4 improvements, each one sentence.
1509
+ - delivery_tip: one tip based on the voice metrics (pace, filler words, confidence).
1510
+ - improved_answer: a stronger version (4-6 sentences, first person) that keeps the candidate's own facts;
1511
+ use placeholders like [number] where a detail is missing. Keep it in English.
1512
+ {_language_rule(lang)}
1513
+ Question: {question}
1514
+ Answer (transcribed): {text}
1515
+ Answer length: {(end - start) / 1000:.0f} seconds
1516
+ Voice metrics: {json.dumps(metrics)}
1517
+ """, PRACTICE_SCHEMA)
1518
+ if feedback:
1519
+ feedback["score"] = int(max(0, min(10, feedback.get("score", 0))))
1520
+ feedback["ai_generated"] = True
1521
+ else:
1522
+ feedback = {"ai_generated": False, "score": None, "summary": fallback_evaluation(
1523
+ {"voice_analysis": voice}, lang)["communication"][0] if "error" not in voice else "",
1524
+ "strengths": [], "improvements": [], "delivery_tip": "", "improved_answer": "", "relevance": None}
1525
+ return {"question": question, "transcript": text, "duration_seconds": round((end - start) / 1000, 1),
1526
+ "voice": voice, "feedback": feedback, "lang": lang}
1527
+ finally:
1528
+ if os.path.exists(wav_file):
1529
+ os.remove(wav_file)
1530
+
1531
+
1532
+ if __name__ == "__main__":
1533
+ import sys
1534
+
1535
+ if len(sys.argv) < 2:
1536
+ print("usage: python process_interview.py <audio-file> [user_id] [en|ar]")
1537
+ sys.exit(1)
1538
+ out = process_interview(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "candidate",
1539
+ lang=sys.argv[3] if len(sys.argv) > 3 else "en")
1540
+ print(json.dumps(out, indent=2, ensure_ascii=False))
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch==2.2.2
3
+ torchaudio==2.2.2
4
+ numpy==1.24.4
5
+ setuptools<70
6
+ nemo_toolkit[asr]==1.23.0
7
+ pytorch-lightning==2.0.7
8
+ huggingface_hub==0.23.2
9
+ cloudpickle==2.2.1
10
+ datasets==3.2.0
11
+ pyarrow==25.0.1
12
+ fsspec==2024.9.0
13
+ pinecone
14
+ google-genai
15
+ librosa
16
+ soundfile
17
+ pydub
18
+ scikit-learn==1.7.2
19
+ pandas
20
+ joblib==1.5.3
21
+ reportlab
22
+ matplotlib
23
+ fastapi
24
+ uvicorn[standard]
25
+ python-multipart
26
+ requests
27
+ arabic-reshaper
28
+ python-bidi
static_ui.html ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" dir="ltr">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>EvalBot · AI Interview Analysis</title>
7
+ <style>
8
+ :root {
9
+ --bg:#f4f6fa; --card:#fff; --ink:#152033; --muted:#5b6573; --line:#dde4ee; --soft:#eef3fb;
10
+ --brand:#0b3d91; --accent:#1f6feb; --accent-ink:#fff; --good:#2e7d32; --good-bg:#eaf6ec;
11
+ --mid:#ef6c00; --mid-bg:#fff3e6; --bad:#c62828; --bad-bg:#fdeeee; --hl:#fff7d6; --shadow:0 1px 2px rgba(16,24,40,.05);
12
+ }
13
+ @media (prefers-color-scheme: dark) {
14
+ :root { --bg:#0e1420; --card:#161e2c; --ink:#e7ecf4; --muted:#9aa6b8; --line:#2a3548; --soft:#1c2738;
15
+ --brand:#8fb4ff; --accent:#4c8dff; --good:#5cc06a; --good-bg:#16301d; --mid:#ffa24c; --mid-bg:#33240f;
16
+ --bad:#ff6b6b; --bad-bg:#3a1919; --hl:#3a3313; --shadow:none; }
17
+ }
18
+ * { box-sizing:border-box; }
19
+ html { -webkit-text-size-adjust:100%; }
20
+ body { margin:0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans Arabic", Tahoma, sans-serif;
21
+ background:var(--bg); color:var(--ink); line-height:1.5; }
22
+ main { max-width: 860px; margin:0 auto; padding: 24px 16px 64px; }
23
+ header { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; }
24
+ h1 { margin:0; font-size:28px; color:var(--brand); letter-spacing:-.01em; }
25
+ .tagline { color:var(--muted); margin:4px 0 0; }
26
+ .lang { display:flex; border:1px solid var(--line); border-radius:999px; overflow:hidden; background:var(--card); }
27
+ .lang button { border:0; background:none; padding:6px 14px; cursor:pointer; color:var(--muted); font:inherit; font-size:14px; }
28
+ .lang button.on { background:var(--accent); color:var(--accent-ink); }
29
+ .tabs { display:flex; gap:6px; margin:20px 0 14px; border-bottom:1px solid var(--line); }
30
+ .tabs button { border:0; background:none; padding:10px 14px; font:inherit; font-weight:600; color:var(--muted); cursor:pointer;
31
+ border-bottom:3px solid transparent; margin-bottom:-1px; }
32
+ .tabs button.on { color:var(--accent); border-color:var(--accent); }
33
+ .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px; margin-bottom:14px; box-shadow:var(--shadow); }
34
+ .card h2 { font-size:17px; margin:0 0 12px; color:var(--brand); }
35
+ .card h3 { font-size:15px; margin:16px 0 8px; }
36
+ .drop { border:2px dashed var(--line); border-radius:12px; padding:24px; text-align:center; cursor:pointer; transition:.15s; }
37
+ .drop.over, .drop:hover { border-color:var(--accent); background:var(--soft); }
38
+ .drop strong { color:var(--accent); }
39
+ .hint { color:var(--muted); font-size:13px; }
40
+ label { font-size:14px; color:var(--muted); display:block; margin-bottom:4px; }
41
+ input[type=text], select { width:100%; padding:10px 12px; border:1px solid var(--line); border-radius:9px; font:inherit; font-size:15px;
42
+ background:var(--card); color:var(--ink); }
43
+ .row { display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; }
44
+ .row > * { flex:1 1 200px; }
45
+ .btn { background:var(--accent); color:var(--accent-ink); border:0; border-radius:9px; padding:11px 16px; font:inherit; font-size:15px;
46
+ font-weight:600; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; }
47
+ .btn.ghost { background:none; color:var(--accent); border:1px solid var(--accent); }
48
+ .btn.rec { background:var(--bad); }
49
+ .btn:disabled { opacity:.5; cursor:default; }
50
+ .btns { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; }
51
+ .btns .btn { flex:1 1 180px; }
52
+ .steps { list-style:none; padding:0; margin:6px 0 0; }
53
+ .steps li { padding:5px 0; color:var(--muted); display:flex; gap:10px; align-items:center; }
54
+ .steps li::before { width:18px; text-align:center; }
55
+ .steps li.done { color:var(--ink); } .steps li.done::before { content:"✓"; color:var(--good); font-weight:700; }
56
+ .steps li.active { color:var(--ink); font-weight:600; } .steps li.active::before { content:"●"; color:var(--accent); animation:pulse 1s infinite; }
57
+ .steps li.todo::before { content:"○"; }
58
+ @keyframes pulse { 50% { opacity:.3 } }
59
+ .score-wrap { display:flex; gap:18px; align-items:center; flex-wrap:wrap; }
60
+ .ring { width:110px; height:110px; flex:none; }
61
+ .ring text { font-weight:800; fill:var(--ink); }
62
+ .verdict { font-weight:600; }
63
+ .chips { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
64
+ .chip { background:var(--soft); border:1px solid var(--line); border-radius:999px; padding:3px 11px; font-size:13px; }
65
+ .bars { display:grid; gap:10px; }
66
+ .bar { display:grid; grid-template-columns: 110px 1fr 46px; gap:10px; align-items:center; font-size:14px; }
67
+ .bar .track { height:10px; background:var(--soft); border-radius:99px; overflow:hidden; }
68
+ .bar .fill { height:100%; border-radius:99px; }
69
+ .metrics { display:grid; grid-template-columns: repeat(auto-fit, minmax(150px,1fr)); gap:10px; margin-top:14px; }
70
+ .metric { background:var(--soft); border-radius:10px; padding:10px 12px; }
71
+ .metric b { display:block; font-size:18px; }
72
+ .metric span { color:var(--muted); font-size:13px; }
73
+ details.q { border:1px solid var(--line); border-radius:11px; margin-bottom:10px; background:var(--card); }
74
+ details.q summary { list-style:none; cursor:pointer; padding:12px 14px; display:flex; gap:12px; align-items:flex-start; }
75
+ details.q summary::-webkit-details-marker { display:none; }
76
+ .badge { flex:none; font-weight:700; font-size:13px; border-radius:8px; padding:3px 8px; color:#fff; min-width:48px; text-align:center; }
77
+ .qbody { padding:0 14px 14px; display:grid; gap:10px; }
78
+ .box { border-radius:9px; padding:10px 12px; font-size:14px; }
79
+ .box h4 { margin:0 0 4px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
80
+ .box.good { background:var(--good-bg); } .box.bad { background:var(--bad-bg); } .box.plain { background:var(--soft); }
81
+ .en { direction:ltr; text-align:left; unicode-bidi:isolate; }
82
+ ul.list { margin:0; padding-inline-start:20px; }
83
+ ul.list li { margin:4px 0; }
84
+ .two { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
85
+ @media (max-width: 640px) { .two { grid-template-columns:1fr; } .bar { grid-template-columns: 90px 1fr 40px; } }
86
+ .player { position:sticky; top:0; background:var(--card); padding:8px 0; z-index:2; }
87
+ audio { width:100%; }
88
+ .tx { max-height:420px; overflow:auto; border:1px solid var(--line); border-radius:10px; }
89
+ .line { display:grid; grid-template-columns: 52px 92px 1fr; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line); cursor:pointer; font-size:14px; }
90
+ .line:hover { background:var(--soft); }
91
+ .line.now { background:var(--hl); }
92
+ .line .t { color:var(--muted); font-variant-numeric:tabular-nums; }
93
+ .line .r { font-weight:600; }
94
+ .line .r.Interviewer { color:var(--accent); } .line .r.Interviewee { color:var(--good); }
95
+ .downloads { display:flex; gap:10px; flex-wrap:wrap; }
96
+ .downloads a { flex:1 1 180px; text-align:center; text-decoration:none; padding:10px; border-radius:9px; border:1px solid var(--accent);
97
+ color:var(--accent); font-weight:600; }
98
+ .downloads a.primary { background:var(--accent); color:var(--accent-ink); }
99
+ .qcard { background:var(--soft); border-radius:12px; padding:16px; font-size:18px; font-weight:600; }
100
+ .qmeta { display:flex; gap:8px; margin-top:10px; flex-wrap:wrap; }
101
+ .timer { font-variant-numeric:tabular-nums; font-weight:700; }
102
+ .err { color:var(--bad); white-space:pre-wrap; }
103
+ .hidden { display:none !important; }
104
+ .session { font-size:14px; }
105
+ .session li { display:flex; justify-content:space-between; gap:10px; }
106
+ footer { color:var(--muted); font-size:13px; margin-top:28px; }
107
+ code { background:var(--soft); padding:1px 5px; border-radius:4px; font-size:12.5px; }
108
+ .sr { position:absolute; left:-9999px; }
109
+ </style>
110
+ </head>
111
+ <body>
112
+ <main>
113
+ <header>
114
+ <div>
115
+ <h1>EvalBot</h1>
116
+ <p class="tagline" data-i="tagline"></p>
117
+ </div>
118
+ <div class="lang" role="group" aria-label="Language">
119
+ <button data-lang="en" class="on">English</button><button data-lang="ar">العربية</button>
120
+ </div>
121
+ </header>
122
+
123
+ <nav class="tabs" role="tablist">
124
+ <button data-tab="analyze" class="on" data-i="tab_analyze"></button>
125
+ <button data-tab="practice" data-i="tab_practice"></button>
126
+ </nav>
127
+
128
+ <!-- ============ ANALYZE ============ -->
129
+ <section id="analyze">
130
+ <div class="card" id="form-card">
131
+ <div class="drop" id="drop" tabindex="0">
132
+ <div><strong data-i="choose"></strong> <span data-i="or_drag"></span></div>
133
+ <div class="hint" data-i="formats"></div>
134
+ <div id="fname" style="margin-top:8px;font-weight:600"></div>
135
+ <input type="file" id="file" accept="audio/*,video/mp4,video/webm" hidden>
136
+ </div>
137
+ <div style="margin-top:12px">
138
+ <label for="uid" data-i="cand_id"></label>
139
+ <input type="text" id="uid" placeholder="candidate_42">
140
+ </div>
141
+ <div class="btns">
142
+ <button class="btn" id="go" disabled data-i="analyze_btn"></button>
143
+ <button class="btn ghost" id="sample" data-i="sample_btn"></button>
144
+ </div>
145
+ <p class="hint" data-i="sample_note" style="margin:10px 0 0"></p>
146
+ </div>
147
+
148
+ <div class="card hidden" id="progress-card">
149
+ <strong data-i="analyzing"></strong> <span class="hint" data-i="takes"></span>
150
+ <ul class="steps" id="steps"></ul>
151
+ </div>
152
+
153
+ <div id="results" class="hidden"></div>
154
+ </section>
155
+
156
+ <!-- ============ PRACTICE ============ -->
157
+ <section id="practice" class="hidden">
158
+ <div class="card">
159
+ <h2 data-i="p_title"></h2>
160
+ <p class="hint" data-i="p_intro" style="margin-top:-6px"></p>
161
+ <div class="row">
162
+ <div><label for="cat" data-i="category"></label><select id="cat"></select></div>
163
+ <div><label for="role" data-i="or_role"></label><input type="text" id="role" data-ph="role_ph"></div>
164
+ </div>
165
+ <div class="btns" style="margin-top:10px"><button class="btn ghost" id="gen" data-i="gen_btn"></button></div>
166
+ </div>
167
+
168
+ <div class="card">
169
+ <div class="qcard en" id="qtext"></div>
170
+ <div class="qmeta">
171
+ <button class="btn ghost" id="speak" data-i="listen"></button>
172
+ <button class="btn ghost" id="nextq" data-i="next_q"></button>
173
+ </div>
174
+ <div class="btns">
175
+ <button class="btn rec" id="recbtn"><span id="recl" data-i="record"></span> <span class="timer" id="timer"></span></button>
176
+ </div>
177
+ <audio id="myans" controls class="hidden" style="margin-top:12px"></audio>
178
+ <div class="btns"><button class="btn" id="fb" disabled data-i="get_fb"></button></div>
179
+ <p class="hint" id="pstatus" style="margin:10px 0 0"></p>
180
+ </div>
181
+
182
+ <div id="presult" class="hidden"></div>
183
+
184
+ <div class="card hidden" id="session-card">
185
+ <h2 data-i="session"></h2>
186
+ <ul class="list session" id="session"></ul>
187
+ </div>
188
+ </section>
189
+
190
+ <footer>
191
+ <span data-i="pipeline"></span><br>
192
+ API: <code>POST /process-audio</code>, <code>POST /practice</code>. <span data-i="credit"></span>
193
+ </footer>
194
+ </main>
195
+
196
+ <script>
197
+ const I18N = {
198
+ en: {
199
+ tagline: "AI interview analysis: upload a recorded interview, or practice answering questions out loud.",
200
+ tab_analyze: "Analyze an interview", tab_practice: "Practice mode",
201
+ choose: "Choose an audio file", or_drag: "or drag it here",
202
+ formats: "WAV, MP3, M4A, FLAC, OGG, WEBM · English · up to 100 MB",
203
+ cand_id: "Candidate ID (optional)", analyze_btn: "Analyze interview", sample_btn: "Try a sample interview",
204
+ sample_note: "The sample is a 2-minute mock interview for a data engineering role, voiced with text-to-speech.",
205
+ analyzing: "Analyzing…", takes: "usually 1-2 minutes",
206
+ steps: ["Converting audio","Transcribing with speaker diarization","Extracting prosodic features","Identifying speakers",
207
+ "Classifying interviewer and interviewee","Analyzing the interviewee's voice","Generating AI evaluation","Building PDF report"],
208
+ suitability: "Suitability score", content: "Content", delivery: "Delivery",
209
+ confidence: "Confidence", anxiety: "Anxiety", hesitation: "Hesitation", fluency: "Fluency",
210
+ high: "high", moderate: "moderate", low: "low", fluent: "fluent", disfluent: "disfluent",
211
+ voice: "Voice & delivery", rate: "Speaking rate", wps: "words/sec", fillers: "Filler words", talk: "Candidate talk time",
212
+ min: "min", words: "words", summary: "Summary", qa: "Question by question", answer: "Answer",
213
+ went_well: "What went well", improve: "What to improve", better: "A stronger answer",
214
+ strengths: "Strengths", weaknesses: "Development areas", evidence: "Evidence",
215
+ recs: "Recommendations", transcript: "Transcript", tx_hint: "Click a line to jump to that moment.",
216
+ pdf: "Open PDF report", json: "Download JSON", again: "Analyze another interview",
217
+ Interviewer: "Interviewer", Interviewee: "Candidate",
218
+ p_title: "Practice an interview answer",
219
+ p_intro: "Pick a question, listen to it, record your answer, and get instant feedback on what you said and how you said it.",
220
+ category: "Question category", or_role: "…or generate questions for a role", role_ph: "e.g. Junior Data Engineer",
221
+ gen_btn: "Generate questions for this role", listen: "🔊 Listen", next_q: "Next question",
222
+ record: "● Record answer", stop: "■ Stop", rerecord: "● Record again", get_fb: "Get feedback",
223
+ p_eval: "Evaluating your answer…", mic_err: "Microphone access is needed to record. Allow it in your browser and try again.",
224
+ p_score: "Answer score", relevance: {on_topic: "On topic", partially: "Partly on topic", off_topic: "Off topic"},
225
+ you_said: "What you said", tip: "Delivery tip", session: "This session", gen_fail: "Couldn't generate questions right now.",
226
+ pipeline: "Pipeline: AssemblyAI transcription & diarization · NVIDIA NeMo TitaNet speaker embeddings + vector index · Random Forest role classifier trained on the MIT Interview dataset · librosa voice analysis · Gemini evaluation · ReportLab PDF.",
227
+ credit: "EvalBot graduation project, Benha University.",
228
+ wrong: "Something went wrong", provisional: "provisional",
229
+ },
230
+ ar: {
231
+ tagline: "تحليل المقابلات بالذكاء الاصطناعي: ارفع تسجيل مقابلة، أو تدرّب على الإجابة بصوتك.",
232
+ tab_analyze: "تحليل مقابلة", tab_practice: "وضع التدريب",
233
+ choose: "اختر ملفاً صوتياً", or_drag: "أو اسحبه هنا",
234
+ formats: "WAV، MP3، M4A، FLAC، OGG، WEBM · المقابلة بالإنجليزية · حتى 100 ميجابايت",
235
+ cand_id: "رقم المرشّح (اختياري)", analyze_btn: "حلّل المقابلة", sample_btn: "جرّب مقابلة نموذجية",
236
+ sample_note: "المقابلة النموذجية مدتها دقيقتان لوظيفة مهندس بيانات، بأصوات مولّدة آلياً.",
237
+ analyzing: "جارٍ التحليل…", takes: "عادةً من دقيقة إلى دقيقتين",
238
+ steps: ["تحويل الصوت","تحويل الكلام إلى نص وتمييز المتحدثين","استخراج خصائص الصوت","التعرّف على المتحدثين",
239
+ "تحديد المحاوِر والمرشّح","تحليل صوت المرشّح","التقييم بالذكاء الاصطناعي","إنشاء تقرير PDF"],
240
+ suitability: "درجة الملاءمة", content: "المحتوى", delivery: "الأداء",
241
+ confidence: "الثقة", anxiety: "القلق", hesitation: "التردد", fluency: "الطلاقة",
242
+ high: "مرتفع", moderate: "متوسط", low: "منخفض", fluent: "طليق", disfluent: "متقطع",
243
+ voice: "الصوت والأداء", rate: "معدل الكلام", wps: "كلمة/ثانية", fillers: "كلمات الحشو", talk: "مدة كلام المرشّح",
244
+ min: "دقيقة", words: "كلمة", summary: "الملخص", qa: "تقييم كل سؤال", answer: "الإجابة",
245
+ went_well: "ما كان جيداً", improve: "ما يحتاج إلى تحسين", better: "إجابة أقوى",
246
+ strengths: "نقاط القوة", weaknesses: "مجالات التطوير", evidence: "الدليل",
247
+ recs: "التوصيات", transcript: "نص المقابلة", tx_hint: "اضغط على أي سطر للانتقال إلى تلك اللحظة.",
248
+ pdf: "افتح تقرير PDF", json: "تحميل JSON", again: "حلّل مقابلة أخرى",
249
+ Interviewer: "المحاوِر", Interviewee: "المرشّح",
250
+ p_title: "تدرّب على الإجابة",
251
+ p_intro: "اختر سؤالاً واستمع إليه، ثم سجّل إجابتك بالإنجليزية، وستحصل فوراً على تقييم لما قلته وطريقة قوله.",
252
+ category: "نوع الأسئلة", or_role: "…أو ولّد أسئلة لوظيفة معينة", role_ph: "مثال: Junior Data Engineer",
253
+ gen_btn: "ولّد أسئلة لهذه الوظيفة", listen: "🔊 استمع", next_q: "السؤال التالي",
254
+ record: "● سجّل إجابتك", stop: "■ إيقاف", rerecord: "● سجّل مرة أخرى", get_fb: "قيّم إجابتي",
255
+ p_eval: "جارٍ تقييم إجابتك…", mic_err: "يجب السماح باستخدام الميكروفون للتسجيل. اسمح به من المتصفح ثم حاول مرة أخرى.",
256
+ p_score: "درجة الإجابة", relevance: {on_topic: "مرتبطة بالسؤال", partially: "مرتبطة جزئياً", off_topic: "خارج الموضوع"},
257
+ you_said: "ما قلته", tip: "نصيحة للأداء", session: "هذه الجلسة", gen_fail: "تعذّر توليد الأسئلة الآن.",
258
+ pipeline: "المكوّنات: AssemblyAI لتحويل الكلام وتمييز المتحدثين · NVIDIA NeMo TitaNet لبصمة الصوت مع فهرس متجهات · Random Forest لتحديد الأدوار مدرّب على بيانات MIT Interview · librosa لتحليل الصوت · Gemini للتقييم · ReportLab للتقرير.",
259
+ credit: "مشروع تخرج EvalBot، جامعة بنها.",
260
+ wrong: "حدث خطأ", provisional: "مبدئي",
261
+ }
262
+ };
263
+ let lang = "en";
264
+ try { lang = localStorage.getItem("evalbot_lang") || (navigator.language || "").startsWith("ar") && "ar" || "en"; } catch (e) {}
265
+ const $ = id => document.getElementById(id);
266
+ const t = k => (I18N[lang][k] ?? I18N.en[k] ?? k);
267
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
268
+ const fmtTime = ms => { const s = Math.floor(ms / 1000); return String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0"); };
269
+ const tone = (v, hi, mid) => v >= hi ? "var(--good)" : v >= mid ? "var(--mid)" : "var(--bad)";
270
+
271
+ function applyLang() {
272
+ document.documentElement.lang = lang;
273
+ document.documentElement.dir = lang === "ar" ? "rtl" : "ltr";
274
+ document.querySelectorAll("[data-i]").forEach(el => el.textContent = t(el.dataset.i));
275
+ document.querySelectorAll("[data-ph]").forEach(el => el.placeholder = t(el.dataset.ph));
276
+ document.querySelectorAll(".lang button").forEach(b => b.classList.toggle("on", b.dataset.lang === lang));
277
+ if (lastAnalysis) renderResults(lastAnalysis, lastMeta);
278
+ if (lastPractice) renderPractice(lastPractice);
279
+ if (recState === "recorded") $("recl").textContent = t("rerecord");
280
+ }
281
+ document.querySelectorAll(".lang button").forEach(b => b.onclick = () => {
282
+ lang = b.dataset.lang; try { localStorage.setItem("evalbot_lang", lang); } catch (e) {}
283
+ applyLang();
284
+ });
285
+ document.querySelectorAll(".tabs button").forEach(b => b.onclick = () => {
286
+ document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
287
+ $("analyze").classList.toggle("hidden", b.dataset.tab !== "analyze");
288
+ $("practice").classList.toggle("hidden", b.dataset.tab !== "practice");
289
+ });
290
+
291
+ /* ---------------- Analyze ---------------- */
292
+ let chosen = null, audioUrl = null, lastAnalysis = null, lastMeta = null;
293
+ const drop = $("drop"), input = $("file");
294
+ drop.onclick = () => input.click();
295
+ drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") input.click(); };
296
+ input.onchange = () => pick(input.files[0]);
297
+ drop.ondragover = e => { e.preventDefault(); drop.classList.add("over"); };
298
+ drop.ondragleave = () => drop.classList.remove("over");
299
+ drop.ondrop = e => { e.preventDefault(); drop.classList.remove("over"); pick(e.dataTransfer.files[0]); };
300
+ function pick(f) { if (!f) return; chosen = f; $("fname").textContent = f.name + " (" + (f.size / 1048576).toFixed(1) + " MB)"; $("go").disabled = false; }
301
+
302
+ function renderSteps(current, done) {
303
+ const steps = I18N[lang].steps, idx = I18N.en.steps.indexOf(current);
304
+ $("steps").innerHTML = steps.map((s, i) => `<li class="${done || (idx >= 0 && i < idx) ? "done" : i === idx ? "active" : "todo"}">${s}</li>`).join("");
305
+ }
306
+ async function start(useSample) {
307
+ $("go").disabled = $("sample").disabled = true;
308
+ $("results").classList.add("hidden");
309
+ $("progress-card").classList.remove("hidden");
310
+ renderSteps("", false);
311
+ const fd = new FormData();
312
+ if (useSample) { fd.append("sample", "true"); audioUrl = "sample.mp3"; }
313
+ else { fd.append("file", chosen); if (audioUrl && audioUrl.startsWith("blob:")) URL.revokeObjectURL(audioUrl); audioUrl = URL.createObjectURL(chosen); }
314
+ fd.append("user_id", $("uid").value || (useSample ? "sample_candidate" : "candidate"));
315
+ fd.append("lang", lang);
316
+ try {
317
+ const r = await fetch("jobs", { method: "POST", body: fd });
318
+ const j = await r.json();
319
+ if (!r.ok) throw new Error(j.detail || "Upload failed");
320
+ poll(j.job_id);
321
+ } catch (e) { fail(e.message); }
322
+ }
323
+ $("go").onclick = () => start(false);
324
+ $("sample").onclick = () => start(true);
325
+
326
+ async function poll(id) {
327
+ try {
328
+ const r = await fetch("jobs/" + id), j = await r.json();
329
+ if (j.status === "done") {
330
+ const a = await (await fetch(j.result.json_path.replace(/^\//, ""))).json();
331
+ lastAnalysis = a; lastMeta = j.result;
332
+ $("progress-card").classList.add("hidden");
333
+ renderResults(a, j.result);
334
+ $("go").disabled = !chosen; $("sample").disabled = false;
335
+ $("results").scrollIntoView({ behavior: "smooth" });
336
+ return;
337
+ }
338
+ if (j.status === "error") return fail(j.error);
339
+ renderSteps(j.step, false);
340
+ } catch (e) { /* transient network error: keep polling */ }
341
+ setTimeout(() => poll(id), 2000);
342
+ }
343
+ function fail(msg) {
344
+ $("progress-card").classList.add("hidden");
345
+ $("results").innerHTML = `<div class="card"><strong>${t("wrong")}</strong><p class="err">${esc(msg)}</p></div>`;
346
+ $("results").classList.remove("hidden");
347
+ $("go").disabled = !chosen; $("sample").disabled = false;
348
+ }
349
+
350
+ function ring(score) {
351
+ const r = 46, c = 2 * Math.PI * r, v = Math.max(0, Math.min(100, score));
352
+ return `<svg class="ring" viewBox="0 0 110 110" role="img" aria-label="${Math.round(v)} / 100">
353
+ <circle cx="55" cy="55" r="${r}" fill="none" stroke="var(--soft)" stroke-width="10"/>
354
+ <circle cx="55" cy="55" r="${r}" fill="none" stroke="${tone(v, 65, 50)}" stroke-width="10" stroke-linecap="round"
355
+ stroke-dasharray="${c * v / 100} ${c}" transform="rotate(-90 55 55)"/>
356
+ <text x="55" y="62" text-anchor="middle" font-size="28">${Math.round(v)}</text></svg>`;
357
+ }
358
+ function bar(label, v, color) {
359
+ return `<div class="bar"><span>${label}</span><div class="track"><div class="fill" style="width:${Math.round(v * 100)}%;background:${color}"></div></div><span>${v.toFixed(2)}</span></div>`;
360
+ }
361
+ function scoreBadge(s, max = 10) {
362
+ if (s == null) return "";
363
+ const c = s / max >= .7 ? "var(--good)" : s / max >= .5 ? "var(--mid)" : "var(--bad)";
364
+ return `<span class="badge" style="background:${c}">${s}/${max}</span>`;
365
+ }
366
+
367
+ function renderResults(a, meta) {
368
+ const ev = a.evaluation || {}, v = a.voice_analysis || {}, it = v.interpretation || {}, cs = v.composite_scores || {};
369
+ const dir = (a.language === "ar") ? "rtl" : "ltr";
370
+ const L = s => `<span dir="${dir}">${esc(s)}</span>`;
371
+ let h = "";
372
+ h += `<div class="card"><div class="score-wrap">${ring(a.suitability_score)}
373
+ <div style="flex:1 1 240px"><div class="hint">${t("suitability")}</div>
374
+ <div class="verdict">${L(a.verdict)}</div>
375
+ <div class="chips">
376
+ ${ev.content_score != null ? `<span class="chip">${t("content")}: ${ev.content_score}/100</span>` : `<span class="chip">${t("provisional")}</span>`}
377
+ ${it.confidence_level ? `<span class="chip">${t("confidence")}: ${t(it.confidence_level)}</span>` : ""}
378
+ ${it.anxiety_level ? `<span class="chip">${t("anxiety")}: ${t(it.anxiety_level)}</span>` : ""}
379
+ ${it.fluency_level ? `<span class="chip">${t("fluency")}: ${t(it.fluency_level)}</span>` : ""}
380
+ </div></div></div>
381
+ <div class="downloads" style="margin-top:16px"><a class="primary" href="${meta.pdf_path.replace(/^\//, "")}" target="_blank" rel="noopener">${t("pdf")}</a>
382
+ <a href="${meta.json_path.replace(/^\//, "")}" target="_blank" rel="noopener">${t("json")}</a></div></div>`;
383
+
384
+ if ((ev.executive_summary || []).length)
385
+ h += `<div class="card"><h2>${t("summary")}</h2><ul class="list" dir="${dir}">${ev.executive_summary.map(x => `<li>${esc(x)}</li>`).join("")}</ul></div>`;
386
+
387
+ if (!v.error) {
388
+ const fl = Object.entries(v.filler_words || {}).slice(0, 4).map(([k, n]) => `"${k}" ×${n}`).join(", ");
389
+ h += `<div class="card"><h2>${t("voice")}</h2><div class="bars">
390
+ ${bar(t("confidence"), cs.confidence, "var(--good)")}${bar(t("anxiety"), cs.anxiety, "var(--bad)")}${bar(t("hesitation"), cs.hesitation, "var(--mid)")}</div>
391
+ <div class="metrics">
392
+ <div class="metric"><b>${v.speaking_rate}</b><span>${t("rate")} (${t("wps")})</span></div>
393
+ <div class="metric"><b>${(v.filler_ratio * 100).toFixed(1)}%</b><span>${t("fillers")}${fl ? " · " + esc(fl) : ""}</span></div>
394
+ <div class="metric"><b>${(v.speaking_time_seconds / 60).toFixed(1)} ${t("min")}</b><span>${t("talk")} · ${v.word_count} ${t("words")}</span></div>
395
+ </div>
396
+ ${ev.communication_tip ? `<div class="box plain" style="margin-top:12px" dir="${dir}">💡 ${esc(ev.communication_tip)}</div>` : ""}</div>`;
397
+ }
398
+
399
+ if ((a.qa_pairs || []).length) {
400
+ h += `<div class="card"><h2>${t("qa")}</h2>` + a.qa_pairs.map((q, i) => `
401
+ <details class="q" ${i === 0 ? "open" : ""}><summary>${scoreBadge(q.score) || `<span class="badge" style="background:var(--muted)">Q${q.index}</span>`}
402
+ <span class="en">${esc(q.question)}</span></summary>
403
+ <div class="qbody">
404
+ <div class="box plain"><h4>${t("answer")} · <a href="#" data-seek="${q.answer_start}">${fmtTime(q.answer_start)}</a></h4><div class="en">${esc(q.answer)}</div></div>
405
+ ${q.what_went_well ? `<div class="box good"><h4>${t("went_well")}</h4><div dir="${dir}">${esc(q.what_went_well)}</div></div>` : ""}
406
+ ${q.what_to_improve ? `<div class="box bad"><h4>${t("improve")}</h4><div dir="${dir}">${esc(q.what_to_improve)}</div></div>` : ""}
407
+ ${q.improved_answer ? `<div class="box plain"><h4>${t("better")}</h4><div class="en">${esc(q.improved_answer)}</div></div>` : ""}
408
+ </div></details>`).join("") + `</div>`;
409
+ }
410
+
411
+ if ((ev.strengths || []).length || (ev.weaknesses || []).length) {
412
+ h += `<div class="card two"><div><h2>${t("strengths")}</h2>${(ev.strengths || []).map(s => `<div class="box good" style="margin-bottom:8px" dir="${dir}">
413
+ <b>${esc(s.competency)}</b><div>${esc(s.explanation)}</div><div class="hint">${t("evidence")}: <span class="en">${esc(s.evidence)}</span></div></div>`).join("")}</div>
414
+ <div><h2>${t("weaknesses")}</h2>${(ev.weaknesses || []).map(w => `<div class="box bad" style="margin-bottom:8px" dir="${dir}"><b>${esc(w.area)}</b><div>${esc(w.feedback)}</div></div>`).join("")}</div></div>`;
415
+ }
416
+ if ((ev.recommendations || []).length)
417
+ h += `<div class="card"><h2>${t("recs")}</h2><ul class="list" dir="${dir}">${ev.recommendations.map(r => `<li><b>${esc(r.recommendation)}</b>: ${esc(r.how_to)}</li>`).join("")}</ul></div>`;
418
+
419
+ h += `<div class="card"><h2>${t("transcript")}</h2><div class="player"><audio id="player" controls preload="metadata" src="${esc(audioUrl || "")}"></audio>
420
+ <div class="hint">${t("tx_hint")}</div></div>
421
+ <div class="tx" id="tx">${(a.transcript || []).map((u, i) => `<div class="line" data-i="${i}" data-start="${u.start}" data-end="${u.end}">
422
+ <span class="t">${fmtTime(u.start)}</span><span class="r ${u.role}">${t(u.role)}</span><span class="en">${esc(u.text)}</span></div>`).join("")}</div></div>`;
423
+ h += `<div class="btns"><button class="btn ghost" onclick="window.scrollTo({top:0,behavior:'smooth'})">${t("again")}</button></div>`;
424
+ $("results").innerHTML = h;
425
+ $("results").classList.remove("hidden");
426
+ wirePlayer();
427
+ }
428
+ function wirePlayer() {
429
+ const p = $("player"); if (!p) return;
430
+ const lines = [...document.querySelectorAll("#tx .line")];
431
+ const seek = ms => { p.currentTime = ms / 1000; p.play().catch(() => {}); };
432
+ lines.forEach(l => l.onclick = () => seek(+l.dataset.start));
433
+ document.querySelectorAll("[data-seek]").forEach(a => a.onclick = e => { e.preventDefault(); seek(+a.dataset.seek); p.scrollIntoView({ behavior: "smooth", block: "center" }); });
434
+ let cur = -1;
435
+ p.ontimeupdate = () => {
436
+ const ms = p.currentTime * 1000;
437
+ const i = lines.findIndex(l => ms >= +l.dataset.start && ms <= +l.dataset.end + 400);
438
+ if (i !== cur) {
439
+ if (cur >= 0) lines[cur].classList.remove("now");
440
+ cur = i;
441
+ if (i >= 0) { lines[i].classList.add("now"); const box = $("tx"); box.scrollTop = lines[i].offsetTop - box.offsetTop - 60; }
442
+ }
443
+ };
444
+ }
445
+
446
+ /* ---------------- Practice ---------------- */
447
+ let bank = {}, queue = [], question = "", roleName = "", lastPractice = null, session = [];
448
+ let recorder = null, chunks = [], recBlob = null, recState = "idle", tick = null, startedAt = 0;
449
+
450
+ async function loadBank() {
451
+ try { bank = await (await fetch("practice/questions")).json(); } catch (e) { bank = { General: ["Tell me about yourself."] }; }
452
+ $("cat").innerHTML = Object.keys(bank).map(k => `<option>${esc(k)}</option>`).join("");
453
+ $("cat").onchange = () => { roleName = ""; setQueue(bank[$("cat").value]); };
454
+ setQueue(bank[$("cat").value]);
455
+ }
456
+ function setQueue(list) { queue = [...list].sort(() => Math.random() - .5); nextQuestion(); }
457
+ function nextQuestion() {
458
+ if (!queue.length) queue = [...(bank[$("cat").value] || [])].sort(() => Math.random() - .5);
459
+ question = queue.shift() || "Tell me about yourself.";
460
+ $("qtext").textContent = question;
461
+ resetRecording();
462
+ $("presult").classList.add("hidden"); lastPractice = null;
463
+ }
464
+ $("nextq").onclick = nextQuestion;
465
+ $("speak").onclick = () => {
466
+ if (!("speechSynthesis" in window)) return;
467
+ speechSynthesis.cancel();
468
+ const u = new SpeechSynthesisUtterance(question); u.lang = "en-US"; u.rate = .95;
469
+ const v = speechSynthesis.getVoices().find(v => v.lang && v.lang.startsWith("en"));
470
+ if (v) u.voice = v;
471
+ speechSynthesis.speak(u);
472
+ };
473
+ $("gen").onclick = async () => {
474
+ const role = $("role").value.trim(); if (!role) { $("role").focus(); return; }
475
+ $("gen").disabled = true; $("pstatus").textContent = "…";
476
+ try {
477
+ const fd = new FormData(); fd.append("role", role);
478
+ const r = await fetch("practice/generate", { method: "POST", body: fd }), j = await r.json();
479
+ if (!r.ok) throw new Error(j.detail);
480
+ roleName = role; queue = j.questions; nextQuestion(); $("pstatus").textContent = "";
481
+ } catch (e) { $("pstatus").textContent = t("gen_fail"); }
482
+ $("gen").disabled = false;
483
+ };
484
+
485
+ function resetRecording() {
486
+ if (recorder && recorder.state === "recording") recorder.stop();
487
+ recBlob = null; recState = "idle"; $("recl").textContent = t("record"); $("timer").textContent = "";
488
+ $("myans").classList.add("hidden"); $("fb").disabled = true; $("pstatus").textContent = "";
489
+ }
490
+ $("recbtn").onclick = async () => {
491
+ if (recState === "recording") { recorder.stop(); return; }
492
+ try {
493
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
494
+ const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"].find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
495
+ recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
496
+ chunks = [];
497
+ recorder.ondataavailable = e => e.data.size && chunks.push(e.data);
498
+ recorder.onstop = () => {
499
+ stream.getTracks().forEach(tr => tr.stop()); clearInterval(tick);
500
+ recBlob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
501
+ $("myans").src = URL.createObjectURL(recBlob); $("myans").classList.remove("hidden");
502
+ recState = "recorded"; $("recl").textContent = t("rerecord"); $("fb").disabled = false;
503
+ };
504
+ recorder.start(); recState = "recording"; startedAt = Date.now();
505
+ $("recl").textContent = t("stop"); $("fb").disabled = true; $("myans").classList.add("hidden");
506
+ tick = setInterval(() => {
507
+ const s = Math.floor((Date.now() - startedAt) / 1000);
508
+ $("timer").textContent = fmtTime(s * 1000);
509
+ if (s >= 180) recorder.stop();
510
+ }, 250);
511
+ } catch (e) { $("pstatus").textContent = t("mic_err"); }
512
+ };
513
+ $("fb").onclick = async () => {
514
+ if (!recBlob) return;
515
+ $("fb").disabled = true; $("pstatus").textContent = t("p_eval");
516
+ const ext = (recBlob.type.includes("mp4") ? "m4a" : recBlob.type.includes("ogg") ? "ogg" : "webm");
517
+ const fd = new FormData();
518
+ fd.append("file", recBlob, "answer." + ext); fd.append("question", question); fd.append("lang", lang); fd.append("role", roleName);
519
+ try {
520
+ const r = await fetch("practice", { method: "POST", body: fd }), j = await r.json();
521
+ if (!r.ok) throw new Error(j.detail);
522
+ lastPractice = j; renderPractice(j); $("pstatus").textContent = "";
523
+ session.push({ q: question, s: j.feedback.score }); renderSession();
524
+ $("presult").scrollIntoView({ behavior: "smooth" });
525
+ } catch (e) { $("pstatus").innerHTML = `<span class="err">${esc(e.message)}</span>`; }
526
+ $("fb").disabled = false;
527
+ };
528
+ function renderPractice(j) {
529
+ const f = j.feedback || {}, v = j.voice || {}, it = v.interpretation || {}, dir = j.lang === "ar" ? "rtl" : "ltr";
530
+ $("presult").innerHTML = `<div class="card">
531
+ <div class="score-wrap">${f.score != null ? ring(f.score * 10) : ""}
532
+ <div style="flex:1 1 240px"><div class="hint">${t("p_score")}${f.score != null ? `: ${f.score}/10` : ""}</div>
533
+ <div class="verdict" dir="${dir}">${esc(f.summary || "")}</div>
534
+ <div class="chips">${f.relevance ? `<span class="chip">${t("relevance")[f.relevance] || f.relevance}</span>` : ""}
535
+ ${v.speaking_rate != null ? `<span class="chip">${t("rate")}: ${v.speaking_rate} ${t("wps")}</span>` : ""}
536
+ ${v.filler_ratio != null ? `<span class="chip">${t("fillers")}: ${(v.filler_ratio * 100).toFixed(1)}%</span>` : ""}
537
+ ${it.confidence_level ? `<span class="chip">${t("confidence")}: ${t(it.confidence_level)}</span>` : ""}</div></div></div>
538
+ <div class="two" style="margin-top:14px">
539
+ <div class="box good"><h4>${t("went_well")}</h4><ul class="list" dir="${dir}">${(f.strengths || []).map(x => `<li>${esc(x)}</li>`).join("")}</ul></div>
540
+ <div class="box bad"><h4>${t("improve")}</h4><ul class="list" dir="${dir}">${(f.improvements || []).map(x => `<li>${esc(x)}</li>`).join("")}</ul></div>
541
+ </div>
542
+ ${f.delivery_tip ? `<div class="box plain" style="margin-top:12px" dir="${dir}">💡 <b>${t("tip")}:</b> ${esc(f.delivery_tip)}</div>` : ""}
543
+ ${f.improved_answer ? `<h3>${t("better")}</h3><div class="box plain en">${esc(f.improved_answer)}</div>` : ""}
544
+ <h3>${t("you_said")}</h3><div class="box plain en">${esc(j.transcript)}</div></div>`;
545
+ $("presult").classList.remove("hidden");
546
+ }
547
+ function renderSession() {
548
+ $("session-card").classList.remove("hidden");
549
+ $("session").innerHTML = session.map(x => `<li><span class="en">${esc(x.q)}</span>${scoreBadge(x.s)}</li>`).join("");
550
+ }
551
+
552
+ applyLang();
553
+ loadBank();
554
+ if ("speechSynthesis" in window) speechSynthesis.getVoices();
555
+ </script>
556
+ </body>
557
+ </html>