Pointf5ive commited on
Commit
5ffc645
·
1 Parent(s): b988c6d

Training phase: scripts 09+10, --model flag in 03, gold schema patch

Browse files
smoke_signal/scripts/03_ocr_bakeoff.py CHANGED
@@ -98,18 +98,56 @@ def load_page_profile(book_id: str) -> Optional[dict]:
98
 
99
 
100
  # ── Surya OCR ──────────────────────────────────────────────────────────────────
101
- def _run_surya(image_path: Path, langs: list) -> dict:
102
  """
103
- Run Surya OCR on a single page image.
104
- Returns standardised result dict.
 
 
 
 
 
 
 
 
 
 
 
 
105
  """
106
  try:
107
- from PIL import Image
108
  from surya.ocr import run_ocr
109
  from surya.model.detection.model import load_model as load_det_model
110
  from surya.model.detection.processor import load_processor as load_det_processor
111
  from surya.model.recognition.model import load_model as load_rec_model
112
  from surya.model.recognition.processor import load_processor as load_rec_processor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  except ImportError as e:
114
  return {
115
  "engine": "surya",
@@ -119,21 +157,25 @@ def _run_surya(image_path: Path, langs: list) -> dict:
119
  "confidence": 0.0,
120
  }
121
 
 
 
 
 
 
 
 
 
 
 
122
  try:
123
  image = Image.open(str(image_path)).convert("RGB")
124
-
125
- det_model = load_det_model()
126
- det_processor = load_det_processor()
127
- rec_model = load_rec_model()
128
- rec_processor = load_rec_processor()
129
-
130
- results = run_ocr(
131
  [image],
132
  [langs],
133
- det_model,
134
- det_processor,
135
- rec_model,
136
- rec_processor,
137
  )
138
 
139
  page_result = results[0]
@@ -163,6 +205,7 @@ def _run_surya(image_path: Path, langs: list) -> dict:
163
  "words": words,
164
  "confidence": avg_conf,
165
  "line_count": len(words),
 
166
  "error": None,
167
  }
168
 
@@ -173,6 +216,7 @@ def _run_surya(image_path: Path, langs: list) -> dict:
173
  "text": "",
174
  "words": [],
175
  "confidence": 0.0,
 
176
  }
177
 
178
 
@@ -269,6 +313,7 @@ def ocr_page(
269
  page_num: int,
270
  engine: str,
271
  config: dict,
 
272
  dry_run: bool = False,
273
  ) -> dict:
274
  """Run OCR on one page, save result, return summary."""
@@ -290,7 +335,7 @@ def ocr_page(
290
  return result
291
 
292
  if engine == "surya":
293
- ocr_out = _run_surya(image_path, config["surya_langs"])
294
  elif engine == "tesseract":
295
  ocr_out = _run_tesseract(image_path, config["tesseract_lang"], config["tesseract_psm"])
296
  else:
@@ -310,7 +355,13 @@ def ocr_page(
310
 
311
 
312
  # ── Per-book OCR runner ────────────────────────────────────────────────────────
313
- def ocr_book(record: dict, engine: str, dry_run: bool = False) -> dict:
 
 
 
 
 
 
314
  book_id = record["book_id"]
315
  print(f"\n [{book_id}] {record['filename']} — engine: {engine}")
316
 
@@ -319,7 +370,7 @@ def ocr_book(record: dict, engine: str, dry_run: bool = False) -> dict:
319
  print(f" ✗ No page profile found. Run 02_profile_pdfs.py first.")
320
  return {"book_id": book_id, "error": "no_profile", "pages": []}
321
 
322
- eligible_routes = OCR_CONFIG["eligible_routes"]
323
  ocr_pages = [p for p in profile["pages"] if p.get("route") in eligible_routes]
324
 
325
  print(f" OCR-eligible pages: {len(ocr_pages)} / {profile['page_count']}")
@@ -356,7 +407,7 @@ def ocr_book(record: dict, engine: str, dry_run: bool = False) -> dict:
356
  errors.append({"page": page_num, "error": "render_missing"})
357
  continue
358
 
359
- result = ocr_page(image_path, book_id, page_num, engine, OCR_CONFIG, dry_run)
360
  page_results.append(result)
361
 
362
  conf = result.get("confidence", 0.0)
@@ -407,18 +458,28 @@ def main():
407
  parser.add_argument("--batch-id", help="Tag this run with a batch ID")
408
  parser.add_argument("--engine", choices=["surya", "tesseract", "both"],
409
  default="surya", help="OCR engine to use (default: surya)")
 
 
410
  parser.add_argument("--dry-run", action="store_true", help="No files written")
411
  parser.add_argument("--all", action="store_true", help="Include already-OCRed books")
412
  args = parser.parse_args()
413
 
414
  run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
415
  engines = ["surya", "tesseract"] if args.engine == "both" else [args.engine]
 
 
 
 
 
 
416
 
417
  print(f"\n{'='*60}")
418
  print(f" Smoke Signal — Stage 4: OCR Bake-Off")
419
  print(f" Run ID : {run_id}")
420
  print(f" Engines : {engines}")
421
- print(f" Config : {OCR_CONFIG['config_version']}")
 
 
422
  if args.dry_run:
423
  print(f" Mode : DRY RUN")
424
  print(f"{'='*60}")
@@ -439,19 +500,30 @@ def main():
439
  else:
440
  books = [r for r in manifest.values() if r.get("status") in eligible_statuses]
441
 
 
 
 
442
  if not books:
443
- print(f"\n No books with status in {eligible_statuses}.")
444
- print(" Run 02_profile_pdfs.py first to render pages.")
 
445
  sys.exit(0)
446
 
447
  print(f"\n Books to OCR: {len(books)}")
448
 
449
  all_results = []
450
  t_start = time.time()
 
 
 
 
 
 
 
451
 
452
  for record in books:
453
  for engine in engines:
454
- result = ocr_book(record, engine, dry_run=args.dry_run)
455
  all_results.append(result)
456
 
457
  # Update manifest status
@@ -461,7 +533,7 @@ def main():
461
  # Save manifest + config + run log
462
  if not args.dry_run:
463
  save_manifest(manifest)
464
- save_ocr_config(OCR_CONFIG)
465
 
466
  log_path = LOGS_DIR / f"{run_id}_ocr_bakeoff.json"
467
  with open(log_path, "w", encoding="utf-8") as f:
@@ -469,7 +541,8 @@ def main():
469
  "run_id": run_id,
470
  "run_at": datetime.utcnow().isoformat() + "Z",
471
  "engines": engines,
472
- "config": OCR_CONFIG,
 
473
  "results": all_results,
474
  }, f, indent=2)
475
  print(f"\n Run log → {log_path.relative_to(ROOT)}")
 
98
 
99
 
100
  # ── Surya OCR ──────────────────────────────────────────────────────────────────
101
+ def _safe_load_surya_component(loader, checkpoint: Optional[str]):
102
  """
103
+ Attempt to pass checkpoint to Surya loader, with safe fallback for older APIs.
104
+ """
105
+ if not checkpoint:
106
+ return loader()
107
+ try:
108
+ return loader(checkpoint=checkpoint)
109
+ except TypeError:
110
+ return loader()
111
+
112
+
113
+ def load_surya_context(checkpoint: Optional[str] = None) -> Optional[dict]:
114
+ """
115
+ Load Surya OCR models once per run.
116
+ Returns context dict or None if Surya import/loading fails.
117
  """
118
  try:
 
119
  from surya.ocr import run_ocr
120
  from surya.model.detection.model import load_model as load_det_model
