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