Spaces:
Running
Running
Commit ·
36d1f48
0
Parent(s):
EvalBot v2: interview analysis, practice mode, English/Arabic reports
Browse files- .dockerignore +6 -0
- .gitattributes +4 -0
- .gitignore +9 -0
- Dockerfile +34 -0
- README.md +100 -0
- app.py +253 -0
- assets/fonts/Amiri-Bold.ttf +3 -0
- assets/fonts/Amiri-Regular.ttf +3 -0
- assets/fonts/LICENSE-Amiri.txt +4 -0
- assets/models/role_classifier.joblib +3 -0
- assets/sample_interview.mp3 +3 -0
- process_interview.py +1540 -0
- requirements.txt +28 -0
- static_ui.html +557 -0
.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 => ({"&":"&","<":"<",">":">",'"':"""}[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>
|