121
  from surya.model.detection.processor import load_processor as load_det_processor
122
  from surya.model.recognition.model import load_model as load_rec_model
123
  from surya.model.recognition.processor import load_processor as load_rec_processor
124
+ except ImportError:
125
+ return None
126
+
127
+ try:
128
+ det_model = _safe_load_surya_component(load_det_model, checkpoint)
129
+ det_processor = _safe_load_surya_component(load_det_processor, checkpoint)
130
+ rec_model = _safe_load_surya_component(load_rec_model, checkpoint)
131
+ rec_processor = _safe_load_surya_component(load_rec_processor, checkpoint)
132
+ return {
133
+ "run": run_ocr,
134
+ "det_model": det_model,
135
+ "det_processor": det_processor,
136
+ "rec_model": rec_model,
137
+ "rec_processor": rec_processor,
138
+ "checkpoint": checkpoint,
139
+ }
140
+ except Exception:
141
+ return None
142
+
143
+
144
+ def _run_surya(image_path: Path, langs: list, surya_ctx: Optional[dict] = None) -> dict:
145
+ """
146
+ Run Surya OCR on a single page image.
147
+ Returns standardised result dict.
148
+ """
149
+ try:
150
+ from PIL import Image
151
  except ImportError as e:
152
  return {
153
  "engine": "surya",
 
157
  "confidence": 0.0,
158
  }
159
 
160
+ ctx = surya_ctx or load_surya_context()
161
+ if not ctx:
162
+ return {
163
+ "engine": "surya",
164
+ "error": "Surya model load failed. Check surya-ocr install and checkpoint path.",
165
+ "text": "",
166
+ "words": [],
167
+ "confidence": 0.0,
168
+ }
169
+
170
  try:
171
  image = Image.open(str(image_path)).convert("RGB")
172
+ results = ctx["run"](
 
 
 
 
 
 
173
  [image],
174
  [langs],
175
+ ctx["det_model"],
176
+ ctx["det_processor"],
177
+ ctx["rec_model"],
178
+ ctx["rec_processor"],
179
  )
180
 
181
  page_result = results[0]
 
205
  "words": words,
206
  "confidence": avg_conf,
207
  "line_count": len(words),
208
+ "model_checkpoint": ctx.get("checkpoint") or "base",
209
  "error": None,
210
  }
211
 
 
216
  "text": "",
217
  "words": [],
218
  "confidence": 0.0,
219
+ "model_checkpoint": ctx.get("checkpoint") or "base",
220
  }
221
 
222
 
 
313
  page_num: int,
314
  engine: str,
315
  config: dict,
316
+ surya_ctx: Optional[dict] = None,
317
  dry_run: bool = False,
318
  ) -> dict:
319
  """Run OCR on one page, save result, return summary."""
 
335
  return result
336
 
337
  if engine == "surya":
338
+ ocr_out = _run_surya(image_path, config["surya_langs"], surya_ctx=surya_ctx)
339
  elif engine == "tesseract":
340
  ocr_out = _run_tesseract(image_path, config["tesseract_lang"], config["tesseract_psm"])
341
  else:
 
355
 
356
 
357
  # ── Per-book OCR runner ────────────────────────────────────────────────────────
358
+ def ocr_book(
359
+ record: dict,
360
+ engine: str,
361
+ config: dict,
362
+ surya_ctx: Optional[dict] = None,
363
+ dry_run: bool = False,
364
+ ) -> dict:
365
  book_id = record["book_id"]
366
  print(f"\n [{book_id}] {record['filename']} — engine: {engine}")
367
 
 
370
  print(f" ✗ No page profile found. Run 02_profile_pdfs.py first.")
371
  return {"book_id": book_id, "error": "no_profile", "pages": []}
372
 
373
+ eligible_routes = config["eligible_routes"]
374
  ocr_pages = [p for p in profile["pages"] if p.get("route") in eligible_routes]
375
 
376
  print(f" OCR-eligible pages: {len(ocr_pages)} / {profile['page_count']}")
 
407
  errors.append({"page": page_num, "error": "render_missing"})
408
  continue
409
 
410
+ result = ocr_page(image_path, book_id, page_num, engine, config, surya_ctx=surya_ctx, dry_run=dry_run)
411
  page_results.append(result)
412
 
413
  conf = result.get("confidence", 0.0)
 
458
  parser.add_argument("--batch-id", help="Tag this run with a batch ID")
459
  parser.add_argument("--engine", choices=["surya", "tesseract", "both"],
460
  default="surya", help="OCR engine to use (default: surya)")
461
+ parser.add_argument("--model", default=None,
462
+ help="Optional Surya checkpoint/model path for OCR engine=surya")
463
  parser.add_argument("--dry-run", action="store_true", help="No files written")
464
  parser.add_argument("--all", action="store_true", help="Include already-OCRed books")
465
  args = parser.parse_args()
466
 
467
  run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
468
  engines = ["surya", "tesseract"] if args.engine == "both" else [args.engine]
469
+ run_config = dict(OCR_CONFIG)
470
+ if args.model:
471
+ run_config["surya_model_checkpoint"] = args.model
472
+ # Governance: checkpoint changes require a new config version.
473
+ model_tag = hashlib.sha256(args.model.encode("utf-8")).hexdigest()[:8]
474
+ run_config["config_version"] = f"{OCR_CONFIG['config_version']}_ft_{model_tag}"
475
 
476
  print(f"\n{'='*60}")
477
  print(f" Smoke Signal — Stage 4: OCR Bake-Off")
478
  print(f" Run ID : {run_id}")
479
  print(f" Engines : {engines}")
480
+ print(f" Config : {run_config['config_version']}")
481
+ if args.model:
482
+ print(f" Surya model override : {args.model}")
483
  if args.dry_run:
484
  print(f" Mode : DRY RUN")
485
  print(f"{'='*60}")
 
500
  else:
501
  books = [r for r in manifest.values() if r.get("status") in eligible_statuses]
502
 
503
+ # Governance: never process unknown/excluded rights in OCR batches.
504
+ books = [r for r in books if r.get("rights_class") not in ("unknown", "excluded")]
505
+
506
  if not books:
507
+ print(f"\n No books eligible after status/rights filters.")
508
+ print(f" Eligible statuses: {eligible_statuses}")
509
+ print(" Rights blocked: unknown, excluded")
510
  sys.exit(0)
511
 
512
  print(f"\n Books to OCR: {len(books)}")
513
 
514
  all_results = []
515
  t_start = time.time()
516
+ surya_ctx = None
517
+ if "surya" in engines and not args.dry_run:
518
+ surya_ctx = load_surya_context(args.model)
519
+ if not surya_ctx:
520
+ print("\n [error] Could not load Surya models/checkpoint.")
521
+ print(" Check surya-ocr install and --model path.")
522
+ sys.exit(1)
523
 
524
  for record in books:
525
  for engine in engines:
526
+ result = ocr_book(record, engine, run_config, surya_ctx=surya_ctx, dry_run=args.dry_run)
527
  all_results.append(result)
528
 
529
  # Update manifest status
 
533
  # Save manifest + config + run log
534
  if not args.dry_run:
535
  save_manifest(manifest)
536
+ save_ocr_config(run_config)
537
 
538
  log_path = LOGS_DIR / f"{run_id}_ocr_bakeoff.json"
539
  with open(log_path, "w", encoding="utf-8") as f:
 
541
  "run_id": run_id,
542
  "run_at": datetime.utcnow().isoformat() + "Z",
543
  "engines": engines,
544
+ "config": run_config,
545
+ "surya_model_checkpoint": args.model or "base",
546
  "results": all_results,
