diff --git a/docs/plans/2026-02-20-redaction-workflow-redesign-impl.md b/docs/plans/2026-02-20-redaction-workflow-redesign-impl.md new file mode 100644 index 0000000..a4640ec --- /dev/null +++ b/docs/plans/2026-02-20-redaction-workflow-redesign-impl.md @@ -0,0 +1,1129 @@ +# Redaction Workflow Redesign Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Decouple auto-detection from upload, make double-click (spot) redactions fully analyzed, and move to inline-on-canvas editing. + +**Architecture:** OCR runs at upload time and is cached. Both the "Detect Redactions" button and double-click spot use cached OCR data. A new `analyze_spot_redaction()` function runs font detection + context extraction on a single known bounding box. The frontend creates spot redactions as `"analyzed"` with full data, identical to auto-detected ones. + +**Tech Stack:** Python/FastAPI backend, vanilla JS frontend (ES modules), Canvas2D rendering, SSE streaming. + +--- + +### Task 1: Add OCR caching to document state + +**Files:** +- Modify: `unredact/app.py:91-104` (upload endpoint, page_data structure) +- Test: `tests/test_app.py` + +**Step 1: Modify upload to initialize OCR cache slot** + +In `unredact/app.py`, change the page_data structure at lines 91-96 to include an `ocr_lines` field: + +```python + page_data = {} + for i, page_img in enumerate(pages, start=1): + page_data[i] = { + "original": page_img, + "analysis": None, + "ocr_lines": None, # Cached OCR results + } +``` + +**Step 2: Run test to verify nothing breaks** + +Run: `pytest tests/test_app.py -v` +Expected: All existing tests PASS (adding a field doesn't break anything) + +**Step 3: Commit** + +```bash +git add unredact/app.py +git commit -m "feat: add ocr_lines cache slot to page data structure" +``` + +--- + +### Task 2: Create OCR SSE endpoint + +**Files:** +- Modify: `unredact/app.py` (add new endpoint after line 127) +- Modify: `unredact/pipeline/ocr.py` (export `OcrLine` and `OcrChar` — already exported) +- Test: `tests/test_app.py` (add test) + +**Step 1: Write the failing test** + +Add to `tests/test_app.py`: + +```python +@pytest.mark.anyio +async def test_ocr_endpoint_streams_results(pdf_bytes: bytes): + """GET /api/doc/{id}/ocr should stream OCR progress and cache results.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post( + "/api/upload", + files={"file": ("test.pdf", pdf_bytes, "application/pdf")}, + ) + doc_id = resp.json()["doc_id"] + + resp = await client.get(f"/api/doc/{doc_id}/ocr") + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers.get("content-type", "") +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app.py::test_ocr_endpoint_streams_results -v` +Expected: FAIL (404 or route not found) + +**Step 3: Implement the OCR SSE endpoint** + +Add to `unredact/app.py` after line 127 (after `get_font`): + +```python +@app.get("/api/doc/{doc_id}/ocr") +async def ocr_doc(doc_id: str): + """SSE endpoint that runs OCR on all pages and caches results.""" + doc = _docs.get(doc_id) + if not doc: + return JSONResponse({"error": "not found"}, status_code=404) + + async def event_generator(): + for page_num, pd in doc["pages"].items(): + # Skip if already cached + if pd["ocr_lines"] is not None: + yield json.dumps({ + "event": "page_ocr_complete", + "page": page_num, + "num_lines": len(pd["ocr_lines"]), + }) + continue + + page_img = pd["original"] + try: + lines = await asyncio.to_thread(ocr_page, page_img) + except Exception as exc: + yield json.dumps({ + "event": "error", + "page": page_num, + "message": str(exc), + }) + continue + + pd["ocr_lines"] = lines + yield json.dumps({ + "event": "page_ocr_complete", + "page": page_num, + "num_lines": len(lines), + }) + + yield json.dumps({"event": "ocr_complete"}) + + return EventSourceResponse(event_generator()) +``` + +Also add the import at the top of `app.py` (around line 16): + +```python +from unredact.pipeline.ocr import ocr_page, OcrLine +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app.py::test_ocr_endpoint_streams_results -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/app.py tests/test_app.py +git commit -m "feat: add /api/doc/{id}/ocr SSE endpoint for cached OCR" +``` + +--- + +### Task 3: Refactor analyze_page to accept pre-computed OCR + +**Files:** +- Modify: `unredact/pipeline/analyze_page.py:45-69` +- Test: `tests/test_analyze_page.py` + +**Step 1: Write the failing test** + +Add to `tests/test_analyze_page.py`: + +```python +@pytest.mark.anyio +async def test_analyze_page_uses_precomputed_ocr(sample_page_image): + """analyze_page should use pre-computed OCR lines when provided.""" + from unredact.pipeline.ocr import ocr_page + from unredact.pipeline.analyze_page import analyze_page + + # Pre-compute OCR + lines = ocr_page(sample_page_image) + assert len(lines) > 0 + + # Pass pre-computed lines — should not re-run OCR + result = await analyze_page(sample_page_image, ocr_lines=lines) + assert result.lines is lines # Same object, not re-computed +``` + +Note: You'll need to check if `sample_page_image` fixture exists in conftest. If not, create one that rasterizes the sample PDF's first page. Check `test_analyze_page.py` for existing fixtures. + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_analyze_page.py::test_analyze_page_uses_precomputed_ocr -v` +Expected: FAIL (unexpected keyword argument `ocr_lines`) + +**Step 3: Modify analyze_page signature** + +In `unredact/pipeline/analyze_page.py`, change the function signature at line 45: + +```python +async def analyze_page( + page_image: Image.Image, + on_progress: callable | None = None, + ocr_lines: list[OcrLine] | None = None, +) -> PageAnalysis: +``` + +And change line 69 from: +```python + lines: list[OcrLine] = await asyncio.to_thread(ocr_page, page_image) +``` +to: +```python + if ocr_lines is not None: + lines = ocr_lines + else: + lines = await asyncio.to_thread(ocr_page, page_image) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_analyze_page.py::test_analyze_page_uses_precomputed_ocr -v` +Expected: PASS + +**Step 5: Run all tests to verify no regressions** + +Run: `pytest tests/ -v --timeout=120` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add unredact/pipeline/analyze_page.py tests/test_analyze_page.py +git commit -m "feat: allow analyze_page to accept pre-computed OCR lines" +``` + +--- + +### Task 4: Wire analyze endpoint to use cached OCR + +**Files:** +- Modify: `unredact/app.py:129-175` (analyze_doc endpoint) + +**Step 1: Modify analyze_doc to use cached OCR** + +In the `event_generator()` inside `analyze_doc()` (line 136-173), change lines 139-140 from: + +```python + page_img = pd["original"] + try: + analysis = await analyze_page(page_img) +``` + +to: + +```python + page_img = pd["original"] + ocr_lines = pd.get("ocr_lines") + try: + analysis = await analyze_page(page_img, ocr_lines=ocr_lines) +``` + +Also, after analysis completes (after line 149 `pd["analysis"] = analysis`), cache the OCR lines if they weren't already: + +```python + pd["analysis"] = analysis + # Cache OCR lines if not already cached + if pd.get("ocr_lines") is None: + pd["ocr_lines"] = analysis.lines +``` + +**Step 2: Run existing tests** + +Run: `pytest tests/test_app.py -v` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add unredact/app.py +git commit -m "feat: wire analyze endpoint to use cached OCR data" +``` + +--- + +### Task 5: Create analyze_spot_redaction function + +This is the core new function that runs the analysis mini-pipeline on a single known bounding box using cached OCR data. + +**Files:** +- Modify: `unredact/pipeline/analyze_page.py` (add new function) +- Test: `tests/test_analyze_page.py` (add test) + +**Step 1: Write the failing test** + +Add to `tests/test_analyze_page.py`: + +```python +def test_analyze_spot_redaction_returns_analysis(sample_page_image): + """analyze_spot_redaction should return full analysis for a known bbox.""" + from unredact.pipeline.ocr import ocr_page + from unredact.pipeline.detect_redactions import Redaction + from unredact.pipeline.analyze_page import analyze_spot_redaction + + lines = ocr_page(sample_page_image) + assert len(lines) > 0 + + # Use a synthetic redaction box in the middle of the first line + line = lines[0] + box = Redaction( + id="test123", + x=line.x + line.w // 3, + y=line.y, + w=line.w // 3, + h=line.h, + ) + + result = analyze_spot_redaction(sample_page_image, lines, box) + assert result is not None + assert result.box is box + assert result.line is line + assert result.font is not None + assert result.font.font_size > 0 + assert isinstance(result.offset_x, float) + assert isinstance(result.offset_y, float) +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_analyze_page.py::test_analyze_spot_redaction_returns_analysis -v` +Expected: FAIL (cannot import `analyze_spot_redaction`) + +**Step 3: Implement analyze_spot_redaction** + +Add to `unredact/pipeline/analyze_page.py` after the `analyze_page` function (after line 196): + +```python +def analyze_spot_redaction( + page_image: Image.Image, + ocr_lines: list[OcrLine], + box: Redaction, +) -> RedactionAnalysis | None: + """Run analysis on a single known redaction bounding box. + + Uses cached OCR data to: + 1. Find the OCR line containing the redaction + 2. Detect the font (with redaction masking) + 3. Extract left/right context text + 4. Compute pixel alignment offsets + + Args: + page_image: PIL Image of the document page. + ocr_lines: Pre-computed OCR lines for the page. + box: Known redaction bounding box. + + Returns: + RedactionAnalysis or None if no suitable OCR line found. + """ + # Find the OCR line that best contains this redaction box. + # Use vertical overlap: the line whose vertical range overlaps most with the box. + best_line = None + best_overlap = 0 + box_top = box.y + box_bottom = box.y + box.h + + for line in ocr_lines: + line_top = line.y + line_bottom = line.y + line.h + overlap = max(0, min(box_bottom, line_bottom) - max(box_top, line_top)) + if overlap > best_overlap: + best_overlap = overlap + best_line = line + + if best_line is None: + log.warning("No OCR line found for spot redaction at (%d,%d)", box.x, box.y) + return None + + line = best_line + redaction_boxes = [(box.x, box.y, box.w, box.h)] + + # Font detection with masking + font = detect_font_masked(line, page_image, redaction_boxes) + + # Extract left/right text using center-point char filtering + left_chars = [c for c in line.chars if c.x + c.w / 2 < box.x] + right_chars = [c for c in line.chars if c.x + c.w / 2 > box.x + box.w] + left_text = "".join(c.text for c in left_chars).strip() + right_text = "".join(c.text for c in right_chars).strip() + + # Pixel alignment + offset_x = 0.0 + offset_y = 0.0 + + if left_text: + pil_font = font.to_pil_font() + text_region_x1 = max(0, line.x - 20) + text_region_x2 = min(page_image.width, box.x + 20) + text_region_y1 = max(0, line.y - 10) + text_region_y2 = min(page_image.height, line.y + line.h + 10) + text_crop = np.array(page_image.convert("L").crop( + (text_region_x1, text_region_y1, text_region_x2, text_region_y2) + )) + align_dx, align_dy = align_text_to_page( + left_text, pil_font, text_crop, + ) + offset_x = float(text_region_x1 + align_dx - line.x) + offset_y = float(text_region_y1 + align_dy - line.y) + + return RedactionAnalysis( + box=box, + line=line, + font=font, + left_text=left_text, + right_text=right_text, + offset_x=offset_x, + offset_y=offset_y, + ) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_analyze_page.py::test_analyze_spot_redaction_returns_analysis -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/analyze_page.py tests/test_analyze_page.py +git commit -m "feat: add analyze_spot_redaction for single-box analysis" +``` + +--- + +### Task 6: Enrich the spot endpoint to return full analysis + +**Files:** +- Modify: `unredact/app.py:459-470` (spot endpoint) +- Test: `tests/test_app.py` + +**Step 1: Write the failing test** + +Add to `tests/test_app.py`: + +```python +@pytest.mark.anyio +async def test_spot_returns_analysis(pdf_bytes: bytes): + """POST /spot should return full analysis data, not just bbox.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + # Upload + resp = await client.post( + "/api/upload", + files={"file": ("test.pdf", pdf_bytes, "application/pdf")}, + ) + doc_id = resp.json()["doc_id"] + + # Run OCR first (required for spot analysis) + resp = await client.get(f"/api/doc/{doc_id}/ocr") + assert resp.status_code == 200 + + # Get page dimensions to pick a spot + resp = await client.get(f"/api/doc/{doc_id}/page/1/original") + assert resp.status_code == 200 + + # Try to spot a redaction (we pick center of page — may or may not find one) + resp = await client.post( + f"/api/doc/{doc_id}/page/1/spot", + json={"x": 300, "y": 300}, + ) + if resp.status_code == 404: + pytest.skip("No redaction found at test coordinates") + + data = resp.json() + # Should have bbox fields + assert "id" in data + assert "x" in data + assert "w" in data + # Should ALSO have analysis data + assert "analysis" in data + assert data["analysis"] is not None or data.get("analysis_error") is not None +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_app.py::test_spot_returns_analysis -v` +Expected: FAIL (`"analysis" not in data`) + +**Step 3: Modify spot endpoint** + +Replace the spot endpoint in `unredact/app.py` (lines 459-470) with: + +```python +@app.post("/api/doc/{doc_id}/page/{page}/spot") +async def spot(doc_id: str, page: int, data: dict): + doc = _docs.get(doc_id) + if not doc or page not in doc["pages"]: + return JSONResponse({"error": "not found"}, status_code=404) + click_x = int(data["x"]) + click_y = int(data["y"]) + pd = doc["pages"][page] + page_img = pd["original"] + result = spot_redaction(page_img, click_x, click_y) + if result is None: + return JSONResponse({"error": "no redaction found"}, status_code=404) + + # Run analysis if OCR data is available + ocr_lines = pd.get("ocr_lines") + analysis_json = None + if ocr_lines: + ra = await asyncio.to_thread( + analyze_spot_redaction, page_img, ocr_lines, result, + ) + if ra is not None: + font_id = _make_font_id(ra.font.font_name) + segments = [] + if ra.left_text: + segments.append({"text": ra.left_text}) + if ra.right_text: + segments.append({"text": ra.right_text}) + + analysis_json = { + "segments": segments, + "gap": {"x": ra.box.x, "w": ra.box.w}, + "font": { + "name": ra.font.font_name, + "id": font_id, + "size": ra.font.font_size, + "score": ra.font.score, + }, + "line": { + "x": ra.line.x, + "y": ra.line.y, + "w": ra.line.w, + "h": ra.line.h, + "text": ra.line.text, + }, + "offset_x": ra.offset_x, + "offset_y": ra.offset_y, + } + + return { + "id": result.id, + "x": result.x, "y": result.y, + "w": result.w, "h": result.h, + "analysis": analysis_json, + } +``` + +Also add the import at the top of `app.py`: + +```python +from unredact.pipeline.analyze_page import analyze_page, analyze_spot_redaction +``` + +(Change the existing `from unredact.pipeline.analyze_page import analyze_page` to include `analyze_spot_redaction`.) + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_app.py::test_spot_returns_analysis -v` +Expected: PASS + +**Step 5: Run all app tests** + +Run: `pytest tests/test_app.py -v` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add unredact/app.py tests/test_app.py +git commit -m "feat: spot endpoint returns full analysis data" +``` + +--- + +### Task 7: Frontend — Add OCR SSE and remove auto-analyze + +**Files:** +- Modify: `unredact/static/main.js:76-105` (uploadFile function) +- Modify: `unredact/static/main.js:107-130` (remove startAnalysisSSE auto-trigger, keep function for button) + +**Step 1: Add startOcrSSE function and modify uploadFile** + +In `unredact/static/main.js`, add a new function after `startAnalysisSSE` (around line 131): + +```javascript +function startOcrSSE() { + const es = new EventSource(`/api/doc/${state.docId}/ocr`); + showToast("Running OCR...", "info"); + + es.onmessage = (e) => { + const data = JSON.parse(e.data); + if (data.event === "page_ocr_complete") { + // OCR cached for this page on the backend + } else if (data.event === "ocr_complete") { + es.close(); + state.ocrReady = true; + showToast("OCR complete — ready to detect redactions", "success"); + if (detectBtn) detectBtn.disabled = false; + } else if (data.event === "error") { + showToast(`OCR error on page ${data.page}: ${data.message}`, "error"); + } + }; + es.onerror = () => { + es.close(); + showToast("OCR connection lost", "error"); + }; +} +``` + +**Step 2: Modify uploadFile to call OCR instead of analysis** + +In `uploadFile()` (line 76), change line 100 from: + +```javascript + startAnalysisSSE(); +``` + +to: + +```javascript + startOcrSSE(); +``` + +**Step 3: Add `ocrReady` to state** + +In `unredact/static/state.js`, add to the state object: + +```javascript + ocrReady: false, +``` + +**Step 4: Commit** + +```bash +git add unredact/static/main.js unredact/static/state.js +git commit -m "feat: run OCR on upload instead of full analysis" +``` + +--- + +### Task 8: Frontend — Add "Detect Redactions" button + +**Files:** +- Modify: `unredact/static/index.html:36-38` (left panel) +- Modify: `unredact/static/dom.js` (add detectBtn export) +- Modify: `unredact/static/main.js` (import detectBtn, wire click handler) + +**Step 1: Add button to HTML** + +In `unredact/static/index.html`, change lines 36-38 from: + +```html +