Files
unredact/scripts/capture_llm_fixture.py

137 lines
4.0 KiB
Python

"""Capture OCR + LLM output from a PDF for use as test fixtures.
Usage:
python scripts/capture_llm_fixture.py /path/to/document.pdf [page_num]
Outputs JSON to stdout with:
- ocr_lines: serialized OCR lines with char positions
- llm_prompt: the prompt sent to the LLM
- llm_tool_input: the raw tool_use input from the LLM response
- llm_redactions: parsed LlmRedaction objects
"""
import asyncio
import json
import os
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env manually (no dotenv dependency)
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
for _line in env_path.read_text().splitlines():
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
from unredact.pipeline.rasterize import rasterize_pdf
from unredact.pipeline.ocr import ocr_page
from unredact.pipeline.llm_detect import (
_build_prompt, _parse_response, _get_client, _TOOL,
)
import os
def serialize_ocr_lines(lines):
"""Convert OCR lines to JSON-serializable dicts."""
result = []
for line in lines:
chars = []
for c in line.chars:
chars.append({
"text": c.text,
"x": c.x, "y": c.y,
"w": c.w, "h": c.h,
"conf": c.conf,
})
result.append({
"chars": chars,
"x": line.x, "y": line.y,
"w": line.w, "h": line.h,
"text": line.text,
})
return result
async def main():
pdf_path = Path(sys.argv[1])
page_num = int(sys.argv[2]) if len(sys.argv) > 2 else 1
print(f"Rasterizing {pdf_path}...", file=sys.stderr)
pages = rasterize_pdf(pdf_path)
if page_num > len(pages):
print(f"Only {len(pages)} pages in PDF", file=sys.stderr)
sys.exit(1)
page_img = pages[page_num - 1]
print(f"Page {page_num}: {page_img.size}", file=sys.stderr)
print("Running OCR...", file=sys.stderr)
lines = ocr_page(page_img)
print(f"Found {len(lines)} lines", file=sys.stderr)
prompt = _build_prompt(lines)
print(f"Prompt length: {len(prompt)} chars", file=sys.stderr)
model = os.environ.get("UNREDACT_LLM_MODEL", "claude-haiku-4-5-20251001")
print(f"Calling {model}...", file=sys.stderr)
client = _get_client()
response = await client.messages.create(
model=model,
max_tokens=2048,
tools=[_TOOL],
tool_choice={"type": "tool", "name": "report_redactions"},
messages=[{"role": "user", "content": prompt}],
)
# Extract tool_use block
tool_input = None
for block in response.content:
if block.type == "tool_use" and block.name == "report_redactions":
tool_input = block.input
break
if tool_input is None:
print("ERROR: No tool_use block in response", file=sys.stderr)
sys.exit(1)
# Parse redactions
redactions = _parse_response(tool_input, lines)
print(f"LLM found {len(tool_input['redactions'])} redactions", file=sys.stderr)
print(f"Parsed to {len(redactions)} with valid positions", file=sys.stderr)
# Dump fixture
fixture = {
"source_pdf": pdf_path.name,
"page": page_num,
"ocr_lines": serialize_ocr_lines(lines),
"llm_prompt": prompt,
"llm_tool_input": tool_input,
"llm_redactions": [
{
"line_index": r.line_index,
"left_word": r.left_word,
"right_word": r.right_word,
"left_x": r.left_x,
"right_x": r.right_x,
"line_y": r.line_y,
"line_h": r.line_h,
}
for r in redactions
],
}
json.dump(fixture, sys.stdout, indent=2)
print(file=sys.stderr)
print("Done!", file=sys.stderr)
if __name__ == "__main__":
asyncio.run(main())