ai-code-review-env / env /grader.py
YAXH64
fix: docker build issue
a2ae9c3
Raw
History Blame Contribute Delete
4.89 kB
"""
Deterministic graders for the Code Review environment.
Rules:
- No randomness, no LLM calls β€” fully deterministic.
- All functions return float in [0.0, 1.0].
- grade_identify: partial credit via keyword matching.
- grade_fix: strict match first, then token-normalized fallback.
"""
import re
from typing import List
# ---------------------------------------------------------------------------
# Identify grader β€” partial scoring
# ---------------------------------------------------------------------------
def grade_identify(expected_keywords: List[str], response: str) -> float:
"""
Score an identify action by counting matched keywords.
Scoring:
- Smooth partial scoring: score = matched / total keywords.
- Whole-word regex matching prevents substring false-positives.
- No hard threshold β€” every matched keyword contributes proportionally.
- Score is capped at 1.0.
Args:
expected_keywords: List of keywords the agent should mention.
response: The agent's identify explanation (free text).
Returns:
float in [0.0, 1.0]
"""
if not response or not expected_keywords:
return 0.0
normalized = response.lower()
matched = [
kw for kw in expected_keywords
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", normalized)
]
score = len(matched) / len(expected_keywords)
return round(min(score, 1.0), 2)
# ---------------------------------------------------------------------------
# Fix grader β€” strict + normalized fallback
# ---------------------------------------------------------------------------
def _normalize(code: str) -> str:
"""
Normalize code for comparison:
- Strip leading/trailing whitespace
- Collapse all internal whitespace runs to a single space
- Lowercase
"""
code = code.strip()
code = re.sub(r"\s+", " ", code)
return code.lower()
def _tokenize(code: str) -> List[str]:
"""Split normalized code into alphanum tokens for partial overlap scoring."""
return re.findall(r"[a-z0-9]+", _normalize(code))
def grade_fix(expected_code: str, response: str) -> float:
"""
Score a fix action against the expected fixed code.
Scoring tiers:
1. Exact match (after stripping) β†’ 1.0
2. Normalized match (whitespace collapsed) β†’ 0.9
3. Token overlap β‰₯ 80% β†’ 0.6
4. Token overlap β‰₯ 50% β†’ 0.3
5. Below 50% overlap β†’ 0.0
The response may contain surrounding explanation β€” we extract the
largest code-like block for comparison before grading.
Args:
expected_code: The correct fixed version of the buggy code.
response: The agent's fix response (may include explanation).
Returns:
float in [0.0, 1.0]
"""
if not response:
return 0.0
# Tier 1 β€” exact strip match on raw response before any extraction
if response.strip() == expected_code.strip():
return 1.0
candidate = _extract_code(response)
# Tier 1b β€” exact strip match on extracted block
if candidate.strip() == expected_code.strip():
return 1.0
# Tier 2 β€” normalized whitespace match
if _normalize(candidate) == _normalize(expected_code):
return 0.9
# Tier 3 & 4 β€” token overlap
expected_tokens = _tokenize(expected_code)
candidate_tokens = _tokenize(candidate)
if not expected_tokens:
return 0.0
# Overlap: count how many expected tokens appear in candidate
candidate_set = set(candidate_tokens)
matched = sum(1 for t in expected_tokens if t in candidate_set)
overlap = matched / len(expected_tokens)
if overlap >= 0.8:
return 0.6
if overlap >= 0.5:
return 0.3
return 0.0
# ---------------------------------------------------------------------------
# Internal helper β€” extract most code-like block from agent response
# ---------------------------------------------------------------------------
def _extract_code(response: str) -> str:
"""
Extract the most likely code block from a free-text response.
Strategy (in order):
1. Pull content from ```...``` fenced blocks if present.
2. Pull lines that look like code (contain { } ; = ( ) arrows).
3. Fall back to the full response stripped.
"""
# 1. Fenced code block
fenced = re.findall(r"```(?:\w+)?\n?(.*?)```", response, re.DOTALL)
if fenced:
return max(fenced, key=len).strip()
# 2. Lines that look like code
code_lines = []
for line in response.splitlines():
stripped = line.strip()
if re.search(r"[{}();=>\[\]]", stripped):
code_lines.append(stripped)
if code_lines:
return "\n".join(code_lines)
# 3. Full response
return response.strip()