#!/usr/bin/env python3 """ Smoke Signal — Stage 11: Surya Fine-Tuning Orchestrator ======================================================== Builds a governance-safe OCR fine-tuning dataset from gold corrections, optionally uploads it to Hugging Face, and can launch Surya OCR finetuning. Governance controls enforced: - unknown/excluded rights are always blocked - mixed rights classes are blocked by default - checkpoint/config changes are versioned and logged - every run logs model version, dataset size, and gold set hash Primary sources used for integration choices: - Surya README finetune entrypoint and args - Surya example dataset shape (`image` + `text`) """ import argparse import csv import hashlib import json import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Tuple ROOT = Path(__file__).resolve().parents[1] MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv" RUN_LOG_CSV = ROOT / "manifest" / "run_log.csv" GOLD_FILE = ROOT / "gold" / "gold_corrections.jsonl" RENDERS_DIR = ROOT / "renders" TRAINING_DIR = ROOT / "training" TRAINING_DATASETS_DIR = TRAINING_DIR / "datasets" TRAINING_RUNS_DIR = TRAINING_DIR / "runs" ELIGIBLE_RIGHTS = {"public-domain", "licensed-owned", "controlled-internal"} BLOCKED_RIGHTS = {"unknown", "excluded"} RUN_LOG_FIELDS = [ "run_id", "date", "operator", "config_version", "schema_version", "source_batch", "pages_processed", "errors", "cost_usd", "output_path", "notes", ] def utc_now() -> datetime: return datetime.now(timezone.utc) def utc_iso() -> str: return utc_now().isoformat().replace("+00:00", "Z") def ensure_dirs() -> None: TRAINING_DIR.mkdir(parents=True, exist_ok=True) TRAINING_DATASETS_DIR.mkdir(parents=True, exist_ok=True) TRAINING_RUNS_DIR.mkdir(parents=True, exist_ok=True) def sha256_file(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(1 << 20), b""): h.update(block) return h.hexdigest() def sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def ensure_run_log() -> None: RUN_LOG_CSV.parent.mkdir(parents=True, exist_ok=True) if RUN_LOG_CSV.exists(): return with open(RUN_LOG_CSV, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS) writer.writeheader() def append_run_log(row: Dict[str, str]) -> None: ensure_run_log() with open(RUN_LOG_CSV, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=RUN_LOG_FIELDS) writer.writerow({k: row.get(k, "") for k in RUN_LOG_FIELDS}) def load_manifest() -> Dict[str, Dict[str, str]]: records: Dict[str, Dict[str, str]] = {} if not MANIFEST_CSV.exists(): return records with open(MANIFEST_CSV, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): book_id = str(row.get("book_id", "")).strip() if book_id: records[book_id] = row return records def load_gold_records(path: Path) -> List[Dict]: records: List[Dict] = [] if not path.exists(): return records with open(path, encoding="utf-8") as f: for idx, line in enumerate(f, start=1): line = line.strip() if not line: continue try: rec = json.loads(line) rec["_gold_line"] = idx records.append(rec) except json.JSONDecodeError: # Keep pipeline resilient: skip malformed line. continue return records def parse_page_number(value) -> Optional[int]: if value is None: return None try: return int(value) except (TypeError, ValueError): return None def resolve_image_path(record: Dict, book_id: str, page_num: Optional[int]) -> Optional[Path]: path_fields = [ "page_image_path", "page_image", "image_path", "crop_path", "render_path", ] for field in path_fields: raw = record.get(field) if not raw: continue candidate = Path(str(raw)) if not candidate.is_absolute(): candidate = ROOT / candidate if candidate.exists() and candidate.is_file(): return candidate.resolve() if page_num is not None: render_dir = RENDERS_DIR / book_id if render_dir.exists(): candidates = sorted(render_dir.glob(f"{book_id}_page_{page_num:04d}_*.*")) if candidates: return candidates[0].resolve() return None def select_transcript(record: Dict) -> str: for key in ("final_text", "corrected_text", "text", "raw_text", "raw_ocr"): value = record.get(key) if value is not None: text = str(value).strip() if text: return text return "" def build_examples( gold_records: List[Dict], manifest: Dict[str, Dict[str, str]], rights_class_filter: Optional[str], allow_mixed_rights: bool, ) -> Tuple[List[Dict], Dict]: stats = { "gold_rows": len(gold_records), "kept": 0, "skipped_missing_book": 0, "skipped_missing_manifest": 0, "skipped_status": 0, "skipped_blocked_rights": 0, "skipped_rights_filter": 0, "skipped_missing_text": 0, "skipped_missing_image": 0, } rights_seen = set() examples: List[Dict] = [] for rec in gold_records: book_id = str(rec.get("book_id", "")).strip() if not book_id: stats["skipped_missing_book"] += 1 continue manifest_row = manifest.get(book_id) if not manifest_row: stats["skipped_missing_manifest"] += 1 continue status = str(rec.get("status", "")).strip().lower() if status and status not in {"accepted", "edited"}: stats["skipped_status"] += 1 continue rights_class = str(manifest_row.get("rights_class", "unknown")).strip().lower() if rights_class in BLOCKED_RIGHTS or rights_class not in ELIGIBLE_RIGHTS: stats["skipped_blocked_rights"] += 1 continue if rights_class_filter and rights_class != rights_class_filter: stats["skipped_rights_filter"] += 1 continue text = select_transcript(rec) if not text: stats["skipped_missing_text"] += 1 continue page_num = parse_page_number(rec.get("page") or rec.get("page_number")) image_path = resolve_image_path(rec, book_id, page_num) if not image_path: stats["skipped_missing_image"] += 1 continue rights_seen.add(rights_class) example = { "image": str(image_path), "text": text, "book_id": book_id, "page": page_num, "region_class": str(rec.get("region_class", "narration")), "rights_class": rights_class, "confidence": float(rec.get("confidence", 0) or 0), "gold_line": int(rec.get("_gold_line", 0)), } examples.append(example) if not allow_mixed_rights and len(rights_seen) > 1: raise ValueError( f"Mixed rights classes found in training set: {sorted(rights_seen)}. " "Run separate jobs per rights class or pass --allow-mixed-rights explicitly." ) stats["kept"] = len(examples) stats["rights_seen"] = sorted(rights_seen) return examples, stats def save_training_artifacts(run_id: str, examples: List[Dict], stats: Dict, metadata: Dict) -> Path: run_dataset_dir = TRAINING_DATASETS_DIR / run_id run_dataset_dir.mkdir(parents=True, exist_ok=True) examples_jsonl = run_dataset_dir / "training_examples.jsonl" with open(examples_jsonl, "w", encoding="utf-8") as f: for row in examples: f.write(json.dumps(row, ensure_ascii=False) + "\n") manifest_path = run_dataset_dir / "dataset_manifest.json" with open(manifest_path, "w", encoding="utf-8") as f: json.dump({"stats": stats, "metadata": metadata}, f, indent=2, ensure_ascii=False) return run_dataset_dir def build_hf_dataset(examples: List[Dict]): try: from datasets import Dataset, Image except Exception as exc: # pragma: no cover - environment-dependent raise RuntimeError( "datasets[vision] is required. Install with: pip install datasets[vision]" ) from exc ds = Dataset.from_dict({ "image": [e["image"] for e in examples], "text": [e["text"] for e in examples], }).cast_column("image", Image()) return ds def push_dataset_to_hub(ds, repo_id: str, private: bool, token: Optional[str], run_id: str) -> None: ds.push_to_hub( repo_id, private=private, token=token, commit_message=f"Smoke Signal finetune dataset {run_id}", ) def resolve_finetune_entrypoint(explicit_script: Optional[str], surya_repo: Optional[str]) -> List[str]: if explicit_script: script_path = Path(explicit_script).expanduser().resolve() if not script_path.exists(): raise FileNotFoundError(f"Surya finetune script not found: {script_path}") return [sys.executable, str(script_path)] try: import importlib.util spec = importlib.util.find_spec("surya.scripts.finetune_ocr") if spec is not None: return [sys.executable, "-m", "surya.scripts.finetune_ocr"] except Exception: pass if surya_repo: candidate = Path(surya_repo).expanduser().resolve() / "surya" / "scripts" / "finetune_ocr.py" if candidate.exists(): return [sys.executable, str(candidate)] raise RuntimeError( "Could not resolve Surya finetune entrypoint. " "Install surya-ocr or pass --surya-finetune-script /path/to/finetune_ocr.py" ) def _detect_hub_model_arg_name(entrypoint_cmd: List[str]) -> str: """ TrainingArguments changed over time. Detect supported hub model id arg from finetune --help output. """ try: probe = subprocess.run( entrypoint_cmd + ["--help"], capture_output=True, text=True, check=False, ) help_text = (probe.stdout or "") + "\\n" + (probe.stderr or "") if "--hub_model_id" in help_text: return "--hub_model_id" if "--push_to_hub_model_id" in help_text: return "--push_to_hub_model_id" except Exception: pass # Default to current TrainingArguments key. return "--hub_model_id" def build_train_command(args, run_output_dir: Path) -> List[str]: cmd = resolve_finetune_entrypoint(args.surya_finetune_script, args.surya_repo) hub_model_arg = _detect_hub_model_arg_name(cmd) cmd += [ "--output_dir", str(run_output_dir), "--dataset_name", args.dataset_repo_id, "--per_device_train_batch_size", str(args.per_device_train_batch_size), "--gradient_checkpointing", "true" if args.gradient_checkpointing else "false", "--max_sequence_length", str(args.max_sequence_length), "--num_train_epochs", str(args.num_train_epochs), "--learning_rate", str(args.learning_rate), "--logging_steps", str(args.logging_steps), "--save_steps", str(args.save_steps), "--save_total_limit", str(args.save_total_limit), "--remove_unused_columns", "false", "--push_to_hub", "true", hub_model_arg, args.model_repo_id, ] if args.pretrained_checkpoint_path: cmd += ["--pretrained_checkpoint_path", args.pretrained_checkpoint_path] if args.hf_token: cmd += ["--hub_token", args.hf_token] return cmd def write_run_summary(path: Path, summary: Dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2, ensure_ascii=False) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Smoke Signal — Stage 11: Surya Fine-Tuning") parser.add_argument("--gold-file", default=str(GOLD_FILE), help="Path to gold corrections JSONL") parser.add_argument( "--rights-class", default=None, choices=sorted(ELIGIBLE_RIGHTS), help="Restrict to one rights class (recommended governance mode)", ) parser.add_argument( "--allow-mixed-rights", action="store_true", help="Allow mixed rights classes in one run (off by default)", ) parser.add_argument( "--dataset-repo-id", default="Pointf5ive/smoke-signal-ocr-finetune", help="HF dataset repo id (/)", ) parser.add_argument( "--model-repo-id", default="Pointf5ive/smoke-signal-surya-ft", help="HF model repo id (/)", ) parser.add_argument("--hf-token", default=None, help="HF token (or set HF_TOKEN env var)") parser.add_argument("--private-dataset", action="store_true", help="Create/push dataset repo as private") parser.add_argument("--private-model", action="store_true", help="Create model repo as private") parser.add_argument("--operator", default="codex", help="Operator name for governance logs") parser.add_argument("--pretrained-checkpoint-path", default=None, help="Optional Surya init checkpoint") parser.add_argument("--surya-finetune-script", default=None, help="Path to surya/scripts/finetune_ocr.py") parser.add_argument("--surya-repo", default=None, help="Path to local Surya repo (fallback resolver)") parser.add_argument("--per-device-train-batch-size", type=int, default=16) parser.add_argument("--max-sequence-length", type=int, default=1024) parser.add_argument("--num-train-epochs", type=float, default=2.0) parser.add_argument("--learning-rate", type=float, default=5e-5) parser.add_argument("--gradient-checkpointing", action="store_true") parser.add_argument("--logging-steps", type=int, default=25) parser.add_argument("--save-steps", type=int, default=200) parser.add_argument("--save-total-limit", type=int, default=2) parser.add_argument( "--prepare-only", action="store_true", help="Stop after dataset prep/upload; do not start finetuning", ) parser.add_argument( "--skip-upload", action="store_true", help="Prepare local dataset artifacts but skip HF push", ) parser.add_argument("--run-id", default=None, help="Optional explicit run id") return parser.parse_args() def main() -> None: args = parse_args() ensure_dirs() token = args.hf_token if not token: import os token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN") args.hf_token = token run_id = args.run_id or f"SS-FT-{utc_now().strftime('%Y%m%d-%H%M%S')}" run_output_dir = TRAINING_RUNS_DIR / run_id run_output_dir.mkdir(parents=True, exist_ok=True) gold_path = Path(args.gold_file).expanduser().resolve() if not gold_path.exists(): raise FileNotFoundError(f"Gold file not found: {gold_path}") manifest = load_manifest() if not manifest: raise RuntimeError("Manifest is empty. Run 01_register_sources.py and set rights_class first.") gold_records = load_gold_records(gold_path) if not gold_records: raise RuntimeError("Gold set is empty or unreadable; cannot fine-tune.") examples, stats = build_examples( gold_records=gold_records, manifest=manifest, rights_class_filter=args.rights_class, allow_mixed_rights=args.allow_mixed_rights, ) if not examples: raise RuntimeError(f"No valid training examples after governance filters. Stats: {stats}") rights_for_run = stats.get("rights_seen", []) if not args.allow_mixed_rights and len(rights_for_run) > 1: raise RuntimeError( f"Mixed rights classes in run: {rights_for_run}. This violates governance by default." ) # Governance metadata gold_hash = sha256_file(gold_path) text_concat = "\n".join(f"{e['book_id']}|{e['page']}|{e['text']}" for e in examples) dataset_hash = sha256_text(text_concat) config_version = "ss_surya_finetune_config_v0.1" if args.pretrained_checkpoint_path: ck_hash = sha256_text(args.pretrained_checkpoint_path)[:8] config_version = f"{config_version}_ckpt_{ck_hash}" metadata = { "run_id": run_id, "created_at": utc_iso(), "config_version": config_version, "schema_version": "ss_surya_ocr_finetune_dataset_v1", "gold_file": str(gold_path), "gold_hash": gold_hash, "dataset_hash": dataset_hash, "dataset_size": len(examples), "rights_seen": rights_for_run, "rights_filter": args.rights_class, "dataset_repo_id": args.dataset_repo_id, "model_repo_id": args.model_repo_id, "pretrained_checkpoint_path": args.pretrained_checkpoint_path or "base", "operator": args.operator, } dataset_artifact_dir = save_training_artifacts(run_id, examples, stats, metadata) print(f"\nPrepared dataset artifacts: {dataset_artifact_dir}") print(f"Examples kept: {len(examples)} | Rights: {rights_for_run} | Gold hash: {gold_hash[:12]}...") summary = { "metadata": metadata, "stats": stats, "train_command": None, "train_returncode": None, "train_stdout_path": None, "train_stderr_path": None, } if not args.skip_upload: if not args.hf_token: raise RuntimeError("HF token required for upload. Pass --hf-token or set HF_TOKEN.") ds = build_hf_dataset(examples) push_dataset_to_hub(ds, args.dataset_repo_id, args.private_dataset, args.hf_token, run_id) print(f"Pushed dataset to HF: {args.dataset_repo_id}") else: print("Skipped HF upload (--skip-upload).") if args.prepare_only: print("Prepare-only mode complete. Finetuning not started.") else: if args.skip_upload: raise RuntimeError( "Cannot start finetuning with --skip-upload because Surya expects --dataset_name. " "Upload dataset first or run with --prepare-only." ) if not args.hf_token: raise RuntimeError("HF token required for model push during finetuning.") train_cmd = build_train_command(args, run_output_dir) stdout_path = run_output_dir / "finetune_stdout.log" stderr_path = run_output_dir / "finetune_stderr.log" summary["train_command"] = train_cmd summary["train_stdout_path"] = str(stdout_path) summary["train_stderr_path"] = str(stderr_path) print("Launching Surya finetune...") print(" ".join(train_cmd)) with open(stdout_path, "w", encoding="utf-8") as out, open(stderr_path, "w", encoding="utf-8") as err: proc = subprocess.run(train_cmd, stdout=out, stderr=err, text=True) summary["train_returncode"] = proc.returncode if proc.returncode != 0: raise RuntimeError( f"Surya finetune failed with return code {proc.returncode}. " f"See {stdout_path} and {stderr_path}." ) print(f"Finetune complete. Model pushed to: {args.model_repo_id}") summary_path = TRAINING_RUNS_DIR / f"{run_id}_summary.json" write_run_summary(summary_path, summary) notes = { "model_version": args.pretrained_checkpoint_path or "base", "dataset_size": len(examples), "gold_hash": gold_hash, "dataset_repo": args.dataset_repo_id, "model_repo": args.model_repo_id, } append_run_log( { "run_id": run_id, "date": utc_now().date().isoformat(), "operator": args.operator, "config_version": config_version, "schema_version": "ss_surya_ocr_finetune_dataset_v1", "source_batch": args.rights_class or ",".join(rights_for_run), "pages_processed": str(len(examples)), "errors": str( stats["skipped_missing_book"] + stats["skipped_missing_manifest"] + stats["skipped_status"] + stats["skipped_blocked_rights"] + stats["skipped_rights_filter"] + stats["skipped_missing_text"] + stats["skipped_missing_image"] ), "cost_usd": "", "output_path": args.model_repo_id, "notes": json.dumps(notes, ensure_ascii=False), } ) print(f"Run summary: {summary_path}") print(f"Governance log updated: {RUN_LOG_CSV}") if __name__ == "__main__": main()