diff --git a/external_plugins/preprocess-legal/.claude-plugin/config-template.md b/external_plugins/preprocess-legal/.claude-plugin/config-template.md new file mode 100644 index 0000000000..e8adb96b8a --- /dev/null +++ b/external_plugins/preprocess-legal/.claude-plugin/config-template.md @@ -0,0 +1,35 @@ +# preprocess-legal — configuration + +The engine reads a JSON config. Point the hook at it with the +`PREPROCESS_LEGAL_CONFIG` environment variable, or place it at the default path: + +``` +~/.claude/plugins/config/claude-for-legal/preprocess/config.json +``` + +## Example + +```json +{ + "enabled": true, + "threshold": 0.65, + "min_chars_per_page": 100, + "file_types": [".pdf"], + "min_bytes_to_route": 0, + "cache_dir": "" +} +``` + +## Fields + +| Field | Default | Meaning | +|-------|---------|---------| +| `enabled` | `false` | Master switch. Opt-in — while `false`, every Read passes through untouched. | +| `threshold` | `0.65` | Confidence at or above this passes through as clean text; below it, the document is withheld. | +| `min_chars_per_page` | `100` | Characters a page must clear to count as "covered" text (separates a real page from a scanned image's stray characters). | +| `file_types` | `[".pdf"]` | Only these suffixes are intercepted. Everything else is passed through. | +| `min_bytes_to_route` | `0` | Files smaller than this are read directly (not worth extraction latency). `0` disables the floor. | +| `cache_dir` | `""` | Where extracted text / withheld markers are written. Empty → a `.preprocess-cache/` folder beside this config. | + +A missing or malformed config falls back to the safe default (disabled) — a +broken config never breaks your Read tool. diff --git a/external_plugins/preprocess-legal/.claude-plugin/plugin.json b/external_plugins/preprocess-legal/.claude-plugin/plugin.json new file mode 100644 index 0000000000..efa9bbaf15 --- /dev/null +++ b/external_plugins/preprocess-legal/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "preprocess-legal", + "version": "0.1.0", + "description": "Transparent local pre-processing for document-heavy legal work. A PreToolUse hook intercepts each Read of a PDF, extracts text-native content locally, scores per-document extraction confidence, and redirects the read to clean text on a pass or to an honest withheld-marker on a fail. Refuses to pass degraded, plausible-looking extractions into context silently. Opt-in. Slice 1: text-native extraction + confidence gate (OCR fallback deferred).", + "author": { + "name": "Eric Tetzlaff", + "email": "eric@erictetzlaff.com" + } +} diff --git a/external_plugins/preprocess-legal/.gitignore b/external_plugins/preprocess-legal/.gitignore new file mode 100644 index 0000000000..a748c52be2 --- /dev/null +++ b/external_plugins/preprocess-legal/.gitignore @@ -0,0 +1,11 @@ +# Materialized extraction cache (clean text + withheld markers). Local per-user, +# never shipped. +.preprocess-cache/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Local machine settings that must never ship with the plugin +.claude/settings.local.json diff --git a/external_plugins/preprocess-legal/README.md b/external_plugins/preprocess-legal/README.md new file mode 100644 index 0000000000..eba2eaba8e --- /dev/null +++ b/external_plugins/preprocess-legal/README.md @@ -0,0 +1,74 @@ +# preprocess-legal + +Transparent local pre-processing for document-heavy legal work. Addresses +[issue #100](https://github.com/anthropics/claude-for-legal/issues/100): when +Claude reads a folder of PDFs (court filings, contracts, scanned exhibits), each +file is read in its native form — slow and token-heavy for large, scanned, or +poorly-OCR'd matters. + +This plugin intercepts each read, extracts the text locally, and hands the model +clean text instead of the raw PDF — **but only when the extraction is +trustworthy.** A degraded scan is never silently passed through as hollow text. + +## How it works + +A `PreToolUse` hook fires on every `Read`. For a routed PDF it: + +1. **Routes** — is this file in scope? (opt-in, by type and optional size floor) +2. **Extracts** — text-native content via `pdfplumber` +3. **Scores** — per-document confidence, as an explainable breakdown: + - `coverage` — fraction of pages that produced real text + - `density` — mean characters per page vs. a text-native baseline + - `legibility` — fraction of characters that are actually readable + - `overall = (weighted coverage + density) × legibility` — legibility is a + **quality veto**, so a page full of plausible-looking mojibake cannot pass + on quantity alone +4. **Gates** — at/above `threshold` → pass; below → withhold +5. **Redirects** — rewrites the Read's `file_path` (`updatedInput`) to point at: + - a **clean-text** file on a pass, or + - an **honest withheld-marker** on a fail — text that *says* the document was + held back and why, never hollow content masquerading as the document + +Because the engine writes the exact bytes the model reads, "refuse to pass +degraded output silently" is literal, not advisory. The redirect is transparent +to the model (confirmed on Claude Code ≥ v2.1.190, CLI + Cowork + Desktop). + +## Design notes + +- **Per-file, not per-folder.** Hooks fire per tool call — reading 20 PDFs fires + 20 times, each with one path. Routing is per document. +- **Fails safe.** A missing/broken config, a missing `pdfplumber`, or an + encrypted/corrupt PDF never breaks a Read — the hook either withholds (fail + closed) or passes the original through (fail open on unexpected errors). +- **Swappable backend.** Only `engine/extract.py` imports `pdfplumber`. The + scoring and gate logic operate on a plain `ExtractionResult`, so slice 2's OCR + cascade plugs in behind the same interface without touching the gate. +- **Warm cache.** Extraction is synchronous (the redirect target must exist when + the read returns). Re-reading an unchanged file hits a cache keyed on + path + size + mtime. + +## Status + +**Slice 1** — text-native extraction + confidence gate + withheld-marker. +**Deferred:** OCR fallback cascade for low-confidence scans (slice 2), caching +tuning for heavy synchronous matters (slice 3). + +## Install & enable + +1. Install the extraction dependency: `pip install -r requirements.txt` +2. Create a config (see `.claude-plugin/config-template.md`) and set `enabled: true` +3. Point the hook at it via `PREPROCESS_LEGAL_CONFIG`, or use the default path + +## Develop + +``` +python -m pytest tests/ # pure scoring + gate tests, no pdfplumber required +``` + +## Open questions (tracked on issue #100) + +- Scoped to a practice-area plugin, or closer to Cowork itself? Built behind a + clean interface either way. +- Real-matter tuning: scanned-vs-text ratio, latency tolerance, and which + structural losses (tables, exhibit stamps, Bates numbers, redactions) matter + most — awaiting the reporter's answers. diff --git a/external_plugins/preprocess-legal/engine/__init__.py b/external_plugins/preprocess-legal/engine/__init__.py new file mode 100644 index 0000000000..c429742e3b --- /dev/null +++ b/external_plugins/preprocess-legal/engine/__init__.py @@ -0,0 +1,26 @@ +"""preprocess-legal extraction engine. + +Slice 1: text-native PDF extraction + per-document confidence scoring + a gate +that refuses to silently pass degraded output into the model's context. + +The engine is deliberately split so the parts that need the pdfplumber dependency +(``extract``) are isolated from the pure decision logic (``confidence``, ``gate``). +Only ``extract`` imports pdfplumber; everything else operates on plain data and is +unit-testable without any PDF library installed. This keeps the extraction backend +swappable (the interface, not pdfplumber, is the contract) per the plugin's design. +""" + +from .confidence import ConfidenceScore, score_extraction +from .gate import Decision, ProcessResult, decide, process +from .config import PreprocessConfig, load_config + +__all__ = [ + "ConfidenceScore", + "score_extraction", + "Decision", + "ProcessResult", + "decide", + "process", + "PreprocessConfig", + "load_config", +] diff --git a/external_plugins/preprocess-legal/engine/confidence.py b/external_plugins/preprocess-legal/engine/confidence.py new file mode 100644 index 0000000000..64e729b838 --- /dev/null +++ b/external_plugins/preprocess-legal/engine/confidence.py @@ -0,0 +1,128 @@ +"""Per-document extraction confidence scoring. + +Pure functions over an ``ExtractionResult`` — no I/O, no PDF library. Given the +text a backend pulled out of a document, decide how much to trust it. + +The score is deliberately a breakdown, not a single opaque number. A confidence +value with no per-dimension reasons is not auditable, and the whole point of the +gate is that an attorney (or a downstream task) can see *why* a document was held +back. Each dimension is a 0..1 signal; ``overall`` is their weighted mean. + +Dimensions +---------- +coverage Fraction of pages that produced real text. A scanned exhibit run + through a text extractor returns empty pages — low coverage is the + loudest signal that this document is image-based, not text-native. +density Mean characters per page, saturated against a text-native baseline. + A page with 12 stray characters scores near zero; a full page of + body text saturates to 1.0. +legibility Fraction of extracted characters that are actually readable (letters, + digits, ordinary punctuation, whitespace) rather than control bytes + or Unicode replacement chars. Catches the dangerous case: text that + extracted to *something* but is mojibake — plausible-looking garbage + that nothing downstream would flag. +""" + +from __future__ import annotations + +import string +from dataclasses import dataclass +from typing import Dict + +from .models import ExtractionResult + +# Characters we consider legible. Everything printable, plus whitespace. +_LEGIBLE = set(string.printable) + +# A full page of body text runs well over a thousand characters. We saturate the +# density signal here so that anything at or above a normal text page scores 1.0 +# and only sparse / near-empty pages are penalized. +_DENSITY_SATURATION_CHARS = 1200.0 + +# Coverage and density measure *quantity* — is there enough text on the page. +# They are combined into a quantity score, coverage weighted higher because "the +# extractor returned nothing for this page" is the highest-signal indicator of an +# image-only document. +_QUANTITY_WEIGHTS: Dict[str, float] = { + "coverage": 0.6, + "density": 0.4, +} +# Legibility measures *quality* — is the extracted text actually readable rather +# than mojibake. It multiplies the quantity score rather than adding to it, so it +# acts as a veto: a page full of illegible bytes has high quantity but cannot pass +# on quantity alone. This is the defense against text that "extracted to something" +# but is structurally garbage — the expensive way to be wrong in a case file. + + +@dataclass +class ConfidenceScore: + """A 0..1 overall score plus the per-dimension breakdown behind it.""" + + overall: float + dimensions: Dict[str, float] + page_count: int + mean_chars_per_page: float + + def reason(self) -> str: + """One-line human explanation, used in the withheld marker.""" + parts = [f"{k}={v:.2f}" for k, v in self.dimensions.items()] + return ( + f"confidence={self.overall:.2f} " + f"({', '.join(parts)}; {self.page_count} pages, " + f"{self.mean_chars_per_page:.0f} chars/page)" + ) + + +def _legibility(text: str) -> float: + # Whitespace-only text carries no legible content — its "printable" newlines + # must not manufacture confidence for an effectively empty page. + if not text.strip(): + return 0.0 + legible = sum(1 for ch in text if ch in _LEGIBLE) + return legible / len(text) + + +def score_extraction( + result: ExtractionResult, + *, + min_chars_per_page: int = 100, +) -> ConfidenceScore: + """Score how trustworthy an extraction is. + + ``min_chars_per_page`` is the bar a page must clear to count as "covered" — + below it, a page is treated as effectively blank (a scanned image yields a + handful of stray characters at most). + """ + pages = result.page_texts + page_count = len(pages) + + # A hard extraction failure or a document with no pages has no trustworthy + # text by definition. Return an all-zero score so the gate withholds it. + if not result.ok or page_count == 0: + dims = {"coverage": 0.0, "density": 0.0, "legibility": 0.0} + return ConfidenceScore(0.0, dims, page_count, 0.0) + + covered = sum(1 for p in pages if len(p.strip()) >= min_chars_per_page) + coverage = covered / page_count + + total_chars = sum(len(p) for p in pages) + mean_chars = total_chars / page_count + density = min(mean_chars / _DENSITY_SATURATION_CHARS, 1.0) + + legibility = _legibility(result.full_text) + + dims = { + "coverage": round(coverage, 4), + "density": round(density, 4), + "legibility": round(legibility, 4), + } + # Quantity (weighted coverage + density) vetoed by quality (legibility). + quantity = sum(_QUANTITY_WEIGHTS[k] * dims[k] for k in _QUANTITY_WEIGHTS) + overall = quantity * legibility + + return ConfidenceScore( + overall=round(overall, 4), + dimensions=dims, + page_count=page_count, + mean_chars_per_page=round(mean_chars, 1), + ) diff --git a/external_plugins/preprocess-legal/engine/config.py b/external_plugins/preprocess-legal/engine/config.py new file mode 100644 index 0000000000..1959e36f30 --- /dev/null +++ b/external_plugins/preprocess-legal/engine/config.py @@ -0,0 +1,94 @@ +"""Configuration for the preprocess-legal engine. + +Loaded from the practice profile (a JSON file the cold-start / customize flow +writes). Everything has a safe default so the engine runs headless in tests and +so a missing/partial config never crashes a Read — it just falls back to +sensible behavior. + +The capability is opt-in: ``enabled`` defaults to False. Nothing is intercepted +until the attorney turns it on. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + + +@dataclass +class PreprocessConfig: + # Master switch. Opt-in by design — an off config passes every Read through + # untouched. + enabled: bool = False + + # Confidence at or above this passes through as clean text; below it, the + # document is withheld (slice 2 will route it to OCR instead). + threshold: float = 0.65 + + # A page must clear this many characters to count as "covered" text. + min_chars_per_page: int = 100 + + # Only these suffixes are routed. Anything else is passed through untouched. + file_types: List[str] = field(default_factory=lambda: [".pdf"]) + + # Files smaller than this are cheap enough to read directly — no point + # paying extraction latency to save a few tokens. 0 disables the floor. + min_bytes_to_route: int = 0 + + # Where extracted text / withheld markers are written. Resolved to an + # absolute path at load time. Defaults to a cache dir beside the config. + cache_dir: str = "" + + def routes(self, path: Path) -> bool: + """Should this path be intercepted at all?""" + if not self.enabled: + return False + if path.suffix.lower() not in [s.lower() for s in self.file_types]: + return False + try: + if self.min_bytes_to_route and path.stat().st_size < self.min_bytes_to_route: + return False + except OSError: + return False + return True + + +def load_config(config_path: Optional[str] = None) -> PreprocessConfig: + """Load config from JSON, falling back to defaults for any missing key. + + A malformed or missing file yields the default (disabled) config rather than + raising — a broken config must never break the user's Read tool. + """ + cfg = PreprocessConfig() + + if config_path: + p = Path(config_path) + if p.is_file(): + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (ValueError, OSError): + data = {} + for key in ( + "enabled", + "threshold", + "min_chars_per_page", + "file_types", + "min_bytes_to_route", + "cache_dir", + ): + if key in data: + setattr(cfg, key, data[key]) + + # Resolve the cache dir. Default: a hidden cache folder next to the config, + # or under the current dir if no config path was given. + if cfg.cache_dir: + cache = Path(cfg.cache_dir).expanduser() + elif config_path: + cache = Path(config_path).expanduser().resolve().parent / ".preprocess-cache" + else: + cache = Path(".preprocess-cache") + cfg.cache_dir = str(cache) + + return cfg diff --git a/external_plugins/preprocess-legal/engine/extract.py b/external_plugins/preprocess-legal/engine/extract.py new file mode 100644 index 0000000000..3d0ff656f8 --- /dev/null +++ b/external_plugins/preprocess-legal/engine/extract.py @@ -0,0 +1,40 @@ +"""Text-native PDF extraction backend (pdfplumber). + +This is the ONLY module that imports pdfplumber. Everything else in the engine +speaks in terms of ``ExtractionResult``, so the backend is swappable — slice 2's +OCR cascade plugs in behind the same ``Extractor`` interface without touching the +scoring or gate logic. + +Failure policy: any error (missing pdfplumber, encrypted or corrupt PDF) returns +``ExtractionResult(ok=False, ...)`` rather than raising. The gate treats a failed +extraction as an automatic withhold — the engine fails closed, never silently +passing a document it could not read. +""" + +from __future__ import annotations + +from .models import ExtractionResult + + +def extract_text(source_path: str) -> ExtractionResult: + try: + import pdfplumber + except ImportError as exc: # pragma: no cover - environment dependent + return ExtractionResult( + source_path=source_path, + ok=False, + error=f"pdfplumber not installed: {exc}", + ) + + try: + page_texts = [] + with pdfplumber.open(source_path) as pdf: + for page in pdf.pages: + page_texts.append(page.extract_text() or "") + return ExtractionResult(source_path=source_path, page_texts=page_texts, ok=True) + except Exception as exc: # noqa: BLE001 - fail closed on any reader error + return ExtractionResult( + source_path=source_path, + ok=False, + error=f"{type(exc).__name__}: {exc}", + ) diff --git a/external_plugins/preprocess-legal/engine/gate.py b/external_plugins/preprocess-legal/engine/gate.py new file mode 100644 index 0000000000..01079b46b5 --- /dev/null +++ b/external_plugins/preprocess-legal/engine/gate.py @@ -0,0 +1,138 @@ +"""The gate: extract → score → decide → materialize the file the model will read. + +This is the orchestration layer. Given a source document path and a config, it: + + 1. Routes — decides whether this file is even in scope (config.routes()). + 2. Extracts — pulls text via the injected backend (default: pdfplumber). + 3. Scores — per-document confidence (confidence.score_extraction()). + 4. Gates — pass vs. withhold against the threshold. + 5. Writes — the exact bytes the model will see: clean text on a pass, or an + honest withheld-marker on a fail. Never degraded text silently. + +Step 5 is the whole point. Because the engine writes the file the redirect hook +points at, "refuse to pass degraded output" is literal: a low-confidence scan +becomes a marker that *says* it was withheld, not hollow text masquerading as the +document. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Callable, Optional + +from .confidence import ConfidenceScore, score_extraction +from .config import PreprocessConfig +from .models import ExtractionResult + +# An extractor is any callable path -> ExtractionResult. The default binds to +# pdfplumber lazily (see _default_extractor) so importing this module — and +# running the pure decision tests — never requires pdfplumber to be installed. +Extractor = Callable[[str], ExtractionResult] + + +class Decision(str, Enum): + PASS = "pass" # clean text, redirect the read to it + WITHHOLD = "withhold" # degraded/failed, redirect to an honest marker + SKIP = "skip" # out of scope, leave the read untouched + + +@dataclass +class ProcessResult: + decision: Decision + # The path the PreToolUse hook should redirect the Read to. None on SKIP, + # which tells the hook to pass the original read through unchanged. + redirect_path: Optional[str] + score: Optional[ConfidenceScore] + cached: bool = False + source_path: str = "" + + +def decide(score: ConfidenceScore, threshold: float) -> Decision: + """Pure pass/withhold decision — no I/O, trivially testable.""" + return Decision.PASS if score.overall >= threshold else Decision.WITHHOLD + + +def _cache_key(path: Path) -> str: + """Stable key from path + size + mtime so re-reading an unchanged file hits + the warm cache instead of re-extracting (the latency mitigation).""" + try: + st = path.stat() + sig = f"{path.resolve()}|{st.st_size}|{int(st.st_mtime)}" + except OSError: + sig = str(path) + return hashlib.sha256(sig.encode("utf-8")).hexdigest()[:16] + + +def _withheld_marker(source_path: str, score: ConfidenceScore, threshold: float) -> str: + return ( + "[preprocess-legal] DOCUMENT WITHHELD FROM CONTEXT\n" + "\n" + f"Source: {source_path}\n" + f"Reason: extraction {score.reason()} did not clear the confidence " + f"threshold ({threshold:.2f}).\n" + "\n" + "This document is likely scanned or image-based. A text extractor " + "returned little or no reliable text, so passing it into context would " + "risk handing you plausible-looking but hollow or structurally-broken " + "content (lost tables, dropped exhibit stamps, invisible redactions).\n" + "\n" + "It was deliberately NOT passed through silently. Next steps:\n" + " - Enable the OCR fallback (not yet available in this slice), or\n" + " - Open the source document directly for this exhibit.\n" + ) + + +def _default_extractor(source_path: str) -> ExtractionResult: + # Lazy import: only touched at real runtime, never during pure tests. + from .extract import extract_text + + return extract_text(source_path) + + +def process( + source_path: str, + config: PreprocessConfig, + extractor: Optional[Extractor] = None, +) -> ProcessResult: + """Run the full pipeline for one document. Safe to call on any path; returns + a SKIP result (no redirect) for anything out of scope.""" + path = Path(source_path) + + if not config.routes(path): + return ProcessResult(Decision.SKIP, None, None, source_path=source_path) + + cache_dir = Path(config.cache_dir) + cache_dir.mkdir(parents=True, exist_ok=True) + key = _cache_key(path) + out_path = cache_dir / f"{key}.txt" + + # Warm-cache hit: an unchanged file we've already processed. Reuse the + # materialized text/marker without re-extracting. + if out_path.is_file(): + # Re-derive the decision label from the marker header so callers still + # get a meaningful decision on a cache hit. + head = out_path.read_text(encoding="utf-8", errors="replace")[:64] + decision = Decision.WITHHOLD if "WITHHELD" in head else Decision.PASS + return ProcessResult( + decision, str(out_path), None, cached=True, source_path=source_path + ) + + extract = extractor or _default_extractor + result: ExtractionResult = extract(source_path) + score = score_extraction(result, min_chars_per_page=config.min_chars_per_page) + decision = decide(score, config.threshold) + + if decision is Decision.PASS: + out_path.write_text(result.full_text, encoding="utf-8") + else: + out_path.write_text( + _withheld_marker(source_path, score, config.threshold), + encoding="utf-8", + ) + + return ProcessResult( + decision, str(out_path), score, cached=False, source_path=source_path + ) diff --git a/external_plugins/preprocess-legal/engine/models.py b/external_plugins/preprocess-legal/engine/models.py new file mode 100644 index 0000000000..e8c3946f99 --- /dev/null +++ b/external_plugins/preprocess-legal/engine/models.py @@ -0,0 +1,37 @@ +"""Plain data types shared across the engine. + +These carry no dependencies (no pdfplumber, no I/O) so both the extraction +backend and the pure scoring/gate logic can import them freely. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class ExtractionResult: + """The output of a text-extraction pass over one document. + + ``page_texts`` is the raw text pulled from each page, in order. A scanned / + image-only PDF run through a text extractor yields empty or near-empty + strings here — that emptiness is exactly the signal the confidence scorer + keys on, so we keep per-page granularity rather than one merged blob. + """ + + source_path: str + page_texts: List[str] = field(default_factory=list) + # True only if the extractor completed without raising. A hard failure + # (corrupt/encrypted PDF) is different from a successful-but-empty scan and + # the gate treats it as an automatic withhold. + ok: bool = True + error: str = "" + + @property + def page_count(self) -> int: + return len(self.page_texts) + + @property + def full_text(self) -> str: + return "\n".join(self.page_texts) diff --git a/external_plugins/preprocess-legal/hooks/hooks.json b/external_plugins/preprocess-legal/hooks/hooks.json new file mode 100644 index 0000000000..1352c9a286 --- /dev/null +++ b/external_plugins/preprocess-legal/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "python \"${CLAUDE_PLUGIN_ROOT}/hooks/preprocess_read.py\"" + } + ] + } + ] + } +} diff --git a/external_plugins/preprocess-legal/hooks/preprocess_read.py b/external_plugins/preprocess-legal/hooks/preprocess_read.py new file mode 100644 index 0000000000..1565f3606c --- /dev/null +++ b/external_plugins/preprocess-legal/hooks/preprocess_read.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: transparently redirect a Read of a heavy PDF to pre-extracted +text (or an honest withheld-marker) that the engine writes just-in-time. + +Contract (Claude Code >= v2.1.190, CLI + Cowork + Desktop): + stdin : JSON with tool_name, tool_input.file_path, cwd, ... + stdout : {"hookSpecificOutput": {"hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": {"file_path": ""}}} + exit 0 : always. This hook must never break a Read. On anything unexpected it + prints nothing and exits 0, so the original file is read as normal. + +The redirect target is materialized synchronously here — it must exist before the +Read returns, so extraction blocks the read (warm cache makes repeat reads cheap). +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +# Make the sibling engine package importable regardless of cwd. +_PLUGIN_ROOT = Path(__file__).resolve().parent.parent +if str(_PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(_PLUGIN_ROOT)) + +_DEFAULT_CONFIG = ( + Path.home() + / ".claude" + / "plugins" + / "config" + / "claude-for-legal" + / "preprocess" + / "config.json" +) + + +def _passthrough() -> None: + """Emit nothing and exit 0 — the original Read proceeds untouched.""" + sys.exit(0) + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except Exception: + _passthrough() + + if payload.get("tool_name") != "Read": + _passthrough() + + file_path = (payload.get("tool_input") or {}).get("file_path") + if not file_path: + _passthrough() + + try: + from engine import load_config, process + from engine.gate import Decision + + config_path = os.environ.get("PREPROCESS_LEGAL_CONFIG") or ( + str(_DEFAULT_CONFIG) if _DEFAULT_CONFIG.is_file() else None + ) + config = load_config(config_path) + + result = process(file_path, config) + if result.decision is Decision.SKIP or not result.redirect_path: + _passthrough() + + out = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": {"file_path": result.redirect_path}, + } + } + print(json.dumps(out)) + sys.exit(0) + except Exception: + # Fail open: never let a preprocessing error block a real Read. + _passthrough() + + +if __name__ == "__main__": + main() diff --git a/external_plugins/preprocess-legal/requirements.txt b/external_plugins/preprocess-legal/requirements.txt new file mode 100644 index 0000000000..25a2166f6c --- /dev/null +++ b/external_plugins/preprocess-legal/requirements.txt @@ -0,0 +1,4 @@ +# Runtime dependency for the extraction backend (engine/extract.py). +# The pure scoring + gate logic and its tests need nothing here — only the real +# PDF extraction path imports pdfplumber. +pdfplumber>=0.11 diff --git a/external_plugins/preprocess-legal/tests/conftest.py b/external_plugins/preprocess-legal/tests/conftest.py new file mode 100644 index 0000000000..fb8217ac12 --- /dev/null +++ b/external_plugins/preprocess-legal/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +# Put the plugin root (which contains the ``engine`` package) on the path so +# tests import it regardless of where pytest is invoked from. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) diff --git a/external_plugins/preprocess-legal/tests/test_confidence.py b/external_plugins/preprocess-legal/tests/test_confidence.py new file mode 100644 index 0000000000..c4e5bb05a1 --- /dev/null +++ b/external_plugins/preprocess-legal/tests/test_confidence.py @@ -0,0 +1,63 @@ +"""Confidence scoring: the signal that separates a clean text-native PDF from a +scanned exhibit that a text extractor hollowed out.""" + +from engine.confidence import score_extraction +from engine.models import ExtractionResult + + +def _pages(*texts): + return ExtractionResult(source_path="doc.pdf", page_texts=list(texts), ok=True) + + +def test_text_native_scores_high(): + # Three full pages of ordinary body text. + page = "Lorem ipsum dolor sit amet. " * 60 # ~1600 chars + score = score_extraction(_pages(page, page, page)) + assert score.overall >= 0.65 + assert score.dimensions["coverage"] == 1.0 + assert score.dimensions["density"] == 1.0 + assert score.dimensions["legibility"] > 0.99 + + +def test_scanned_empty_pages_score_near_zero(): + # A scanned/image PDF: the text extractor returns empty strings per page. + score = score_extraction(_pages("", "", "")) + assert score.overall == 0.0 + assert score.dimensions["coverage"] == 0.0 + assert score.dimensions["density"] == 0.0 + + +def test_failed_extraction_scores_zero(): + result = ExtractionResult(source_path="x.pdf", ok=False, error="encrypted") + score = score_extraction(result) + assert score.overall == 0.0 + + +def test_no_pages_scores_zero(): + score = score_extraction(_pages()) + assert score.overall == 0.0 + assert score.page_count == 0 + + +def test_sparse_pages_below_coverage_bar(): + # A dozen stray OCR-ish characters per page — below the coverage threshold, + # so coverage collapses even though the page isn't literally empty. + score = score_extraction(_pages("a b c d", "x y z", "1 2 3"), min_chars_per_page=100) + assert score.dimensions["coverage"] == 0.0 + assert score.overall < 0.3 + + +def test_mojibake_penalized_by_legibility(): + # Text that "extracted to something" but is mostly control/replacement bytes. + garbage = "\x00\x01�\x02�" * 400 # long, dense, but illegible + score = score_extraction(_pages(garbage, garbage, garbage)) + # Coverage/density can look fine; legibility is what catches this. + assert score.dimensions["legibility"] < 0.1 + assert score.overall < 0.65 + + +def test_reason_string_is_human_readable(): + page = "Lorem ipsum dolor sit amet. " * 60 + score = score_extraction(_pages(page)) + r = score.reason() + assert "confidence=" in r and "coverage=" in r and "chars/page" in r diff --git a/external_plugins/preprocess-legal/tests/test_gate.py b/external_plugins/preprocess-legal/tests/test_gate.py new file mode 100644 index 0000000000..646b8d0481 --- /dev/null +++ b/external_plugins/preprocess-legal/tests/test_gate.py @@ -0,0 +1,102 @@ +"""The gate: routing, pass/withhold, and the exact bytes written for the redirect.""" + +from pathlib import Path + +import pytest + +from engine.confidence import score_extraction +from engine.config import PreprocessConfig +from engine.gate import Decision, decide, process +from engine.models import ExtractionResult + + +# --- fake extractors (no pdfplumber) --------------------------------------- + +def _text_native(_path): + page = "Lorem ipsum dolor sit amet. " * 60 + return ExtractionResult(source_path=_path, page_texts=[page, page, page], ok=True) + + +def _scanned(_path): + return ExtractionResult(source_path=_path, page_texts=["", "", ""], ok=True) + + +def _failed(_path): + return ExtractionResult(source_path=_path, ok=False, error="encrypted") + + +def _cfg(tmp_path, **overrides): + cfg = PreprocessConfig(enabled=True, cache_dir=str(tmp_path)) + for k, v in overrides.items(): + setattr(cfg, k, v) + return cfg + + +# --- decide() (pure) -------------------------------------------------------- + +def test_decide_pass_and_withhold(): + high = score_extraction(_text_native("d.pdf")) + low = score_extraction(_scanned("d.pdf")) + assert decide(high, 0.65) is Decision.PASS + assert decide(low, 0.65) is Decision.WITHHOLD + + +# --- process() end to end --------------------------------------------------- + +def test_pass_writes_clean_text(tmp_path): + res = process("matter/contract.pdf", _cfg(tmp_path), extractor=_text_native) + assert res.decision is Decision.PASS + out = Path(res.redirect_path) + assert out.is_file() + assert "Lorem ipsum" in out.read_text(encoding="utf-8") + assert "WITHHELD" not in out.read_text(encoding="utf-8") + + +def test_scanned_writes_withheld_marker(tmp_path): + res = process("matter/exhibit.pdf", _cfg(tmp_path), extractor=_scanned) + assert res.decision is Decision.WITHHOLD + text = Path(res.redirect_path).read_text(encoding="utf-8") + assert "DOCUMENT WITHHELD FROM CONTEXT" in text + assert "matter/exhibit.pdf" in text + + +def test_failed_extraction_withholds(tmp_path): + res = process("matter/broken.pdf", _cfg(tmp_path), extractor=_failed) + assert res.decision is Decision.WITHHOLD + assert "WITHHELD" in Path(res.redirect_path).read_text(encoding="utf-8") + + +def test_disabled_config_skips(tmp_path): + res = process("matter/contract.pdf", _cfg(tmp_path, enabled=False), extractor=_text_native) + assert res.decision is Decision.SKIP + assert res.redirect_path is None + + +def test_non_pdf_skips(tmp_path): + res = process("matter/notes.txt", _cfg(tmp_path), extractor=_text_native) + assert res.decision is Decision.SKIP + + +def test_warm_cache_reuses_without_reextracting(tmp_path): + calls = {"n": 0} + + def counting(_path): + calls["n"] += 1 + return _text_native(_path) + + cfg = _cfg(tmp_path) + first = process("matter/contract.pdf", cfg, extractor=counting) + second = process("matter/contract.pdf", cfg, extractor=counting) + assert calls["n"] == 1 # second call hit the cache + assert first.cached is False and second.cached is True + assert second.decision is Decision.PASS + assert first.redirect_path == second.redirect_path + + +def test_min_bytes_floor_skips_small_files(tmp_path): + # A real small file: below the byte floor, not worth extraction latency. + small = tmp_path / "small.pdf" + small.write_bytes(b"%PDF-1.4 tiny") + cfg = _cfg(tmp_path, min_bytes_to_route=1_000_000) + res = process(str(small), cfg, extractor=_text_native) + assert res.decision is Decision.SKIP