547
  }, f, indent=2)
548
  print(f"\n Run log → {log_path.relative_to(ROOT)}")
smoke_signal/scripts/09_finetune_surya.py ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal — Stage 11: Surya Fine-Tuning Orchestrator
4
+ ========================================================
5
+ Builds a governance-safe OCR fine-tuning dataset from gold corrections,
6
+ optionally uploads it to Hugging Face, and can launch Surya OCR finetuning.
7
+
8
+ Governance controls enforced:
9
+ - unknown/excluded rights are always blocked
10
+ - mixed rights classes are blocked by default
11
+ - checkpoint/config changes are versioned and logged
12
+ - every run logs model version, dataset size, and gold set hash
13
+
14
+ Primary sources used for integration choices:
15
+ - Surya README finetune entrypoint and args
16
+ - Surya example dataset shape (`image` + `text`)
17
+ """
18
+
19
+ import argparse
20
+ import csv
21
+ import hashlib
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Dict, List, Optional, Tuple
28
+
29
+ ROOT = Path(__file__).resolve().parents[1]
30
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
31
+ RUN_LOG_CSV = ROOT / "manifest" / "run_log.csv"
32
+ GOLD_FILE = ROOT / "gold" / "gold_corrections.jsonl"
33
+ RENDERS_DIR = ROOT / "renders"
34
+ TRAINING_DIR = ROOT / "training"
35
+ TRAINING_DATASETS_DIR = TRAINING_DIR / "datasets"
36
+ TRAINING_RUNS_DIR = TRAINING_DIR / "runs"
37
+
38
+ ELIGIBLE_RIGHTS = {"public-domain", "licensed-owned", "controlled-internal"}
39
+ BLOCKED_RIGHTS = {"unknown", "excluded"}
40
+
41
+ RUN_LOG_FIELDS = [
42
+ "run_id",
43
+ "date",
44
+ "operator",
45
+ "config_version",
46
+ "schema_version",
47
+ "source_batch",
48
+ "pages_processed",
49
+ "errors",
50
+ "cost_usd",
51
+ "output_path",
52
+ "notes",
53
+ ]
54
+
55
+
56
+ def utc_now() -> datetime:
57
+ return datetime.now(timezone.utc)
58
+
59
+
60
+ def utc_iso() -> str:
61
+ return utc_now().isoformat().replace("+00:00", "Z")
62
+
63
+
64
+ def ensure_dirs() -> None:
65
+ TRAINING_DIR.mkdir(parents=True, exist_ok=True)
66
+ TRAINING_DATASETS_DIR.mkdir(parents=True, exist_ok=True)
67
+ TRAINING_RUNS_DIR.mkdir(parents=True, exist_ok=True)
68
+
69
+
70
+ def sha256_file(path: Path) -> str:
71
+ h = hashlib.sha256()
72
+ with open(path, "rb") as f:
73
+ for block in iter(lambda: f.read(1 << 20), b""):
74
+ h.update(block)
75
+ return h.hexdigest()
76
+
77
+
78
+ def sha256_text(text: str) -> str:
79
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
80
+
81
+
82
+ def ensure_run_log() -> None:
83
+ RUN_LOG_CSV.parent.mkdir(parents=True, exist_ok=True)
84
+ if RUN_LOG_CSV.exists():
85
+ return
86
+ with open(RUN_LOG_CSV, "w", newline="", encoding="utf-8") as f:
87
+ writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS)
88
+ writer.writeheader()
89
+
90
+
91
+ def append_run_log(row: Dict[str, str]) -> None:
92
+ ensure_run_log()
93
+ with open(RUN_LOG_CSV, "a", newline="", encoding="utf-8") as f:
94
+ writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS)
95
+ writer.writerow({k: row.get(k, "") for k in RUN_LOG_FIELDS})
96
+
97
+
98
+ def load_manifest() -> Dict[str, Dict[str, str]]:
99
+ records: Dict[str, Dict[str, str]] = {}
100
+ if not MANIFEST_CSV.exists():
101
+ return records
102
+
103
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
104
+ for row in csv.DictReader(f):
105
+ book_id = str(row.get("book_id", "")).strip()
106
+ if book_id:
107
+ records[book_id] = row
108
+ return records
109
+
110
+
111
+ def load_gold_records(path: Path) -> List[Dict]:
112
+ records: List[Dict] = []
113
+ if not path.exists():
114
+ return records
115
+
116
+ with open(path, encoding="utf-8") as f:
117
+ for idx, line in enumerate(f, start=1):
118
+ line = line.strip()
119
+ if not line:
120
+ continue
121
+ try:
122
+ rec = json.loads(line)
123
+ rec["_gold_line"] = idx
124
+ records.append(rec)
125
+ except json.JSONDecodeError:
126
+ # Keep pipeline resilient: skip malformed line.
127
+ continue
128
+ return records
129
+
130
+
131
+ def parse_page_number(value) -> Optional[int]:
132
+ if value is None:
133
+ return None
134
+ try:
135
+ return int(value)
136
+ except (TypeError, ValueError):
137
+ return None
138
+
139
+
140
+ def resolve_image_path(record: Dict, book_id: str, page_num: Optional[int]) -> Optional[Path]:
141
+ path_fields = [
142
+ "page_image_path",
143
+ "page_image",
144
+ "image_path",
145
+ "crop_path",
146
+ "render_path",
147
+ ]
148
+ for field in path_fields:
149
+ raw = record.get(field)
150
+ if not raw:
151
+ continue
152
+ candidate = Path(str(raw))
153
+ if not candidate.is_absolute():
154
+ candidate = ROOT / candidate
155
+ if candidate.exists() and candidate.is_file():
156
+ return candidate.resolve()
157
+
158
+ if page_num is not None:
159
+ render_dir = RENDERS_DIR / book_id
160
+ if render_dir.exists():
161
+ candidates = sorted(render_dir.glob(f"{book_id}_page_{page_num:04d}_*.*"))
162
+ if candidates:
163
+ return candidates[0].resolve()
164
+
165
+ return None
166
+
167
+
168
+ def select_transcript(record: Dict) -> str:
169
+ for key in ("final_text", "corrected_text", "text", "raw_text", "raw_ocr"):
170
+ value = record.get(key)
171
+ if value is not None:
172
+ text = str(value).strip()
173
+ if text:
174
+ return text
175
+ return ""
176
+
177
+
178
+ def build_examples(
179
+ gold_records: List[Dict],
180
+ manifest: Dict[str, Dict[str, str]],
181
+ rights_class_filter: Optional[str],
182
+ allow_mixed_rights: bool,
183
+ ) -> Tuple[List[Dict], Dict]:
184
+ stats = {
185
+ "gold_rows": len(gold_records),
186
+ "kept": 0,
187
+ "skipped_missing_book": 0,
188
+ "skipped_missing_manifest": 0,
189
+ "skipped_status": 0,
190
+ "skipped_blocked_rights": 0,
191
+ "skipped_rights_filter": 0,
192
+ "skipped_missing_text": 0,
193
+ "skipped_missing_image": 0,
194
+ }
195
+
196
+ rights_seen = set()
197
+ examples: List[Dict] = []
198
+
199
+ for rec in gold_records:
200
+ book_id = str(rec.get("book_id", "")).strip()
201
+ if not book_id:
202
+ stats["skipped_missing_book"] += 1
203
+ continue
204
+
205
+ manifest_row = manifest.get(book_id)
206
+ if not manifest_row:
207
+ stats["skipped_missing_manifest"] += 1
208
+ continue
209
+
210
+ status = str(rec.get("status", "")).strip().lower()
211
+ if status and status not in {"accepted", "edited"}:
212
+ stats["skipped_status"] += 1
213
+ continue
214
+
215
+ rights_class = str(manifest_row.get("rights_class", "unknown")).strip().lower()
216
+ if rights_class in BLOCKED_RIGHTS or rights_class not in ELIGIBLE_RIGHTS:
217
+ stats["skipped_blocked_rights"] += 1
218
+ continue
219
+
220
+ if rights_class_filter and rights_class != rights_class_filter:
221
+ stats["skipped_rights_filter"] += 1
222
+ continue
223
+
224
+ text = select_transcript(rec)
225
+ if not text:
226
+ stats["skipped_missing_text"] += 1
227
+ continue
228
+
229
+ page_num = parse_page_number(rec.get("page") or rec.get("page_number"))
230
+ image_path = resolve_image_path(rec, book_id, page_num)
231
+ if not image_path:
232
+ stats["skipped_missing_image"] += 1
233
+ continue
234
+
235
+ rights_seen.add(rights_class)
236
+
237
+ example = {
238
+ "image": str(image_path),
239
+ "text": text,
240
+ "book_id": book_id,
241
+ "page": page_num,
242
+ "region_class": str(rec.get("region_class", "narration")),
243
+ "rights_class": rights_class,
244
+ "confidence": float(rec.get("confidence", 0) or 0),
245
+ "gold_line": int(rec.get("_gold_line", 0)),
246
+ }
247
+ examples.append(example)
248
+
249
+ if not allow_mixed_rights and len(rights_seen) > 1:
250
+ raise ValueError(
251
+ f"Mixed rights classes found in training set: {sorted(rights_seen)}. "
252
+ "Run separate jobs per rights class or pass --allow-mixed-rights explicitly."
253
+ )
254
+
255
+ stats["kept"] = len(examples)
256
+ stats["rights_seen"] = sorted(rights_seen)
257
+ return examples, stats
258
+
259
+
260
+ def save_training_artifacts(run_id: str, examples: List[Dict], stats: Dict, metadata: Dict) -> Path:
261
+ run_dataset_dir = TRAINING_DATASETS_DIR / run_id
262
+ run_dataset_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ examples_jsonl = run_dataset_dir / "training_examples.jsonl"
265
+ with open(examples_jsonl, "w", encoding="utf-8") as f:
266
+ for row in examples:
267
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
268
+
269
+ manifest_path = run_dataset_dir / "dataset_manifest.json"
270
+ with open(manifest_path, "w", encoding="utf-8") as f:
271
+ json.dump({"stats": stats, "metadata": metadata}, f, indent=2, ensure_ascii=False)
272
+
273
+ return run_dataset_dir
274
+
275
+
276
+ def build_hf_dataset(examples: List[Dict]):
277
+ try:
278
+ from datasets import Dataset, Image
279
+ except Exception as exc: # pragma: no cover - environment-dependent
280
+ raise RuntimeError(
281
+ "datasets[vision] is required. Install with: pip install datasets[vision]"
282
+ ) from exc
283
+
284
+ ds = Dataset.from_dict({
285
+ "image": [e["image"] for e in examples],
286
+ "text": [e["text"] for e in examples],
287
+ }).cast_column("image", Image())
288
+ return ds
289
+
290
+
291
+ def push_dataset_to_hub(ds, repo_id: str, private: bool, token: Optional[str], run_id: str) -> None:
292
+ ds.push_to_hub(
293
+ repo_id,
294
+ private=private,
295
+ token=token,
296
+ commit_message=f"Smoke Signal finetune dataset {run_id}",
297
+ )
298
+
299
+
300
+ def resolve_finetune_entrypoint(explicit_script: Optional[str], surya_repo: Optional[str]) -> List[str]:
301
+ if explicit_script:
302
+ script_path = Path(explicit_script).expanduser().resolve()
303
+ if not script_path.exists():
304
+ raise FileNotFoundError(f"Surya finetune script not found: {script_path}")
305
+ return [sys.executable, str(script_path)]
306
+
307
+ try:
308
+ import importlib.util
309
+
310
+ spec = importlib.util.find_spec("surya.scripts.finetune_ocr")
311
+ if spec is not None:
312
+ return [sys.executable, "-m", "surya.scripts.finetune_ocr"]
313
+ except Exception:
314
+ pass
315
+
316
+ if surya_repo:
317
+ candidate = Path(surya_repo).expanduser().resolve() / "surya" / "scripts" / "finetune_ocr.py"
318
+ if candidate.exists():
319
+ return [sys.executable, str(candidate)]
320
+
321
+ raise RuntimeError(
322
+ "Could not resolve Surya finetune entrypoint. "
323
+ "Install surya-ocr or pass --surya-finetune-script /path/to/finetune_ocr.py"
324
+ )
325
+
326
+
327
+ def _detect_hub_model_arg_name(entrypoint_cmd: List[str]) -> str:
328
+ """
329
+ TrainingArguments changed over time. Detect supported hub model id arg
330
+ from finetune --help output.
331
+ """
332
+ try:
333
+ probe = subprocess.run(
334
+ entrypoint_cmd + ["--help"],
335
+ capture_output=True,
336
+ text=True,
337
+ check=False,
338
+ )
339
+ help_text = (probe.stdout or "") + "\\n" + (probe.stderr or "")
340
+ if "--hub_model_id" in help_text:
341
+ return "--hub_model_id"
342
+ if "--push_to_hub_model_id" in help_text:
343
+ return "--push_to_hub_model_id"
344
+ except Exception:
345
+ pass
346
+ # Default to current TrainingArguments key.
347
+ return "--hub_model_id"
348
+
349
+
350
+ def build_train_command(args, run_output_dir: Path) -> List[str]:
351
+ cmd = resolve_finetune_entrypoint(args.surya_finetune_script, args.surya_repo)
352
+ hub_model_arg = _detect_hub_model_arg_name(cmd)
353
+ cmd += [
354
+ "--output_dir",
355
+ str(run_output_dir),
356
+ "--dataset_name",
357
+ args.dataset_repo_id,
358
+ "--per_device_train_batch_size",
359
+ str(args.per_device_train_batch_size),
360
+ "--gradient_checkpointing",
361
+ "true" if args.gradient_checkpointing else "false",
362
+ "--max_sequence_length",
363
+ str(args.max_sequence_length),
364
+ "--num_train_epochs",
365
+ str(args.num_train_epochs),
366
+ "--learning_rate",
367
+ str(args.learning_rate),
368
+ "--logging_steps",
369
+ str(args.logging_steps),
370
+ "--save_steps",
371
+ str(args.save_steps),
372
+ "--save_total_limit",
373
+ str(args.save_total_limit),
374
+ "--remove_unused_columns",
375
+ "false",
376
+ "--push_to_hub",
377
+ "true",
378
+ hub_model_arg,
379
+ args.model_repo_id,
380
+ ]
381
+
382
+ if args.pretrained_checkpoint_path:
383
+ cmd += ["--pretrained_checkpoint_path", args.pretrained_checkpoint_path]
384
+
385
+ if args.hf_token:
386
+ cmd += ["--hub_token", args.hf_token]
387
+
388
+ return cmd
389
+
390
+
391
+ def write_run_summary(path: Path, summary: Dict) -> None:
392
+ path.parent.mkdir(parents=True, exist_ok=True)
393
+ with open(path, "w", encoding="utf-8") as f:
394
+ json.dump(summary, f, indent=2, ensure_ascii=False)
395
+
396
+
397
+ def parse_args() -> argparse.Namespace:
398
+ parser = argparse.ArgumentParser(description="Smoke Signal — Stage 11: Surya Fine-Tuning")
399
+ parser.add_argument("--gold-file", default=str(GOLD_FILE), help="Path to gold corrections JSONL")
400
+ parser.add_argument(
401
+ "--rights-class",
402
+ default=None,
403
+ choices=sorted(ELIGIBLE_RIGHTS),
404
+ help="Restrict to one rights class (recommended governance mode)",
405
+ )
406
+ parser.add_argument(
407
+ "--allow-mixed-rights",
408
+ action="store_true",
409
+ help="Allow mixed rights classes in one run (off by default)",
410
+ )
411
+ parser.add_argument(
412
+ "--dataset-repo-id",
413
+ default="Pointf5ive/smoke-signal-ocr-finetune",
414
+ help="HF dataset repo id (<namespace>/<name>)",
415
+ )
416
+ parser.add_argument(
417
+ "--model-repo-id",
418
+ default="Pointf5ive/smoke-signal-surya-ft",
419
+ help="HF model repo id (<namespace>/<name>)",
420
+ )
421
+ parser.add_argument("--hf-token", default=None, help="HF token (or set HF_TOKEN env var)")
422
+ parser.add_argument("--private-dataset", action="store_true", help="Create/push dataset repo as private")
423
+ parser.add_argument("--private-model", action="store_true", help="Create model repo as private")
424
+ parser.add_argument("--operator", default="codex", help="Operator name for governance logs")
425
+
426
+ parser.add_argument("--pretrained-checkpoint-path", default=None, help="Optional Surya init checkpoint")
427
+ parser.add_argument("--surya-finetune-script", default=None, help="Path to surya/scripts/finetune_ocr.py")
428
+ parser.add_argument("--surya-repo", default=None, help="Path to local Surya repo (fallback resolver)")
429
+
430
+ parser.add_argument("--per-device-train-batch-size", type=int, default=16)
431
+ parser.add_argument("--max-sequence-length", type=int, default=1024)
432
+ parser.add_argument("--num-train-epochs", type=float, default=2.0)
433
+ parser.add_argument("--learning-rate", type=float, default=5e-5)
434
+ parser.add_argument("--gradient-checkpointing", action="store_true")
435
+ parser.add_argument("--logging-steps", type=int, default=25)
436
+ parser.add_argument("--save-steps", type=int, default=200)
437
+ parser.add_argument("--save-total-limit", type=int, default=2)
438
+
439
+ parser.add_argument(
440
+ "--prepare-only",
441
+ action="store_true",
442
+ help="Stop after dataset prep/upload; do not start finetuning",
443
+ )
444
+ parser.add_argument(
445
+ "--skip-upload",
446
+ action="store_true",
447
+ help="Prepare local dataset artifacts but skip HF push",
448
+ )
449
+ parser.add_argument("--run-id", default=None, help="Optional explicit run id")
450
+
451
+ return parser.parse_args()
452
+
453
+
454
+ def main() -> None:
455
+ args = parse_args()
456
+ ensure_dirs()
457
+
458
+ token = args.hf_token
459
+ if not token:
460
+ import os
461
+
462
+ token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN")
463
+ args.hf_token = token
464
+
465
+ run_id = args.run_id or f"SS-FT-{utc_now().strftime('%Y%m%d-%H%M%S')}"
466
+ run_output_dir = TRAINING_RUNS_DIR / run_id
467
+ run_output_dir.mkdir(parents=True, exist_ok=True)
468
+
469
+ gold_path = Path(args.gold_file).expanduser().resolve()
470
+ if not gold_path.exists():
471
+ raise FileNotFoundError(f"Gold file not found: {gold_path}")
472
+
473
+ manifest = load_manifest()
474
+ if not manifest:
475
+ raise RuntimeError("Manifest is empty. Run 01_register_sources.py and set rights_class first.")
476
+
477
+ gold_records = load_gold_records(gold_path)
478
+ if not gold_records:
479
+ raise RuntimeError("Gold set is empty or unreadable; cannot fine-tune.")
480
+
481
+ examples, stats = build_examples(
482
+ gold_records=gold_records,
483
+ manifest=manifest,
484
+ rights_class_filter=args.rights_class,
485
+ allow_mixed_rights=args.allow_mixed_rights,
486
+ )
487
+
488
+ if not examples:
489
+ raise RuntimeError(f"No valid training examples after governance filters. Stats: {stats}")
490
+
491
+ rights_for_run = stats.get("rights_seen", [])
492
+ if not args.allow_mixed_rights and len(rights_for_run) > 1:
493
+ raise RuntimeError(
494
+ f"Mixed rights classes in run: {rights_for_run}. This violates governance by default."
495
+ )
496
+
497
+ # Governance metadata
498
+ gold_hash = sha256_file(gold_path)
499
+ text_concat = "\n".join(f"{e['book_id']}|{e['page']}|{e['text']}" for e in examples)
500
+ dataset_hash = sha256_text(text_concat)
501
+ config_version = "ss_surya_finetune_config_v0.1"
502
+ if args.pretrained_checkpoint_path:
503
+ ck_hash = sha256_text(args.pretrained_checkpoint_path)[:8]
504
+ config_version = f"{config_version}_ckpt_{ck_hash}"
505
+
506
+ metadata = {
507
+ "run_id": run_id,
508
+ "created_at": utc_iso(),
509
+ "config_version": config_version,
510
+ "schema_version": "ss_surya_ocr_finetune_dataset_v1",
511
+ "gold_file": str(gold_path),
512
+ "gold_hash": gold_hash,
513
+ "dataset_hash": dataset_hash,
514
+ "dataset_size": len(examples),
515
+ "rights_seen": rights_for_run,
516
+ "rights_filter": args.rights_class,
517
+ "dataset_repo_id": args.dataset_repo_id,
518
+ "model_repo_id": args.model_repo_id,
519
+ "pretrained_checkpoint_path": args.pretrained_checkpoint_path or "base",
520
+ "operator": args.operator,
521
+ }
522
+
523
+ dataset_artifact_dir = save_training_artifacts(run_id, examples, stats, metadata)
524
+ print(f"\nPrepared dataset artifacts: {dataset_artifact_dir}")
525
+ print(f"Examples kept: {len(examples)} | Rights: {rights_for_run} | Gold hash: {gold_hash[:12]}...")
526
+
527
+ summary = {
528
+ "metadata": metadata,
529
+ "stats": stats,
530
+ "train_command": None,
531
+ "train_returncode": None,
532
+ "train_stdout_path": None,
533
+ "train_stderr_path": None,
534
+ }
535
+
536
+ if not args.skip_upload:
537
+ if not args.hf_token:
538
+ raise RuntimeError("HF token required for upload. Pass --hf-token or set HF_TOKEN.")
539
+
540
+ ds = build_hf_dataset(examples)
541
+ push_dataset_to_hub(ds, args.dataset_repo_id, args.private_dataset, args.hf_token, run_id)
542
+ print(f"Pushed dataset to HF: {args.dataset_repo_id}")
543
+ else:
544
+ print("Skipped HF upload (--skip-upload).")
545
+
546
+ if args.prepare_only:
547
+ print("Prepare-only mode complete. Finetuning not started.")
548
+ else:
549
+ if args.skip_upload:
550
+ raise RuntimeError(
551
+ "Cannot start finetuning with --skip-upload because Surya expects --dataset_name. "
552
+ "Upload dataset first or run with --prepare-only."
553
+ )
554
+
555
+ if not args.hf_token:
556
+ raise RuntimeError("HF token required for model push during finetuning.")
557
+
558
+ train_cmd = build_train_command(args, run_output_dir)
559
+ stdout_path = run_output_dir / "finetune_stdout.log"
560
+ stderr_path = run_output_dir / "finetune_stderr.log"
561
+
562
+ summary["train_command"] = train_cmd
563
+ summary["train_stdout_path"] = str(stdout_path)
564
+ summary["train_stderr_path"] = str(stderr_path)
565
+
566
+ print("Launching Surya finetune...")
567
+ print(" ".join(train_cmd))
568
+ with open(stdout_path, "w", encoding="utf-8") as out, open(stderr_path, "w", encoding="utf-8") as err:
569
+ proc = subprocess.run(train_cmd, stdout=out, stderr=err, text=True)
570
+ summary["train_returncode"] = proc.returncode
571
+
572
+ if proc.returncode != 0:
573
+ raise RuntimeError(
574
+ f"Surya finetune failed with return code {proc.returncode}. "
575
+ f"See {stdout_path} and {stderr_path}."
576
+ )
577
+
578
+ print(f"Finetune complete. Model pushed to: {args.model_repo_id}")
579
+
580
+ summary_path = TRAINING_RUNS_DIR / f"{run_id}_summary.json"
581
+ write_run_summary(summary_path, summary)
582
+
583
+ notes = {
584
+ "model_version": args.pretrained_checkpoint_path or "base",
585
+ "dataset_size": len(examples),
586
+ "gold_hash": gold_hash,
587
+ "dataset_repo": args.dataset_repo_id,
588
+ "model_repo": args.model_repo_id,
589
+ }
590
+
591
+ append_run_log(
592
+ {
593
+ "run_id": run_id,
594
+ "date": utc_now().date().isoformat(),
595
+ "operator": args.operator,
596
+ "config_version": config_version,
597
+ "schema_version": "ss_surya_ocr_finetune_dataset_v1",
598
+ "source_batch": args.rights_class or ",".join(rights_for_run),
599
+ "pages_processed": str(len(examples)),
600
+ "errors": str(
601
+ stats["skipped_missing_book"]
602
+ + stats["skipped_missing_manifest"]
603
+ + stats["skipped_status"]
604
+ + stats["skipped_blocked_rights"]
605
+ + stats["skipped_rights_filter"]
606
+ + stats["skipped_missing_text"]
607
+ + stats["skipped_missing_image"]
608
+ ),
609
+ "cost_usd": "",
610
+ "output_path": args.model_repo_id,
611
+ "notes": json.dumps(notes, ensure_ascii=False),
612
+ }
613
+ )
614
+
615
+ print(f"Run summary: {summary_path}")
616
+ print(f"Governance log updated: {RUN_LOG_CSV}")
617
+
618
+
619
+ if __name__ == "__main__":
620
+ main()
smoke_signal/scripts/10_recalibrate.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal — Stage 12: Confidence Recalibration
4
+ ==================================================
5
+ Recomputes confidence thresholds from the full gold set using a simple
6
+ precision/recall threshold analysis per region class.
7
+
8
+ Outputs:
9
+ - manifest/confidence_calibration.json
10
+ - training/runs/<RUN_ID>_recalibration.json
11
+ - manifest/run_log.csv entry (governance)
12
+ """
13
+
14
+ import argparse
15
+ import csv
16
+ import json
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Tuple
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
23
+ CALIBRATION_JSON = ROOT / "manifest" / "confidence_calibration.json"
24
+ RUN_LOG_CSV = ROOT / "manifest" / "run_log.csv"
25
+ GOLD_FILE = ROOT / "gold" / "gold_corrections.jsonl"
26
+ RUNS_DIR = ROOT / "training" / "runs"
27
+
28
+ ELIGIBLE_RIGHTS = {"public-domain", "licensed-owned", "controlled-internal"}
29
+ BLOCKED_RIGHTS = {"unknown", "excluded"}
30
+
31
+ RUN_LOG_FIELDS = [
32
+ "run_id",
33
+ "date",
34
+ "operator",
35
+ "config_version",
36
+ "schema_version",
37
+ "source_batch",
38
+ "pages_processed",
39
+ "errors",
40
+ "cost_usd",
41
+ "output_path",
42
+ "notes",
43
+ ]
44
+
45
+
46
+ def utc_now() -> datetime:
47
+ return datetime.now(timezone.utc)
48
+
49
+
50
+ def utc_iso() -> str:
51
+ return utc_now().isoformat().replace("+00:00", "Z")
52
+
53
+
54
+ def ensure_run_dirs() -> None:
55
+ RUNS_DIR.mkdir(parents=True, exist_ok=True)
56
+
57
+
58
+ def ensure_run_log() -> None:
59
+ RUN_LOG_CSV.parent.mkdir(parents=True, exist_ok=True)
60
+ if RUN_LOG_CSV.exists():
61
+ return
62
+ with open(RUN_LOG_CSV, "w", newline="", encoding="utf-8") as f:
63
+ writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS)
64
+ writer.writeheader()
65
+
66
+
67
+ def append_run_log(row: Dict[str, str]) -> None:
68
+ ensure_run_log()
69
+ with open(RUN_LOG_CSV, "a", newline="", encoding="utf-8") as f:
70
+ writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS)
71
+ writer.writerow({k: row.get(k, "") for k in RUN_LOG_FIELDS})
72
+
73
+
74
+ def load_manifest() -> Dict[str, Dict[str, str]]:
75
+ out: Dict[str, Dict[str, str]] = {}
76
+ if not MANIFEST_CSV.exists():
77
+ return out
78
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
79
+ for row in csv.DictReader(f):
80
+ book_id = str(row.get("book_id", "")).strip()
81
+ if book_id:
82
+ out[book_id] = row
83
+ return out
84
+
85
+
86
+ def load_gold_records(path: Path) -> List[Dict]:
87
+ rows: List[Dict] = []
88
+ if not path.exists():
89
+ return rows
90
+ with open(path, encoding="utf-8") as f:
91
+ for idx, line in enumerate(f, start=1):
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ try:
96
+ rec = json.loads(line)
97
+ rec["_line"] = idx
98
+ rows.append(rec)
99
+ except json.JSONDecodeError:
100
+ continue
101
+ return rows
102
+
103
+
104
+ def parse_bool(value) -> Optional[bool]:
105
+ if isinstance(value, bool):
106
+ return value
107
+ if value is None:
108
+ return None
109
+ text = str(value).strip().lower()
110
+ if text in {"true", "1", "yes", "y"}:
111
+ return True
112
+ if text in {"false", "0", "no", "n"}:
113
+ return False
114
+ return None
115
+
116
+
117
+ def parse_confidence(value) -> Optional[float]:
118
+ try:
119
+ conf = float(value)
120
+ return max(0.0, min(1.0, conf))
121
+ except (TypeError, ValueError):
122
+ return None
123
+
124
+
125
+ def infer_was_correct(rec: Dict) -> Optional[bool]:
126
+ explicit = parse_bool(rec.get("was_correct"))
127
+ if explicit is not None:
128
+ return explicit
129
+
130
+ final_text = str(rec.get("final_text", "")).strip()
131
+ raw_text = str(rec.get("raw_text", rec.get("raw_ocr", ""))).strip()
132
+ if final_text and raw_text:
133
+ return final_text == raw_text
134
+ return None
135
+
136
+
137
+ def precision_recall_at_threshold(rows: List[Tuple[float, int]], threshold: float) -> Dict[str, float]:
138
+ tp = fp = fn = tn = 0
139
+ for conf, label in rows:
140
+ pred = 1 if conf >= threshold else 0
141
+ if pred == 1 and label == 1:
142
+ tp += 1
143
+ elif pred == 1 and label == 0:
144
+ fp += 1
145
+ elif pred == 0 and label == 1:
146
+ fn += 1
147
+ else:
148
+ tn += 1
149
+
150
+ precision = tp / (tp + fp) if (tp + fp) else 0.0
151
+ recall = tp / (tp + fn) if (tp + fn) else 0.0
152
+ return {
153
+ "tp": tp,
154
+ "fp": fp,
155
+ "fn": fn,
156
+ "tn": tn,
157
+ "precision": precision,
158
+ "recall": recall,
159
+ }
160
+
161
+
162
+ def f_beta(precision: float, recall: float, beta: float) -> float:
163
+ if precision <= 0 and recall <= 0:
164
+ return 0.0
165
+ beta2 = beta * beta
166
+ denom = (beta2 * precision) + recall
167
+ if denom <= 0:
168
+ return 0.0
169
+ return (1 + beta2) * (precision * recall) / denom
170
+
171
+
172
+ def select_thresholds(
173
+ rows: List[Tuple[float, int]],
174
+ auto_precision_target: float,
175
+ auto_recall_floor: float,
176
+ review_recall_target: float,
177
+ review_precision_floor: float,
178
+ quarantine_gap: float,
179
+ ) -> Dict:
180
+ unique_thresholds = sorted({round(conf, 4) for conf, _ in rows})
181
+ if not unique_thresholds:
182
+ return {
183
+ "auto_accept": 0.85,
184
+ "review": 0.60,
185
+ "quarantine": 0.35,
186
+ "metrics": {},
187
+ }
188
+
189
+ # Add boundary values so we can always compute a fallback.
190
+ thresholds = sorted(set([0.0, 1.0] + unique_thresholds))
191
+
192
+ # Auto-accept: highest threshold meeting strict precision target.
193
+ auto_t = None
194
+ for t in thresholds:
195
+ m = precision_recall_at_threshold(rows, t)
196
+ if m["precision"] >= auto_precision_target and m["recall"] >= auto_recall_floor:
197
+ auto_t = t
198
+ if auto_t is None:
199
+ # Fallback: maximize F0.5 to prioritize precision.
200
+ auto_t = max(thresholds, key=lambda t: f_beta(
201
+ precision_recall_at_threshold(rows, t)["precision"],
202
+ precision_recall_at_threshold(rows, t)["recall"],
203
+ beta=0.5,
204
+ ))
205
+
206
+ # Review threshold: below/at auto threshold, try to capture most true positives.
207
+ review_candidates = [t for t in thresholds if t <= auto_t]
208
+ review_t = None
209
+ for t in review_candidates:
210
+ m = precision_recall_at_threshold(rows, t)
211
+ if m["recall"] >= review_recall_target and m["precision"] >= review_precision_floor:
212
+ review_t = t
213
+ break
214
+ if review_t is None:
215
+ # Fallback: maximize F1 while respecting t <= auto_t.
216
+ review_t = max(review_candidates, key=lambda t: f_beta(
217
+ precision_recall_at_threshold(rows, t)["precision"],
218
+ precision_recall_at_threshold(rows, t)["recall"],
219
+ beta=1.0,
220
+ ))
221
+
222
+ review_t = min(review_t, auto_t)
223
+
224
+ quarantine_t = max(0.0, review_t - quarantine_gap)
225
+ quarantine_t = min(quarantine_t, review_t)
226
+
227
+ # Round for readability and stable diffs.
228
+ auto_t = round(float(auto_t), 3)
229
+ review_t = round(float(review_t), 3)
230
+ quarantine_t = round(float(quarantine_t), 3)
231
+
232
+ # Guarantee monotonic order.
233
+ if review_t > auto_t:
234
+ review_t = auto_t
235
+ if quarantine_t > review_t:
236
+ quarantine_t = review_t
237
+
238
+ return {
239
+ "auto_accept": auto_t,
240
+ "review": review_t,
241
+ "quarantine": quarantine_t,
242
+ "metrics": {
243
+ "auto": precision_recall_at_threshold(rows, auto_t),
244
+ "review": precision_recall_at_threshold(rows, review_t),
245
+ "quarantine": precision_recall_at_threshold(rows, quarantine_t),
246
+ },
247
+ }
248
+
249
+
250
+ def build_region_rows(
251
+ gold_records: List[Dict],
252
+ manifest: Dict[str, Dict[str, str]],
253
+ rights_class_filter: Optional[str],
254
+ ) -> Tuple[Dict[str, List[Tuple[float, int]]], Dict[str, int]]:
255
+ by_region: Dict[str, List[Tuple[float, int]]] = {}
256
+ counters = {
257
+ "input": len(gold_records),
258
+ "used": 0,
259
+ "skipped_missing_book": 0,
260
+ "skipped_missing_manifest": 0,
261
+ "skipped_blocked_rights": 0,
262
+ "skipped_rights_filter": 0,
263
+ "skipped_missing_confidence": 0,
264
+ "skipped_missing_label": 0,
265
+ }
266
+
267
+ for rec in gold_records:
268
+ book_id = str(rec.get("book_id", "")).strip()
269
+ if not book_id:
270
+ counters["skipped_missing_book"] += 1
271
+ continue
272
+
273
+ manifest_row = manifest.get(book_id)
274
+ if not manifest_row:
275
+ counters["skipped_missing_manifest"] += 1
276
+ continue
277
+
278
+ rights = str(manifest_row.get("rights_class", "unknown")).strip().lower()
279
+ if rights in BLOCKED_RIGHTS or rights not in ELIGIBLE_RIGHTS:
280
+ counters["skipped_blocked_rights"] += 1
281
+ continue
282
+
283
+ if rights_class_filter and rights != rights_class_filter:
284
+ counters["skipped_rights_filter"] += 1
285
+ continue
286
+
287
+ conf = parse_confidence(rec.get("confidence"))
288
+ if conf is None:
289
+ counters["skipped_missing_confidence"] += 1
290
+ continue
291
+
292
+ was_correct = infer_was_correct(rec)
293
+ if was_correct is None:
294
+ counters["skipped_missing_label"] += 1
295
+ continue
296
+
297
+ # Positive class = OCR output was correct.
298
+ label = 1 if was_correct else 0
299
+ region_class = str(rec.get("region_class", "narration")).strip() or "narration"
300
+
301
+ by_region.setdefault(region_class, []).append((conf, label))
302
+ by_region.setdefault("_default", []).append((conf, label))
303
+ counters["used"] += 1
304
+
305
+ return by_region, counters
306
+
307
+
308
+ def parse_args() -> argparse.Namespace:
309
+ parser = argparse.ArgumentParser(description="Smoke Signal — Stage 12: Recalibration")
310
+ parser.add_argument("--gold-file", default=str(GOLD_FILE), help="Path to gold corrections JSONL")
311
+ parser.add_argument(
312
+ "--rights-class",
313
+ default=None,
314
+ choices=sorted(ELIGIBLE_RIGHTS),
315
+ help="Optional rights class filter",
316
+ )
317
+ parser.add_argument("--operator", default="codex", help="Operator for governance log")
318
+
319
+ parser.add_argument("--auto-precision-target", type=float, default=0.98)
320
+ parser.add_argument("--auto-recall-floor", type=float, default=0.20)
321
+ parser.add_argument("--review-recall-target", type=float, default=0.90)
322
+ parser.add_argument("--review-precision-floor", type=float, default=0.60)
323
+ parser.add_argument("--quarantine-gap", type=float, default=0.20)
324
+ parser.add_argument("--min-samples-per-class", type=int, default=10)
325
+
326
+ parser.add_argument("--run-id", default=None, help="Optional explicit run id")
327
+ return parser.parse_args()
328
+
329
+
330
+ def main() -> None:
331
+ args = parse_args()
332
+ ensure_run_dirs()
333
+
334
+ run_id = args.run_id or f"SS-CAL-{utc_now().strftime('%Y%m%d-%H%M%S')}"
335
+
336
+ gold_path = Path(args.gold_file).expanduser().resolve()
337
+ if not gold_path.exists():
338
+ raise FileNotFoundError(f"Gold file not found: {gold_path}")
339
+
340
+ manifest = load_manifest()
341
+ if not manifest:
342
+ raise RuntimeError("Manifest is empty. Cannot enforce rights controls.")
343
+
344
+ gold_records = load_gold_records(gold_path)
345
+ if not gold_records:
346
+ raise RuntimeError("No valid gold records found.")
347
+
348
+ by_region, counters = build_region_rows(gold_records, manifest, args.rights_class)
349
+ if counters["used"] == 0:
350
+ raise RuntimeError(f"No usable records for recalibration after filtering. Counters: {counters}")
351
+
352
+ calibration: Dict[str, Dict] = {}
353
+ report_regions: Dict[str, Dict] = {}
354
+
355
+ for region_class, rows in by_region.items():
356
+ if len(rows) < args.min_samples_per_class and region_class != "_default":
357
+ # Too little data for a reliable per-class threshold; defer to default.
358
+ continue
359
+
360
+ selected = select_thresholds(
361
+ rows=rows,
362
+ auto_precision_target=args.auto_precision_target,
363
+ auto_recall_floor=args.auto_recall_floor,
364
+ review_recall_target=args.review_recall_target,
365
+ review_precision_floor=args.review_precision_floor,
366
+ quarantine_gap=args.quarantine_gap,
367
+ )
368
+
369
+ corrections = sum(1 for _, label in rows if label == 0)
370
+ calibration[region_class] = {
371
+ "auto_accept": selected["auto_accept"],
372
+ "review": selected["review"],
373
+ "quarantine": selected["quarantine"],
374
+ "corrections": corrections,
375
+ }
376
+
377
+ report_regions[region_class] = {
378
+ "samples": len(rows),
379
+ "correct": sum(1 for _, label in rows if label == 1),
380
+ "incorrect": sum(1 for _, label in rows if label == 0),
381
+ "thresholds": calibration[region_class],
382
+ "metrics": selected["metrics"],
383
+ }
384
+
385
+ # Ensure required defaults exist for runtime readers.
386
+ if "_default" not in calibration:
387
+ calibration["_default"] = {
388
+ "auto_accept": 0.85,
389
+ "review": 0.60,
390
+ "quarantine": 0.35,
391
+ "corrections": 0,
392
+ }
393
+
394
+ default_entry = calibration["_default"]
395
+ for cls in ["narration", "dialogue-speech-bubble", "caption", "title", "sign-label"]:
396
+ if cls not in calibration:
397
+ calibration[cls] = dict(default_entry)
398
+
399
+ CALIBRATION_JSON.parent.mkdir(parents=True, exist_ok=True)
400
+ with open(CALIBRATION_JSON, "w", encoding="utf-8") as f:
401
+ json.dump(calibration, f, indent=2, ensure_ascii=False)
402
+
403
+ report = {
404
+ "run_id": run_id,
405
+ "generated_at": utc_iso(),
406
+ "config_version": "ss_confidence_calibration_v0.1",
407
+ "schema_version": "ss_confidence_calibration_report_v1",
408
+ "gold_file": str(gold_path),
409
+ "rights_class_filter": args.rights_class,
410
+ "counters": counters,
411
+ "regions": report_regions,
412
+ "output_file": str(CALIBRATION_JSON),
413
+ }
414
+
415
+ report_path = RUNS_DIR / f"{run_id}_recalibration.json"
416
+ with open(report_path, "w", encoding="utf-8") as f:
417
+ json.dump(report, f, indent=2, ensure_ascii=False)
418
+
419
+ append_run_log(
420
+ {
421
+ "run_id": run_id,
422
+ "date": utc_now().date().isoformat(),
423
+ "operator": args.operator,
424
+ "config_version": "ss_confidence_calibration_v0.1",
425
+ "schema_version": "ss_confidence_calibration_report_v1",
426
+ "source_batch": args.rights_class or "auto",
427
+ "pages_processed": str(counters["used"]),
428
+ "errors": str(
429
+ counters["skipped_missing_book"]
430
+ + counters["skipped_missing_manifest"]
431
+ + counters["skipped_blocked_rights"]
432
+ + counters["skipped_rights_filter"]
433
+ + counters["skipped_missing_confidence"]
434
+ + counters["skipped_missing_label"]
435
+ ),
436
+ "cost_usd": "",
437
+ "output_path": str(CALIBRATION_JSON.relative_to(ROOT)),
438
+ "notes": json.dumps(
439
+ {
440
+ "auto_precision_target": args.auto_precision_target,
441
+ "review_recall_target": args.review_recall_target,
442
+ "regions_calibrated": sorted(report_regions.keys()),
443
+ },
444
+ ensure_ascii=False,
445
+ ),
446
+ }
447
+ )
448
+
449
+ print(f"Recalibration complete: {CALIBRATION_JSON}")
450
+ print(f"Report: {report_path}")
451
+ print(f"Governance log updated: {RUN_LOG_CSV}")
452
+
453
+
454
+ if __name__ == "__main__":
455
+ main()
smoke_signal_tab.py CHANGED
@@ -824,7 +824,9 @@ def run_ocr() -> tuple:
824
  "book_id": book_id, "filename": row["filename"],
