Spaces:
Running
Running
Download platform_app/insights.py from EvalBot/Audio-EvalBot: direct link, hf CLI and curl.
- Browser
- Download file 7.96 kB
-
https://huggingface.co/spaces/EvalBot/Audio-EvalBot/resolve/main/platform_app/insights.py
- Command line
-
hf download hf://spaces/EvalBot/Audio-EvalBot/platform_app/insights.py
-
curl -L -o insights.py https://huggingface.co/spaces/EvalBot/Audio-EvalBot/resolve/main/platform_app/insights.py
7.96 kB
| """Session insights beyond the answers: camera analysis (emotions, presence), interview integrity, and CV profiles.""" | |
| import io | |
| import logging | |
| import re | |
| from typing import Dict, List, Optional | |
| import process_interview as pi | |
| logger = logging.getLogger("evalbot.insights") | |
| EMOTIONS = ["neutral", "happy", "surprised", "sad", "angry", "fearful", "disgusted"] | |
| # -------------------------------------------------------------------------------------- | |
| # Camera analysis (all numbers were computed in the candidate's browser) | |
| # -------------------------------------------------------------------------------------- | |
| def camera_summary(answers: List[Dict]) -> Optional[Dict]: | |
| pres = [(a["idx"], a["presence"]) for a in answers if (a.get("presence") or {}).get("samples")] | |
| if not pres: | |
| return None | |
| total = sum(p["samples"] for _, p in pres) | |
| def wavg(key): | |
| vals = [(p.get(key), p["samples"]) for _, p in pres if p.get(key) is not None] | |
| n = sum(w for _, w in vals) | |
| return round(sum(v * w for v, w in vals) / n, 3) if n else None | |
| emo_tot = {e: 0.0 for e in EMOTIONS} | |
| emo_n = 0 | |
| timeline = [] | |
| for idx, p in pres: | |
| em = p.get("emotions") | |
| if em: | |
| for e in EMOTIONS: | |
| emo_tot[e] += float(em.get(e, 0)) * p["samples"] | |
| emo_n += p["samples"] | |
| for seg in p.get("timeline") or []: | |
| timeline.append({"answer": idx + 1, "t": seg.get("t", 0), "dominant": seg.get("dominant"), | |
| "probs": {e: round(float((seg.get("probs") or {}).get(e, 0)), 3) for e in EMOTIONS}}) | |
| emotions = {e: round(v / emo_n, 3) for e, v in emo_tot.items()} if emo_n else None | |
| return { | |
| "samples": total, "answers": len(pres), | |
| "face_visible": wavg("face_visible"), "eye_contact": wavg("eye_contact"), "smile": wavg("smile"), | |
| "head_movement": wavg("head_movement"), | |
| "emotions": emotions, "dominant_emotion": max(emotions, key=emotions.get) if emotions else None, | |
| "timeline": timeline[:400], | |
| } | |
| # -------------------------------------------------------------------------------------- | |
| # Integrity: events become review flags with a simple, explainable score | |
| # -------------------------------------------------------------------------------------- | |
| # type -> (penalty per event, cap) | |
| PENALTIES = { | |
| "multiple_faces": (15, 30), "second_voice": (15, 30), "voice_without_lips": (12, 24), "tab_hidden": (8, 32), | |
| "reading_pattern": (8, 16), "looking_away": (3, 15), "no_face": (4, 16), "fullscreen_exit": (3, 9), | |
| } | |
| def integrity(answers: List[Dict], camera: Optional[Dict], camera_required: bool) -> Dict: | |
| events: List[Dict] = [] | |
| for a in answers: | |
| p = a.get("presence") or {} | |
| for e in p.get("events") or []: | |
| if e.get("type") in PENALTIES: | |
| events.append({"type": e["type"], "answer": a["idx"] + 1, "t": round(float(e.get("t", 0)), 1), | |
| "dur": round(float(e["dur"]), 1) if e.get("dur") is not None else None}) | |
| if (a.get("voice") or {}).get("second_voice"): | |
| events.append({"type": "second_voice", "answer": a["idx"] + 1, "t": 0, "dur": None}) | |
| counts: Dict[str, int] = {} | |
| for e in events: | |
| counts[e["type"]] = counts.get(e["type"], 0) + 1 | |
| penalty = sum(min(PENALTIES[k][0] * n, PENALTIES[k][1]) for k, n in counts.items()) | |
| notes = [] | |
| if camera: | |
| away = 1 - (camera.get("eye_contact") or 0) | |
| if away > 0.35: | |
| penalty += 15 | |
| notes.append("away_high") | |
| elif away > 0.2: | |
| penalty += 6 | |
| notes.append("away_some") | |
| fv = camera.get("face_visible") or 0 | |
| if fv < 0.6: | |
| penalty += 15 | |
| notes.append("face_often_missing") | |
| elif fv < 0.85: | |
| penalty += 6 | |
| notes.append("face_sometimes_missing") | |
| elif camera_required: | |
| penalty += 25 | |
| notes.append("camera_off") | |
| score = max(0, 100 - penalty) | |
| return {"score": score, "level": "low" if score >= 80 else "medium" if score >= 60 else "high", | |
| "counts": counts, "events": events[:200], "notes": notes, "camera": bool(camera)} | |
| # -------------------------------------------------------------------------------------- | |
| # CV profiles | |
| # -------------------------------------------------------------------------------------- | |
| CV_SCHEMA = { | |
| "type": "object", | |
| "properties": { | |
| "name": {"type": "string"}, | |
| "headline": {"type": "string", "description": "one line, e.g. 'Data engineer with 2 years of Spark and Kafka'"}, | |
| "years_experience": {"type": "number"}, | |
| "skills": {"type": "array", "items": {"type": "string"}, "description": "technical skills and tools, short names"}, | |
| "soft_skills": {"type": "array", "items": {"type": "string"}}, | |
| "experience": {"type": "array", "items": {"type": "object", "properties": { | |
| "title": {"type": "string"}, "company": {"type": "string"}, "period": {"type": "string"}, | |
| "summary": {"type": "string"}}, "required": ["title", "company", "period", "summary"]}}, | |
| "education": {"type": "array", "items": {"type": "object", "properties": { | |
| "degree": {"type": "string"}, "institution": {"type": "string"}, "year": {"type": "string"}}, | |
| "required": ["degree", "institution", "year"]}}, | |
| "projects": {"type": "array", "items": {"type": "object", "properties": { | |
| "name": {"type": "string"}, "summary": {"type": "string"}, "tech": {"type": "array", "items": {"type": "string"}}}, | |
| "required": ["name", "summary", "tech"]}}, | |
| "certifications": {"type": "array", "items": {"type": "string"}}, | |
| "languages": {"type": "array", "items": {"type": "string"}, "description": "spoken languages only"}, | |
| }, | |
| "required": ["name", "headline", "years_experience", "skills", "soft_skills", "experience", "education", "projects", | |
| "certifications", "languages"], | |
| } | |
| def cv_text_from_file(data: bytes, filename: str) -> str: | |
| name = (filename or "").lower() | |
| if name.endswith(".pdf") or data[:4] == b"%PDF": | |
| from pypdf import PdfReader | |
| reader = PdfReader(io.BytesIO(data)) | |
| text = "\n".join((page.extract_text() or "") for page in reader.pages[:10]) | |
| elif name.endswith(".docx"): | |
| import docx | |
| d = docx.Document(io.BytesIO(data)) | |
| parts = [p.text for p in d.paragraphs] | |
| for t in d.tables: | |
| for row in t.rows: | |
| parts.append(" | ".join(c.text for c in row.cells)) | |
| text = "\n".join(parts) | |
| else: | |
| text = data.decode("utf-8", errors="ignore") | |
| text = re.sub(r"[ \t]+", " ", text) | |
| return re.sub(r"\n{3,}", "\n\n", text).strip()[:20000] | |
| def parse_cv(text: str) -> Optional[Dict]: | |
| prompt = f"""Extract a structured profile from this CV / resume. Use only what the CV says; do not invent anything. | |
| Keep the original language of names and titles. Skills are short tool or technology names (e.g. "Python", "Apache Spark"). | |
| CV: | |
| {text[:15000]} | |
| """ | |
| return pi._gemini_json(prompt, CV_SCHEMA, temperature=0.1) | |
| def _norm(s: str) -> str: | |
| return re.sub(r"[^a-z0-9+#]", "", s.lower().replace("apache ", "")) | |
| def skill_match(job_skills: List[str], cv: Optional[Dict], cv_text: str = "") -> Optional[Dict]: | |
| if not job_skills or not cv: | |
| return None | |
| have = [_norm(x) for x in (cv.get("skills") or []) + [t for p in cv.get("projects") or [] for t in p.get("tech") or []]] | |
| blob = _norm(cv_text or "") | |
| matched, missing = [], [] | |
| for s in job_skills: | |
| k = _norm(s) | |
| ok = bool(k) and (any(k == h or (len(k) > 2 and len(h) > 2 and (k in h or h in k)) for h in have if h) or (len(k) > 2 and k in blob)) | |
| (matched if ok else missing).append(s) | |
| return {"matched": matched, "missing": missing, "percent": round(100 * len(matched) / len(job_skills))} | |