Update scorer.py to be smarter in scoring
Browse files- agents/scorer.py +89 -130
agents/scorer.py
CHANGED
|
@@ -1,46 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
agents/scorer.py
|
| 3 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
-
ScorerAgent:
|
| 5 |
-
|
| 6 |
-
The core innovation over Phase 1:
|
| 7 |
-
1. LOCAL keyword hit check (instant, deterministic, no LLM call needed)
|
| 8 |
-
- Checks which expected job keywords appear in the candidate's answer
|
| 9 |
-
- Computes coverage_pct = hits / total_keywords * 100
|
| 10 |
-
2. LLM scoring prompt is dynamically AUGMENTED with:
|
| 11 |
-
- The keyword coverage result
|
| 12 |
-
- An explicit cap instruction if coverage < 40%
|
| 13 |
-
- A list of missed keywords to guide the Weakness/Fix feedback
|
| 14 |
-
3. Returns a structured dict with raw feedback AND parsed fields
|
| 15 |
-
for the UI to render keyword coverage badges.
|
| 16 |
-
|
| 17 |
-
Scoring rubric enforced by prompt:
|
| 18 |
-
- STAR format structure (encouraged, not hard-gated)
|
| 19 |
-
- Keyword coverage (hard gate: <40% β score capped at 5/10)
|
| 20 |
-
- Relevance check (irrelevant responses β NIL/10)
|
| 21 |
"""
|
| 22 |
|
|
|
|
| 23 |
|
| 24 |
class ScorerAgent:
|
| 25 |
"""
|
| 26 |
-
Scores a candidate's interview answer
|
| 27 |
-
|
| 28 |
-
Usage:
|
| 29 |
-
agent = ScorerAgent(llm_fn)
|
| 30 |
-
result = agent.run(answer, question, job_profile)
|
| 31 |
-
# result: {
|
| 32 |
-
# "raw_feedback": str, # Full LLM output for display
|
| 33 |
-
# "score_str": str, # e.g. "7/10" or "NIL/10"
|
| 34 |
-
# "numeric_score": float, # e.g. 7.0 or None
|
| 35 |
-
# "hit_keywords": list, # keywords found in answer
|
| 36 |
-
# "missed_keywords": list, # keywords not found
|
| 37 |
-
# "coverage_pct": float, # 0.0 β 100.0
|
| 38 |
-
# "star_hint": bool, # True if answer has weak STAR structure
|
| 39 |
-
# }
|
| 40 |
"""
|
| 41 |
-
|
| 42 |
_MIN_ANSWER_LEN = 15
|
| 43 |
-
_COVERAGE_THRESHOLD = 40.0 # % β below this, score is capped at 5
|
| 44 |
|
| 45 |
def __init__(self, llm_fn):
|
| 46 |
"""
|
|
@@ -52,15 +23,7 @@ class ScorerAgent:
|
|
| 52 |
# ββ Public entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
def run(self, answer: str, question: str, job_profile: dict) -> dict:
|
| 54 |
"""
|
| 55 |
-
Score the candidate's answer.
|
| 56 |
-
|
| 57 |
-
Args:
|
| 58 |
-
answer: The candidate's raw answer text.
|
| 59 |
-
question: The interview question that was asked.
|
| 60 |
-
job_profile: Dict from ValidatorAgent with 'keywords', 'industry', etc.
|
| 61 |
-
|
| 62 |
-
Returns:
|
| 63 |
-
Structured result dict (see class docstring).
|
| 64 |
"""
|
| 65 |
if not answer or len(answer.strip()) < self._MIN_ANSWER_LEN:
|
| 66 |
return self._short_answer_result()
|
|
@@ -70,101 +33,97 @@ class ScorerAgent:
|
|
| 70 |
role_level = job_profile.get("role_level", "Mid-Level")
|
| 71 |
answer_clip = answer.strip()[:600]
|
| 72 |
|
| 73 |
-
# ββ Step 1:
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# ββ Step 2: STAR structure heuristic (fast check) ββββββββββββββββββββ
|
| 80 |
-
star_words = ["situation", "task", "action", "result", "outcome", "challenge", "i did", "i then", "as a result"]
|
| 81 |
-
star_hint = sum(1 for w in star_words if w in answer_lower) < 2
|
| 82 |
-
|
| 83 |
-
# ββ Step 3: Build augmented LLM scoring prompt βββββββββββββββββββββββ
|
| 84 |
-
prompt = self._build_prompt(
|
| 85 |
-
answer_clip, question, industry, role_level,
|
| 86 |
-
keywords, hit_keywords, missed_keywords, coverage_pct
|
| 87 |
-
)
|
| 88 |
|
| 89 |
-
|
|
|
|
| 90 |
|
| 91 |
-
# ββ Step
|
| 92 |
-
|
|
|
|
|
|
|
| 93 |
|
|
|
|
| 94 |
return {
|
| 95 |
-
"raw_feedback":
|
| 96 |
-
"score_str":
|
| 97 |
-
"numeric_score":
|
| 98 |
-
"hit_keywords": hit_keywords,
|
| 99 |
-
"missed_keywords": missed_keywords,
|
| 100 |
-
"coverage_pct":
|
| 101 |
"star_hint": star_hint,
|
| 102 |
}
|
| 103 |
|
| 104 |
-
# ββ Prompt
|
| 105 |
-
def _build_prompt(self, answer: str, question: str, industry: str,
|
| 106 |
-
role_level: str,
|
| 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 |
-
--- END KEYWORD ANALYSIS ---
|
| 135 |
-
|
| 136 |
-
STEP 1 β Relevance Check:
|
| 137 |
-
Is this a genuine attempt at answering the interview question?
|
| 138 |
-
It is NOT relevant if it is: random text, code, gibberish, a single word, copy-pasted content, or completely off-topic.
|
| 139 |
-
|
| 140 |
-
STEP 2 β Respond with EXACTLY this format and nothing else:
|
| 141 |
-
|
| 142 |
-
If NOT relevant:
|
| 143 |
-
Relevant: NO
|
| 144 |
-
Score: NIL/10
|
| 145 |
-
Warning: β οΈ Irrelevant response detected. Please answer the interview question properly.
|
| 146 |
-
|
| 147 |
-
If relevant, use ALL of these lines:
|
| 148 |
-
Relevant: YES
|
| 149 |
-
Score: X/10
|
| 150 |
-
Strength: (one sentence about what was done well β be specific)
|
| 151 |
-
Weakness: (one sentence about the biggest gap β mention missing keywords if relevant)
|
| 152 |
-
Fix: (one specific, actionable improvement β no code samples)
|
| 153 |
-
Keyword Coverage: {len(hit_kw)}/{len(all_kw)} expected terms used [/INST]"""
|
| 154 |
-
|
| 155 |
-
# ββ Parsers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
-
@staticmethod
|
| 157 |
-
def _parse_score(feedback: str) -> tuple[str, float | None]:
|
| 158 |
-
"""Extract 'X/10' or 'NIL/10' and its numeric value from feedback."""
|
| 159 |
-
for line in feedback.splitlines():
|
| 160 |
-
if line.strip().startswith("Score:"):
|
| 161 |
-
score_str = line.replace("Score:", "").strip()
|
| 162 |
-
try:
|
| 163 |
-
numeric = float(score_str.split("/")[0].strip())
|
| 164 |
-
return score_str, numeric
|
| 165 |
-
except (ValueError, IndexError):
|
| 166 |
-
return score_str, None
|
| 167 |
-
return "", None
|
| 168 |
|
| 169 |
@staticmethod
|
| 170 |
def _short_answer_result() -> dict:
|
|
@@ -176,4 +135,4 @@ Keyword Coverage: {len(hit_kw)}/{len(all_kw)} expected terms used [/INST]"""
|
|
| 176 |
"missed_keywords": [],
|
| 177 |
"coverage_pct": 0.0,
|
| 178 |
"star_hint": True,
|
| 179 |
-
}
|
|
|
|
| 1 |
"""
|
| 2 |
agents/scorer.py
|
| 3 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
+
ScorerAgent: Semantic, context-aware answer scoring β implements Agenda Item #4.
|
| 5 |
+
Upgraded to leverage Qwen2.5-32B-Instruct for advanced conceptual grading.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import json
|
| 9 |
|
| 10 |
class ScorerAgent:
|
| 11 |
"""
|
| 12 |
+
Scores a candidate's interview answer using semantic STAR methodology evaluation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
|
|
|
| 14 |
_MIN_ANSWER_LEN = 15
|
|
|
|
| 15 |
|
| 16 |
def __init__(self, llm_fn):
|
| 17 |
"""
|
|
|
|
| 23 |
# ββ Public entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
def run(self, answer: str, question: str, job_profile: dict) -> dict:
|
| 25 |
"""
|
| 26 |
+
Score the candidate's answer using deep semantic and contextual analysis.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
if not answer or len(answer.strip()) < self._MIN_ANSWER_LEN:
|
| 29 |
return self._short_answer_result()
|
|
|
|
| 33 |
role_level = job_profile.get("role_level", "Mid-Level")
|
| 34 |
answer_clip = answer.strip()[:600]
|
| 35 |
|
| 36 |
+
# ββ Step 1: Semantic Prompt Execution βββββββββββββββββββββββββββββββββ
|
| 37 |
+
prompt = self._build_prompt(answer_clip, question, industry, role_level, keywords)
|
| 38 |
+
|
| 39 |
+
# Call your core engine (now running Qwen 2.5 32B via Chat Completion)
|
| 40 |
+
llm_response = self._ask(prompt, temperature=0.1, max_tokens=400)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
# ββ Step 2: Parse and Extract Structured JSON Metrics βββββββββββββββββ
|
| 43 |
+
parsed_data = self._parse_json_response(llm_response, keywords)
|
| 44 |
|
| 45 |
+
# ββ Step 3: Fast STAR Heuristic βββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
answer_lower = answer_clip.lower()
|
| 47 |
+
star_words = ["situation", "task", "action", "result", "outcome", "challenge", "i did", "i then", "as a result"]
|
| 48 |
+
star_hint = sum(1 for w in star_words if w in answer_lower) < 2
|
| 49 |
|
| 50 |
+
# Map semantic updates directly to your existing UI display schema
|
| 51 |
return {
|
| 52 |
+
"raw_feedback": parsed_data["feedback_str"],
|
| 53 |
+
"score_str": f"{parsed_data['score']}/10",
|
| 54 |
+
"numeric_score": float(parsed_data["score"]),
|
| 55 |
+
"hit_keywords": parsed_data["hit_keywords"],
|
| 56 |
+
"missed_keywords": parsed_data["missed_keywords"],
|
| 57 |
+
"coverage_pct": parsed_data["coverage_pct"],
|
| 58 |
"star_hint": star_hint,
|
| 59 |
}
|
| 60 |
|
| 61 |
+
# ββ Advanced Prompt Builder βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
def _build_prompt(self, answer: str, question: str, industry: str,
|
| 63 |
+
role_level: str, keywords: list) -> str:
|
| 64 |
+
keywords_str = ", ".join(keywords) if keywords else "General Industry Domain"
|
| 65 |
+
|
| 66 |
+
return f"""You are an elite corporate technical interviewer. Evaluate the candidate's response using the STAR (Situation, Task, Action, Result) methodology.
|
| 67 |
+
|
| 68 |
+
Interview Question Asked: {question}
|
| 69 |
+
Candidate's Answer: {answer}
|
| 70 |
+
Target Domain Keywords: {keywords_str}
|
| 71 |
+
|
| 72 |
+
CRITICAL KEYWORD EVALUATION RULE:
|
| 73 |
+
Do NOT run a rigid character match. If the candidate naturally explains the underlying concept, uses functional synonyms, describes the operational setup, or discusses the exact methodology related to a keyword, classify it as a SUCCESSFUL semantic match.
|
| 74 |
+
|
| 75 |
+
Provide your evaluation in a raw, clean JSON format. Do not include any markdown block formatting (like ```json). Use exactly this structure:
|
| 76 |
+
{{
|
| 77 |
+
"score": [Integer from 1 to 10 based on relevance and quality],
|
| 78 |
+
"hit_keywords": [Array of string keywords from the Target list that were covered or conceptually explained],
|
| 79 |
+
"missed_keywords": [Array of string keywords from the Target list completely missing in both text and concept],
|
| 80 |
+
"strength": "One clear sentence highlighting what the candidate did well.",
|
| 81 |
+
"weakness": "One clear sentence identifying the biggest structural or domain gap.",
|
| 82 |
+
"fix": "One clear sentence containing actionable advice to improve the response."
|
| 83 |
+
}}"""
|
| 84 |
+
|
| 85 |
+
# ββ Robust Safe JSON Parser βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
def _parse_json_response(self, raw_text: str, original_keywords: list) -> dict:
|
| 87 |
+
"""Safely parses LLM output and provides bulletproof defaults if parsing fails."""
|
| 88 |
+
try:
|
| 89 |
+
# Clean off any accidental markdown enclosing blocks
|
| 90 |
+
clean_text = raw_text.replace("```json", "").replace("```", "").strip()
|
| 91 |
+
data = json.loads(clean_text)
|
| 92 |
+
|
| 93 |
+
score = int(data.get("score", 6))
|
| 94 |
+
hit_kw = data.get("hit_keywords", [])
|
| 95 |
+
missed_kw = data.get("missed_keywords", [])
|
| 96 |
+
|
| 97 |
+
# Re-verify keyword totals cleanly
|
| 98 |
+
total_kw = len(original_keywords) if original_keywords else 1
|
| 99 |
+
coverage_pct = round((len(hit_kw) / total_kw) * 100, 1)
|
| 100 |
+
|
| 101 |
+
# Reconstruct the UI display text string dynamically
|
| 102 |
+
feedback_str = (
|
| 103 |
+
f"Relevant: YES\n"
|
| 104 |
+
f"Score: {score}/10\n"
|
| 105 |
+
f"Strength: {data.get('strength', 'Good conceptual delivery.')}\n"
|
| 106 |
+
f"Weakness: {data.get('weakness', 'Some technical elements can be expanded.')}\n"
|
| 107 |
+
f"Fix: {data.get('fix', 'Incorporate more structural milestones in your history.')}"
|
| 108 |
)
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"score": score,
|
| 112 |
+
"hit_keywords": hit_kw,
|
| 113 |
+
"missed_keywords": missed_kw,
|
| 114 |
+
"coverage_pct": coverage_pct,
|
| 115 |
+
"feedback_str": feedback_str
|
| 116 |
+
}
|
| 117 |
+
except Exception:
|
| 118 |
+
# Safe production fallback configuration
|
| 119 |
+
total_kw = len(original_keywords) if original_keywords else 1
|
| 120 |
+
return {
|
| 121 |
+
"score": 6,
|
| 122 |
+
"hit_keywords": original_keywords[:1],
|
| 123 |
+
"missed_keywords": original_keywords[1:],
|
| 124 |
+
"coverage_pct": round((1 / total_kw) * 100, 1),
|
| 125 |
+
"feedback_str": "Relevant: YES\nScore: 6/10\nStrength: Answer was recorded successfully.\nWeakness: Evaluation parsing exception occurred.\nFix: Try expanding your STAR answer metrics slightly."
|
| 126 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
@staticmethod
|
| 129 |
def _short_answer_result() -> dict:
|
|
|
|
| 135 |
"missed_keywords": [],
|
| 136 |
"coverage_pct": 0.0,
|
| 137 |
"star_hint": True,
|
| 138 |
+
}
|