torchdocs-agent / eval /checks.py
eliezer avihail
Codebase quality pass: kill silent-truncation hacks, fix latent bugs, cover them (#46)
95d6832 unverified
Raw
History Blame Contribute Delete
4.66 kB
"""Static checks on every Answer β€” no code execution, ever.
Three checks per the plan:
parses β€” every code block in answer_md is valid Python (ast.parse)
imports β€” every import in those blocks is torch-family or stdlib
symbols β€” every symbol in symbols_used actually appears in the answer
"""
from __future__ import annotations
import ast
import re
import sys
import textwrap
from agent.schemas import Answer
# only explicitly-tagged python blocks: untagged fences are often directory
# trees, shell output, or prose that must not be parsed as code. Case-insensitive
# and accept python/py/python3 (``` Python ```, ``` py3 ``` all count) so a bad
# import can't hide behind a capitalised tag. `pycon` is intentionally excluded:
# console sessions carry >>> prompts and output that never ast.parse.
CODE_BLOCK_RE = re.compile(
r"```[ \t]*(?:python3?|py)[ \t]*\r?\n(.*?)```", re.DOTALL | re.IGNORECASE
)
ALLOWED_IMPORT_ROOTS = frozenset(sys.stdlib_module_names) | {
"torch",
"torchvision",
"torchaudio",
}
def extract_code_blocks(answer_md: str) -> list[str]:
# models often indent whole blocks (e.g. inside markdown lists) β€” dedent first
return [textwrap.dedent(block).strip() for block in CODE_BLOCK_RE.findall(answer_md)]
def check_code_parses(answer: Answer) -> str | None:
for i, block in enumerate(extract_code_blocks(answer.answer_md)):
try:
ast.parse(block)
except SyntaxError as exc:
return f"code block {i}: {exc.msg} (line {exc.lineno})"
return None
def check_imports_allowed(answer: Answer) -> str | None:
for block in extract_code_blocks(answer.answer_md):
try:
tree = ast.parse(block)
except SyntaxError:
continue # already reported by check_code_parses
for node in ast.walk(tree):
roots: list[str] = []
if isinstance(node, ast.Import):
roots = [alias.name.split(".")[0] for alias in node.names]
elif isinstance(node, ast.ImportFrom):
if node.level > 0: # `from . import x` β€” never valid in a docs answer
return f"disallowed import: relative '{'.' * node.level}{node.module or ''}'"
if node.module:
roots = [node.module.split(".")[0]]
for root in roots:
if root not in ALLOWED_IMPORT_ROOTS:
return f"disallowed import: {root}"
return None
def _symbol_present(symbol: str, answer_md: str) -> bool:
"""True if the symbol appears in any conventional spelling.
Answers legitimately write `nn.Linear` for torch.nn.Linear, `F.relu`
for torch.nn.functional.relu, or `.add_(...)` for torch.Tensor.add_ β€”
this check verifies bookkeeping consistency, not exact spelling.
"""
parts = symbol.split(".")
candidates = {symbol}
if symbol.startswith("torch."):
candidates.add(symbol.removeprefix("torch."))
if len(parts) >= 2:
candidates.add(".".join(parts[-2:]))
# a bare last component is only accepted if it's distinctive: short tails
# (torch.T, ...functional.F) would substring-match almost any prose
if len(parts[-1]) >= 3:
candidates.add(parts[-1])
# word-boundary match so `torch.relu` isn't "found" inside `prelu` and
# `nn.Linear` isn't satisfied by the English word "linear"
return any(re.search(rf"\b{re.escape(c)}\b", answer_md) for c in candidates)
def check_symbols_present(answer: Answer) -> str | None:
missing = [s for s in answer.symbols_used if not _symbol_present(s, answer.answer_md)]
if missing:
return f"symbols listed but not in answer: {', '.join(missing)}"
return None
CHECKS = [
("parses", check_code_parses),
("imports", check_imports_allowed),
("symbols", check_symbols_present),
]
def run_checks(answer: Answer) -> dict[str, str | None]:
"""Run all checks; value is None on pass, else the failure reason."""
return {name: fn(answer) for name, fn in CHECKS}
def format_table(rows: list[tuple[str, dict[str, str | None]]]) -> str:
"""rows: [(question_id, check_results)] β†’ aligned pass/fail table."""
names = [name for name, _ in CHECKS]
header = f"{'id':<8}" + "".join(f"{n:<10}" for n in names)
lines = [header, "-" * len(header)]
for qid, results in rows:
cells = "".join(f"{'ok' if results[n] is None else 'FAIL':<10}" for n in names)
lines.append(f"{qid:<8}{cells}")
for n in names:
if results[n] is not None:
lines.append(f" {n}: {results[n]}")
return "\n".join(lines)