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