Spaces:
Paused
Paused
File size: 8,149 Bytes
578518d | 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 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | """Evaluation engine: rubric-driven, evidence-based scoring of free-text answers.
Rules enforced here:
- NO SCORE WITHOUT EVIDENCE: every score above 1 must reference specific user
statements (quotes extracted from the answer). No evidence => score 1.
- ANTI-GAMING: score derives ONLY from concept coverage, tradeoff mentions and
quantified results — never from answer length. A long waffle that covers no
rubric concepts scores below a short precise answer.
- LLM output (when available) must validate against the EvaluationResult
contract AND its evidence quotes must actually appear in the user's answer;
otherwise the deterministic rule-based result is used. An LLM may never
raise the score more than one point above the rule-based floor.
"""
from __future__ import annotations
import json
import re
from typing import List, Optional, Tuple
from sensorflow.hillclimb import coach, llm
from sensorflow.hillclimb.content import Exercise, RubricItem
from sensorflow.hillclimb.models import EvaluationResult
QUANT_RE = re.compile(
r"\d+(?:\.\d+)?\s*(?:%|percent|ms\b|s\b|x\b|qps\b|rps\b|fps\b|gb\b|tb\b|pb\b|"
r"days?\b|hours?\b|weeks?\b|engineers?\b|gpus?\b|nodes?\b|replicas?\b|frames?\b)|"
r"\bfrom\s+\d+(?:\.\d+)?\s+to\s+\d+(?:\.\d+)?|\bp9[059]\b|\d+(?:\.\d+)?\s*sigma",
re.IGNORECASE,
)
TRADEOFF_MARKERS = [
"trade-off", "tradeoff", "trade off", "at the cost of", "sacrific", "downside",
"at the expense", " versus ", " vs ", "on the other hand", "the cost is",
"we give up", "gives up", "in exchange", "instead of", "weighed against",
]
MISCONCEPTION_PATTERNS: List[Tuple[str, str]] = [
(r"accuracy\s+is\s+the\s+best|just\s+use\s+accuracy",
"Accuracy is a poor headline metric for imbalanced detection problems; prefer precision/recall/AP."),
(r"average\s+latency\s+is\s+(fine|enough|what matters)",
"Serving systems are judged by tail latency (p99), not the average."),
(r"more\s+data\s+always\s+(helps|fixes|improves)",
"More data does not fix distribution mismatch, label noise, or serving skew."),
(r"offline\s+(metrics?|eval(uation)?)\s+(is|are)\s+ground\s+truth",
"Offline evaluation is a proxy under its own sampling; it is not ground truth for live behavior."),
(r"definitely\s+a\s+(true\s+)?regression|certainly\s+regressed",
"Claiming certainty before significance testing / measurement validation is premature."),
]
def split_sentences(text: str) -> List[str]:
parts = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
return [p.strip() for p in parts if p.strip()]
def _clip(sentence: str, limit: int = 220) -> str:
return sentence if len(sentence) <= limit else sentence[:limit].rsplit(" ", 1)[0] + "…"
def _find_sentence(sentences: List[str], predicate) -> Optional[str]:
for s in sentences:
if predicate(s):
return s
return None
def _criterion_evidence(item: RubricItem, answer_lower: str, sentences: List[str]) -> Optional[str]:
"""Return an evidence quote (user's own sentence) if the criterion is met."""
if item.check == "quantified":
hit = _find_sentence(sentences, lambda s: bool(QUANT_RE.search(s)))
return hit
if item.check == "tradeoff":
return _find_sentence(sentences, lambda s: any(m in s.lower() for m in TRADEOFF_MARKERS))
for kw in item.keywords:
if kw.lower() in answer_lower:
hit = _find_sentence(sentences, lambda s: kw.lower() in s.lower())
if hit:
return hit
return None
def rule_based_evaluate(exercise: Exercise, answer: str) -> EvaluationResult:
sentences = split_sentences(answer)
answer_lower = answer.lower()
covered: List[Tuple[RubricItem, str]] = []
missed: List[RubricItem] = []
evidence: List[str] = []
for item in exercise.evaluation_rubric:
quote = _criterion_evidence(item, answer_lower, sentences)
if quote:
covered.append((item, quote))
clipped = _clip(quote)
if clipped not in evidence:
evidence.append(clipped)
else:
missed.append(item)
total_weight = sum(i.weight for i in exercise.evaluation_rubric) or 1.0
covered_weight = sum(i.weight for i, _ in covered)
coverage = covered_weight / total_weight
# Coverage → score. Deliberately independent of answer length.
if coverage >= 0.85:
score = 5
elif coverage >= 0.65:
score = 4
elif coverage >= 0.45:
score = 3
elif coverage >= 0.20:
score = 2
else:
score = 1
# No score without evidence.
if not evidence:
score = 1
misconceptions = [msg for pat, msg in MISCONCEPTION_PATTERNS if re.search(pat, answer_lower)]
strengths = [f"{i.criterion} — \"{_clip(q, 140)}\"" for i, q in covered[:5]]
weaknesses = [i.criterion for i in missed if i.check == "keywords"][:5]
missing_evidence = []
for i in missed:
if i.check == "quantified":
missing_evidence.append("No quantified claims: no numbers, thresholds, or before/after comparisons anywhere in the answer.")
elif i.check == "tradeoff":
missing_evidence.append("No explicit tradeoff stated: nothing about what your approach sacrifices.")
else:
missing_evidence.append(f"Not addressed: {i.criterion.lower()}.")
confidence = round(min(0.9, 0.35 + 0.07 * len(covered)), 2)
result = EvaluationResult(
competency=exercise.competency_id,
score=score,
confidence=confidence,
evidence=evidence,
strengths=strengths,
weaknesses=weaknesses,
missing_evidence=missing_evidence[:6],
misconceptions=misconceptions,
evaluator="rule_based",
)
result.recommended_action = coach.recommended_action(exercise, result)
result.follow_up_question = coach.pick_follow_up(exercise, result)
return result
def _normalize(s: str) -> str:
return re.sub(r"\s+", " ", s.lower()).strip(" .\"'…")
def _llm_result_valid(candidate: EvaluationResult, answer: str) -> bool:
"""LLM evidence must quote the user's actual statements."""
if not candidate.evidence:
return False
normalized_answer = _normalize(answer)
for quote in candidate.evidence:
if _normalize(quote) not in normalized_answer:
return False
return True
def evaluate_response(exercise: Exercise, answer: str, use_llm: bool = True) -> EvaluationResult:
rule_result = rule_based_evaluate(exercise, answer)
if not (use_llm and llm.llm_enabled()):
return rule_result
rubric_lines = "\n".join(f"- {i.criterion}" for i in exercise.evaluation_rubric)
prompt = (
"You are grading an engineering-manager interview answer against a rubric.\n"
f"SCENARIO:\n{exercise.scenario}\n\nRUBRIC:\n{rubric_lines}\n\n"
f"ANSWER:\n{answer}\n\n"
"Respond with ONLY a JSON object with keys: competency (string), score (int 1-5), "
"confidence (0-1), evidence (array of EXACT quotes copied verbatim from the answer), "
"strengths (array), weaknesses (array), missing_evidence (array), misconceptions (array), "
"recommended_action (string), follow_up_question (string). "
"Every point awarded must be justified by a quote in evidence. Do not reward verbosity."
)
candidate = llm.generate_json(prompt, EvaluationResult, timeout=25.0)
if candidate is None or not _llm_result_valid(candidate, answer):
return rule_result
candidate.competency = exercise.competency_id
candidate.evaluator = "llm"
# Anti-gaming guard: the LLM may refine, but never inflate more than one
# point above the deterministic evidence-based floor.
if candidate.score > rule_result.score + 1:
candidate.score = rule_result.score + 1
if not candidate.recommended_action:
candidate.recommended_action = coach.recommended_action(exercise, candidate)
if not candidate.follow_up_question:
candidate.follow_up_question = coach.pick_follow_up(exercise, candidate)
return candidate
|