#!/usr/bin/env python3 """ Smoke Signal — Stage 8: Human Review Workbench ================================================ Gradio app for reviewing low-confidence OCR pages. Reviewer actions per page: - ACCEPT : text is correct, pass to Codex export - EDIT : correct the text, then accept - REJECT : unusable, exclude from export - QUARANTINE: flag for specialist review - ILLUSTRATION ONLY: no text on this page Captures: reviewer ID, timestamp, edits, reason codes, final status. To run locally: pip install gradio python scripts/06_review_workbench.py To deploy on HF Spaces: This file should be copied to the root as smoke_signal_review.py or integrated into the main app.py as a new tab. """ import csv import json import os from datetime import datetime from pathlib import Path from typing import Optional import gradio as gr # ── Paths ────────────────────────────────────────────────────────────────────── ROOT = Path(__file__).resolve().parents[1] REVIEW_DIR = ROOT / "review" REGIONS_DIR = ROOT / "regions" CLEANED_DIR = ROOT / "regions" / "cleaned" RENDERS_DIR = ROOT / "renders" EXPORTS_DIR = ROOT / "exports" REVIEW_DIR.mkdir(parents=True, exist_ok=True) EXPORTS_DIR.mkdir(parents=True, exist_ok=True) QUEUE_CSV = REVIEW_DIR / "review_queue.csv" DECISIONS_CSV = REVIEW_DIR / "review_decisions.csv" # ── Reason codes ─────────────────────────────────────────────────────────────── REASON_CODES = [ "OCR_MISS", "OCR_WRONG_WORD", "REGION_MISSING", "REGION_FALSE_POSITIVE", "READING_ORDER_ERROR", "DECORATIVE_FONT", "SPEECH_BUBBLE_ERROR", "LOW_CONTRAST", "SCAN_SKEW_BLUR", "NON_STORY_TEXT", "RIGHTS_UNCLEAR", "DUPLICATE_SOURCE", "LLM_OVER_CORRECTION", "MANUAL_TRANSCRIPTION_REQUIRED", "OTHER", ] REVIEW_STATUSES = ["pending", "accepted", "edited", "rejected", "quarantined", "illustration-only"] # ── CSS ──────────────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Code+Pro:wght@400;600&family=Lato:wght@300;400;700&display=swap'); :root { --ink: #1a1a2e; --paper: #f5f0e8; --smoke: #2d3561; --signal: #e94560; --ash: #8892b0; --accepted: #00b894; --rejected: #e17055; --quarantine: #fdcb6e; --pending: #74b9ff; } .gradio-container { background: var(--paper) !important; font-family: 'Lato', sans-serif !important; max-width: none !important; } footer { display: none !important; } #ss-header { background: var(--ink); padding: 20px 32px; border-bottom: 3px solid var(--signal); display: flex; align-items: center; gap: 20px; } #ss-title { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 900; color: var(--paper); letter-spacing: -0.5px; margin: 0; } #ss-subtitle { font-family: 'Source Code Pro', monospace; font-size: 11px; color: var(--ash); letter-spacing: 3px; text-transform: uppercase; margin: 0; } #ss-signal { color: var(--signal); font-size: 36px; font-weight: 900; } .queue-panel { background: white; border: 1px solid #e0d9cc; border-radius: 8px; padding: 16px; height: 600px; overflow-y: auto; } .queue-item { padding: 12px 14px; border-radius: 6px; margin-bottom: 8px; cursor: pointer; border: 2px solid transparent; transition: all 0.15s; font-size: 13px; } .queue-item:hover { border-color: var(--smoke); } .queue-item.active { border-color: var(--signal); background: #fff5f7; } .queue-item.pending { border-left: 4px solid var(--pending); } .queue-item.accepted { border-left: 4px solid var(--accepted); opacity: 0.6; } .queue-item.rejected { border-left: 4px solid var(--rejected); opacity: 0.6; } .queue-item.quarantined { border-left: 4px solid var(--quarantine); } .page-image-panel { background: #2a2a2a; border-radius: 8px; min-height: 400px; display: flex; align-items: center; justify-content: center; } .confidence-badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; font-family: 'Source Code Pro', monospace; } .conf-high { background: #d4f5e9; color: #00695c; } .conf-medium { background: #fff3cd; color: #856404; } .conf-low { background: #fde8e8; color: #c62828; } .conf-quarantine { background: #2a2a2a; color: #fdcb6e; } .action-btn { font-weight: 700 !important; font-size: 14px !important; border-radius: 6px !important; min-height: 44px !important; transition: transform 0.1s !important; } .action-btn:active { transform: scale(0.97) !important; } .accept-btn { background: var(--accepted) !important; color: white !important; } .reject-btn { background: var(--rejected) !important; color: white !important; } .quar-btn { background: var(--quarantine) !important; color: var(--ink) !important; } .illus-btn { background: var(--smoke) !important; color: white !important; } .stats-bar { background: var(--ink); color: var(--paper); padding: 10px 20px; border-radius: 6px; font-family: 'Source Code Pro', monospace; font-size: 12px; display: flex; gap: 24px; margin-bottom: 12px; } .stat-item { display: flex; flex-direction: column; gap: 2px; } .stat-value { font-size: 20px; font-weight: 600; } .stat-label { color: var(--ash); font-size: 10px; letter-spacing: 1px; } .ocr-text-box textarea { font-family: 'Source Code Pro', monospace !important; font-size: 14px !important; background: #fafaf8 !important; border: 2px solid #e0d9cc !important; border-radius: 6px !important; } .ocr-text-box textarea:focus { border-color: var(--signal) !important; } """ # ── Data loading ─────────────────────────────────────────────────────────────── def load_queue() -> list: """Load the review queue CSV.""" if not QUEUE_CSV.exists(): return [] with open(QUEUE_CSV, newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def load_decisions() -> dict: """Load existing decisions keyed by region_id.""" decisions = {} if not DECISIONS_CSV.exists(): return decisions with open(DECISIONS_CSV, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): decisions[row.get("region_id", "")] = row return decisions def save_decision( region_id: str, book_id: str, page: int, status: str, final_text: str, reason_code: str, reviewer: str, notes: str, ) -> None: """Append or update a decision record.""" fields = [ "region_id", "book_id", "page", "status", "final_text", "reason_code", "reviewer", "notes", "decided_at" ] existing = load_decisions() existing[region_id] = { "region_id": region_id, "book_id": book_id, "page": page, "status": status, "final_text": final_text, "reason_code": reason_code, "reviewer": reviewer, "notes": notes, "decided_at": datetime.utcnow().isoformat() + "Z", } write_header = not DECISIONS_CSV.exists() with open(DECISIONS_CSV, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fields) writer.writeheader() writer.writerows(existing.values()) def get_queue_stats(queue: list, decisions: dict) -> dict: total = len(queue) decided = len(decisions) pending = total - decided accepted = sum(1 for d in decisions.values() if d["status"] == "accepted") edited = sum(1 for d in decisions.values() if d["status"] == "edited") rejected = sum(1 for d in decisions.values() if d["status"] == "rejected") quarantined = sum(1 for d in decisions.values() if d["status"] == "quarantined") return { "total": total, "pending": pending, "accepted": accepted, "edited": edited, "rejected": rejected, "quarantined": quarantined, } # ── Image loader ─────────────────────────────────────────────────────────────── def get_page_image(book_id: str, page: str) -> Optional[str]: """Find the rendered page image.""" try: page_num = int(page) except (ValueError, TypeError): return None book_dir = RENDERS_DIR / str(book_id) if book_dir.exists(): candidates = sorted(book_dir.glob(f"{book_id}_page_{page_num:04d}_*.png")) if candidates: return str(candidates[0]) return None # ── Queue HTML builder ───────────────────────────────────────────────────────── def build_queue_html(queue: list, decisions: dict, active_idx: int = 0) -> str: if not queue: return "
Smoke Signal
OCR Review Workbench · Human-in-the-Loop