Spaces:
Running
Running
File size: 7,963 Bytes
ec1a96a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | """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))}
|