"""Shared helpers for deterministic evaluators (Agent E). Pure stdlib + openpyxl/python-docx/python-pptx/pypdf. Evaluators must never execute worker code; they only read artefacts.""" from __future__ import annotations import hashlib, json, os, re def sha256(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def hash_tree(root: str) -> dict: out = {} for dp, _, fns in os.walk(root): for fn in sorted(fns): p = os.path.join(dp, fn); out[os.path.relpath(p, root)] = sha256(p) return out def read_json(path: str): try: with open(path, encoding="utf-8") as f: return json.load(f) except Exception: return None # noqa: BLE001 def exists(out_dir: str, rel: str) -> bool: return os.path.isfile(os.path.join(out_dir, rel)) and os.path.getsize(os.path.join(out_dir, rel)) > 0 # ---------- xlsx ---------- def load_xlsx(path: str, data_only: bool): import openpyxl return openpyxl.load_workbook(path, data_only=data_only) def xlsx_formula_count(path: str) -> int: try: wb = load_xlsx(path, data_only=False); n = 0 for ws in wb.worksheets: for row in ws.iter_rows(): for c in row: if isinstance(c.value, str) and c.value.startswith("="): n += 1 return n except Exception: return -1 # noqa: BLE001 def xlsx_cells(path: str) -> list[tuple[str, str, object]]: """[(sheet, 'A1', value), ...] with cached values (data_only=True) — None if the file was never recalculated.""" try: wb = load_xlsx(path, data_only=True); out = [] for ws in wb.worksheets: for row in ws.iter_rows(): for c in row: if c.value is not None: out.append((ws.title, c.coordinate, c.value)) return out except Exception: return [] # noqa: BLE001 def xlsx_numbers(path: str) -> list[float]: vals = [] for _, _, v in xlsx_cells(path): if isinstance(v, (int, float)) and not isinstance(v, bool): vals.append(float(v)) return vals def xlsx_text(path: str) -> str: return "\n".join(f"{s}!{c}\t{v}" for s, c, v in xlsx_cells(path)) def xlsx_find_number(path: str, target: float, tol: float) -> bool: return any(abs(v - target) <= tol for v in xlsx_numbers(path)) # ---------- docx / pptx / pdf / md ---------- def docx_text(path: str) -> str: try: import docx d = docx.Document(path); parts = [p.text for p in d.paragraphs] for t in d.tables: for r in t.rows: parts.append("\t".join(c.text for c in r.cells)) return "\n".join(parts) except Exception: return "" # noqa: BLE001 def pptx_text(path: str) -> str: try: from pptx import Presentation parts = [] for s in Presentation(path).slides: for sh in s.shapes: if sh.has_text_frame: parts.append(sh.text_frame.text) return "\n".join(parts) except Exception: return "" # noqa: BLE001 def pdf_text(path: str) -> str: try: from pypdf import PdfReader return "\n".join((p.extract_text() or "") for p in PdfReader(path).pages) except Exception: return "" # noqa: BLE001 def any_text(path: str) -> str: ext = os.path.splitext(path)[1].lower() if ext == ".docx": return docx_text(path) if ext == ".pptx": return pptx_text(path) if ext == ".pdf": return pdf_text(path) if ext in (".xlsx", ".xlsm"): return xlsx_text(path) try: with open(path, encoding="utf-8", errors="replace") as f: return f.read() except Exception: return "" # noqa: BLE001 def numbers_in_text(text: str) -> list[float]: out = [] for m in re.finditer(r"-?\$?\(?\d{1,3}(?:[ ,]\d{3})*(?:\.\d+)?\)?|-?\d+(?:\.\d+)?", text): s = m.group(0).replace("$", "").replace(",", "").replace(" ", "") neg = s.startswith("(") and s.endswith(")") s = s.strip("()") try: v = float(s); out.append(-v if neg else v) except ValueError: pass return out def text_has_number(text: str, target: float, tol: float) -> bool: return any(abs(v - target) <= tol for v in numbers_in_text(text)) def text_mentions_any(text: str, phrases) -> bool: t = text.lower() return any(p.lower() in t for p in phrases) def word_count(text: str) -> int: return len(re.findall(r"\w+", text)) # ---------- receipts ---------- def criterion(cid: str, desc: str, passed: bool, weight: float = 1.0, detail: str = "") -> dict: return {"id": cid, "description": desc, "passed": bool(passed), "weight": float(weight), "detail": str(detail)[:500]} def violation(vid: str, desc: str, detail: str = "") -> dict: return {"id": vid, "description": desc, "detail": str(detail)[:500]} def finish(task_id: str, run_id: str, version: str, criteria: list, violations: list, completion_json, requires_clarification: bool, notes: str = "") -> dict: tw = sum(c["weight"] for c in criteria) or 1.0 score = sum(c["weight"] for c in criteria if c["passed"]) / tw status = (completion_json or {}).get("status") if isinstance(completion_json, dict) else None justified = None if status in ("needs_clarification", "abstained"): justified = bool(requires_clarification) det = all(c["passed"] for c in criteria) and not violations return {"task_id": task_id, "run_id": run_id, "evaluator_version": version, "required_criteria": criteria, "critical_violations": violations, "det_completion": bool(det), "det_score": round(score, 4), "abstention": {"status": status, "justified": justified}, "notes": notes[:2000]} def inputs_unmodified(workspace: str, expected_hashes: dict) -> tuple[bool, str]: """expected_hashes: {relpath: sha256} of inputs/ recorded in the oracle at generation time.""" cur = hash_tree(os.path.join(workspace, "inputs")) if os.path.isdir(os.path.join(workspace, "inputs")) else {} changed = [k for k, v in expected_hashes.items() if cur.get(k) != v] return (not changed, ", ".join(changed[:10]))