1"""Shared helpers for deterministic evaluators (Agent E). Pure stdlib + openpyxl/python-docx/python-pptx/pypdf.2Evaluators must never execute worker code; they only read artefacts."""3from __future__ import annotations4import hashlib, json, os, re56def sha256(path: str) -> str:7 h = hashlib.sha256()8 with open(path, "rb") as f:9 for chunk in iter(lambda: f.read(1 << 20), b""):10 h.update(chunk)11 return h.hexdigest()1213def hash_tree(root: str) -> dict:14 out = {}15 for dp, _, fns in os.walk(root):16 for fn in sorted(fns):17 p = os.path.join(dp, fn); out[os.path.relpath(p, root)] = sha256(p)18 return out1920def read_json(path: str):21 try:22 with open(path, encoding="utf-8") as f: return json.load(f)23 except Exception: return None # noqa: BLE0012425def exists(out_dir: str, rel: str) -> bool:26 return os.path.isfile(os.path.join(out_dir, rel)) and os.path.getsize(os.path.join(out_dir, rel)) > 02728# ---------- xlsx ----------29def load_xlsx(path: str, data_only: bool):30 import openpyxl31 return openpyxl.load_workbook(path, data_only=data_only)3233def xlsx_formula_count(path: str) -> int:34 try:35 wb = load_xlsx(path, data_only=False); n = 036 for ws in wb.worksheets:37 for row in ws.iter_rows():38 for c in row:39 if isinstance(c.value, str) and c.value.startswith("="): n += 140 return n41 except Exception: return -1 # noqa: BLE0014243def xlsx_cells(path: str) -> list[tuple[str, str, object]]:44 """[(sheet, 'A1', value), ...] with cached values (data_only=True) — None if the file was never recalculated."""45 try:46 wb = load_xlsx(path, data_only=True); out = []47 for ws in wb.worksheets:48 for row in ws.iter_rows():49 for c in row:50 if c.value is not None: out.append((ws.title, c.coordinate, c.value))51 return out52 except Exception: return [] # noqa: BLE0015354def xlsx_numbers(path: str) -> list[float]:55 vals = []56 for _, _, v in xlsx_cells(path):57 if isinstance(v, (int, float)) and not isinstance(v, bool): vals.append(float(v))58 return vals5960def xlsx_text(path: str) -> str:61 return "\n".join(f"{s}!{c}\t{v}" for s, c, v in xlsx_cells(path))6263def xlsx_find_number(path: str, target: float, tol: float) -> bool:64 return any(abs(v - target) <= tol for v in xlsx_numbers(path))6566# ---------- docx / pptx / pdf / md ----------67def docx_text(path: str) -> str:68 try:69 import docx70 d = docx.Document(path); parts = [p.text for p in d.paragraphs]71 for t in d.tables:72 for r in t.rows: parts.append("\t".join(c.text for c in r.cells))73 return "\n".join(parts)74 except Exception: return "" # noqa: BLE0017576def pptx_text(path: str) -> str:77 try:78 from pptx import Presentation79 parts = []80 for s in Presentation(path).slides:81 for sh in s.shapes:82 if sh.has_text_frame: parts.append(sh.text_frame.text)83 return "\n".join(parts)84 except Exception: return "" # noqa: BLE0018586def pdf_text(path: str) -> str:87 try:88 from pypdf import PdfReader89 return "\n".join((p.extract_text() or "") for p in PdfReader(path).pages)90 except Exception: return "" # noqa: BLE0019192def any_text(path: str) -> str:93 ext = os.path.splitext(path)[1].lower()94 if ext == ".docx": return docx_text(path)95 if ext == ".pptx": return pptx_text(path)96 if ext == ".pdf": return pdf_text(path)97 if ext in (".xlsx", ".xlsm"): return xlsx_text(path)98 try:99 with open(path, encoding="utf-8", errors="replace") as f: return f.read()100 except Exception: return "" # noqa: BLE001101102def numbers_in_text(text: str) -> list[float]:103 out = []104 for m in re.finditer(r"-?\$?\(?\d{1,3}(?:[ ,]\d{3})*(?:\.\d+)?\)?|-?\d+(?:\.\d+)?", text):105 s = m.group(0).replace("$", "").replace(",", "").replace(" ", "")106 neg = s.startswith("(") and s.endswith(")")107 s = s.strip("()")108 try:109 v = float(s); out.append(-v if neg else v)110 except ValueError: pass111 return out112113def text_has_number(text: str, target: float, tol: float) -> bool:114 return any(abs(v - target) <= tol for v in numbers_in_text(text))115116def text_mentions_any(text: str, phrases) -> bool:117 t = text.lower()118 return any(p.lower() in t for p in phrases)119120def word_count(text: str) -> int:121 return len(re.findall(r"\w+", text))122123# ---------- receipts ----------124def criterion(cid: str, desc: str, passed: bool, weight: float = 1.0, detail: str = "") -> dict:125 return {"id": cid, "description": desc, "passed": bool(passed), "weight": float(weight), "detail": str(detail)[:500]}126127def violation(vid: str, desc: str, detail: str = "") -> dict:128 return {"id": vid, "description": desc, "detail": str(detail)[:500]}129130def finish(task_id: str, run_id: str, version: str, criteria: list, violations: list, completion_json, requires_clarification: bool, notes: str = "") -> dict:131 tw = sum(c["weight"] for c in criteria) or 1.0132 score = sum(c["weight"] for c in criteria if c["passed"]) / tw133 status = (completion_json or {}).get("status") if isinstance(completion_json, dict) else None134 justified = None135 if status in ("needs_clarification", "abstained"): justified = bool(requires_clarification)136 det = all(c["passed"] for c in criteria) and not violations137 return {"task_id": task_id, "run_id": run_id, "evaluator_version": version, "required_criteria": criteria,138 "critical_violations": violations, "det_completion": bool(det), "det_score": round(score, 4),139 "abstention": {"status": status, "justified": justified}, "notes": notes[:2000]}140141def inputs_unmodified(workspace: str, expected_hashes: dict) -> tuple[bool, str]:142 """expected_hashes: {relpath: sha256} of inputs/ recorded in the oracle at generation time."""143 cur = hash_tree(os.path.join(workspace, "inputs")) if os.path.isdir(os.path.join(workspace, "inputs")) else {}144 changed = [k for k, v in expected_hashes.items() if cur.get(k) != v]145 return (not changed, ", ".join(changed[:10]))146