"""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