825
  "page": page_num, "region_id": region_id,
826
  "region_class": "narration",
 
827
  "crop_path": page_data.get("render_path",""),
 
828
  "raw_ocr": raw_text,
829
  "confidence": conf, "confidence_class": conf_class,
830
  "status": "quarantine" if conf_class == "quarantine" else "pending",
@@ -967,8 +969,10 @@ def save_review_decision(idx: int, final_text: str, action: str, reviewer: str,
967
  f.write(json.dumps({
968
  **decision,
969
  "region_class": region_class,
 
970
  "confidence": float(item.get("confidence", 0)),
971
  "conf_class": item.get("confidence_class",""),
 
972
  }) + "\n")
973
 
974
  cal = load_calibration()
 
824
  "book_id": book_id, "filename": row["filename"],
825
  "page": page_num, "region_id": region_id,
826
  "region_class": "narration",
827
+ "rights_class": row.get("rights_class", "unknown"),
828
  "crop_path": page_data.get("render_path",""),
829
+ "page_image_path": page_data.get("render_path",""),
830
  "raw_ocr": raw_text,
831
  "confidence": conf, "confidence_class": conf_class,
832
  "status": "quarantine" if conf_class == "quarantine" else "pending",
 
969
  f.write(json.dumps({
970
  **decision,
971
  "region_class": region_class,
972
+ "rights_class": item.get("rights_class", "unknown"),
973
  "confidence": float(item.get("confidence", 0)),
974
  "conf_class": item.get("confidence_class",""),
975
+ "page_image_path": item.get("page_image_path", item.get("crop_path", "")),
976
  }) + "\n")
977
 
978
  cal = load_calibration()