Pointf5ive commited on
Commit
532d429
Β·
verified Β·
1 Parent(s): 33e5e27

Upload 13 files

Browse files
smoke_signal/.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Source PDFs β€” never commit these (large files + rights issues)
2
+ source_pdfs/
3
+
4
+ # Derived processing outputs β€” large, regenerable
5
+ renders/
6
+ ocr_raw/
7
+ regions/
8
+
9
+ # Keep review decisions and exports (small, important)
10
+ # review/ ← intentionally tracked
11
+ # exports/ ← intentionally tracked
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ *.egg-info/
17
+ .venv/
18
+ venv/
19
+ dist/
20
+ build/
21
+
22
+ # Environment
23
+ .env
24
+ .env.local
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Logs (keep manifests and reports, not raw logs)
31
+ logs/
32
+
33
+ # HF cache
34
+ .cache/
smoke_signal/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Totem: Smoke Signal v0.1
2
+
3
+ Picture-book OCR & Codex extraction pipeline.
4
+
5
+ ## Folder Structure
6
+
7
+ ```
8
+ smoke_signal/
9
+ β”œβ”€β”€ source_pdfs/ # Immutable β€” never overwrite source PDFs
10
+ β”œβ”€β”€ manifest/ # source_manifest.csv, run logs
11
+ β”œβ”€β”€ renders/ # Page PNG/TIFF renders from PDF
12
+ β”œβ”€β”€ ocr_raw/ # Raw OCR outputs per page/region
13
+ β”œβ”€β”€ regions/ # Region detection JSON, bounding boxes
14
+ β”œβ”€β”€ review/ # Human review queue CSVs and corrections
15
+ β”œβ”€β”€ exports/ # Codex-ready JSONL/CSV/Markdown exports
16
+ β”œβ”€β”€ logs/ # Per-run timestamped logs
17
+ β”œβ”€β”€ configs/ # Frozen config files per run
18
+ β”œβ”€β”€ schemas/ # JSON schemas for all output types
19
+ β”œβ”€β”€ reports/ # Validation and batch reports
20
+ └── scripts/ # Pipeline scripts
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # 1. Install dependencies
27
+ pip install -r requirements.txt
28
+
29
+ # 2. Add PDFs to source_pdfs/ folder
30
+
31
+ # 3. Register and hash all sources
32
+ python scripts/01_register_sources.py
33
+
34
+ # 4. Review manifest/source_manifest.csv and set rights_class for each book
35
+
36
+ # 5. Run PDF profiler (Stage 3)
37
+ # python scripts/02_profile_pdfs.py ← coming in next stage
38
+ ```
39
+
40
+ ## Control Principles
41
+
42
+ | Rule | Description |
43
+ |------|-------------|
44
+ | No silent acquisition | Every source file must be logged with rights/provenance/hash before extraction |
45
+ | No OCR soup in Codex | Low-confidence pages must be flagged before influencing fingerprints |
46
+ | No extractor logic drift | Changes to scoring/labels/thresholds require a versioned issue |
47
+ | Page-level traceability | Every exported line maps back to book ID, page, region, and extraction method |
48
+
49
+ ## Naming Convention
50
+
51
+ - Files: `BOOKID_page_REGIONID.ext`
52
+ - Batches: `SS-BATCH-001`, `SS-BATCH-002`, ...
53
+ - Configs: `ss_ocr_config_v0.1.json`
54
+ - Schemas: `schema_book_manifest_v1.json`
smoke_signal/README_HF.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Smoke Signal
3
+ emoji: πŸ”­
4
+ colorFrom: gray
5
+ colorTo: slate
6
+ sdk: gradio
7
+ sdk_version: "4.0.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Totem: Smoke Signal
14
+
15
+ Picture-book OCR & Codex extraction pipeline with human review workbench.
smoke_signal/app.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Smoke Signal β€” Hugging Face Spaces entry point
3
+ ===============================================
4
+ This file is the Gradio app for the human review workbench (Stage 8).
5
+ Currently a placeholder β€” will be built after Stage 1-7 pipeline is working.
6
+
7
+ To run locally:
8
+ pip install gradio
9
+ python app.py
10
+
11
+ To deploy to HF Spaces:
12
+ Push this repo to a Hugging Face Space with SDK: gradio
13
+ """
14
+
15
+ import gradio as gr
16
+
17
+ def placeholder(message):
18
+ return f"Smoke Signal review workbench β€” coming in Stage 8. You said: {message}"
19
+
20
+ with gr.Blocks(title="Smoke Signal β€” Review Workbench") as demo:
21
+ gr.Markdown("""
22
+ # πŸ”­ Totem: Smoke Signal
23
+ ### Picture-Book OCR & Codex Extraction β€” Human Review Workbench
24
+
25
+ **Status:** Pipeline under construction (Stage 0–1 complete)
26
+
27
+ Current stages:
28
+ - βœ… Stage 0: Project lock & scaffold
29
+ - βœ… Stage 1: Source registry & hashing
30
+ - πŸ”² Stage 2: Calibration corpus selection
31
+ - πŸ”² Stage 3: PDF profiling & rendering
32
+ - πŸ”² Stage 4: OCR bake-off (Surya baseline)
33
+ - πŸ”² Stage 5: Region detection
34
+ - πŸ”² Stage 6: Reading order resolver
35
+ - πŸ”² Stage 7: LLM normalisation
36
+ - πŸ”² Stage 8: This review workbench
37
+ """)
38
+
39
+ with gr.Row():
40
+ msg = gr.Textbox(label="Test input")
41
+ out = gr.Textbox(label="Output")
42
+ msg.submit(placeholder, inputs=msg, outputs=out)
43
+
44
+ if __name__ == "__main__":
45
+ demo.launch()
smoke_signal/manifest/PROJECT_CHARTER.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smoke Signal v0.1 β€” Project Charter
2
+
3
+ **Project name:** Totem: Smoke Signal
4
+ **Version:** 0.1 Discovery
5
+ **Date:** 2026-05-16
6
+ **Status:** Active
7
+
8
+ ---
9
+
10
+ ## Purpose
11
+
12
+ Smoke Signal is a narrow, reliable extraction subsystem.
13
+ Its job: convert messy picture-book PDFs into structured, traceable, reviewable source text and extraction metadata for the wider TOTEM/Codex environment.
14
+
15
+ It is **not** a general writing assistant.
16
+ It is **not** a Codex fingerprint scorer.
17
+ It is **not** a content generator.
18
+
19
+ ---
20
+
21
+ ## Inputs
22
+
23
+ - 150+ picture-book PDF source files (various scan quality, layouts, rights classes)
24
+ - Source manifest with rights classification per book
25
+ - Frozen pipeline configs per run
26
+
27
+ ---
28
+
29
+ ## Outputs
30
+
31
+ - `codex_export.jsonl` β€” Codex-ready structured text with full provenance
32
+ - `page_profile.json` β€” Per-book page routing metadata
33
+ - `review_queue.csv` β€” Human review queue for low-confidence pages
34
+ - `batch_report.md` β€” Per-batch validation summary
35
+ - `source_manifest.csv` β€” Immutable source registry
36
+
37
+ ---
38
+
39
+ ## Exclusions
40
+
41
+ - Does **not** tune Codex fingerprint scoring logic
42
+ - Does **not** acquire new source material autonomously
43
+ - Does **not** export unreviewed low-confidence text as clean source
44
+ - Does **not** mix rights classes in the same validation batch
45
+
46
+ ---
47
+
48
+ ## Risk Rules
49
+
50
+ | Risk | Control |
51
+ |------|---------|
52
+ | OCR noise entering Codex | Confidence thresholds + human review gate |
53
+ | Rights violation | Rights class separation; unknown = excluded from export |
54
+ | Silent logic drift | All config/schema/threshold changes require versioned issue |
55
+ | Hallucinated text | LLM outputs must be strict JSON; uncertain words flagged explicitly |
56
+ | Data loss | Source PDFs immutable; all processing to derived folders only |
57
+
58
+ ---
59
+
60
+ ## Approval Gates
61
+
62
+ | Gate | Condition |
63
+ |------|-----------|
64
+ | A | Source manifest complete before extraction begins |
65
+ | B | Calibration corpus approved before OCR bake-off |
66
+ | C | Baseline config approved before validation run |
67
+ | D | Validation report accepted before full-library batching |
68
+ | E | Batch QA accepted before Codex export |
69
+ | F | Closure pack accepted before declaring v1.0 |
70
+
71
+ ---
72
+
73
+ ## Roles
74
+
75
+ | Role | Responsibility |
76
+ |------|---------------|
77
+ | Human operator | Rights classification, review queue decisions, gate approvals |
78
+ | AI operator | Plans, schemas, scripts, reports β€” within approved configs only |
79
+
80
+ ---
81
+
82
+ *This charter was created as part of Smoke Signal Stage 0 (Project Lock & Scope).*
smoke_signal/manifest/decision_log.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ date,decision,reason,approver,affected_files_configs,notes
smoke_signal/manifest/issue_log.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ issue_id,date_raised,raised_by,type,description,affected_component,status,resolution,date_resolved,approver
smoke_signal/manifest/run_log.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ run_id,date,operator,config_version,schema_version,source_batch,pages_processed,errors,cost_usd,output_path,notes
smoke_signal/manifest/source_manifest.csv ADDED
@@ -0,0 +1 @@
 
 
1
+ book_id,source_id,filename,sha256,file_size_bytes,page_count,rights_class,source_location,acquisition_date,status,allowed_use,notes
smoke_signal/requirements.txt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smoke Signal v0.1 β€” Core dependencies
2
+
3
+ # PDF handling
4
+ pdfplumber>=0.10.0
5
+ pymupdf>=1.23.0 # fitz β€” fast rendering and text layer detection
6
+ pypdf>=3.0.0
7
+
8
+ # OCR β€” Surya (HF-native, primary engine)
9
+ surya-ocr>=0.4.0
10
+
11
+ # Image processing
12
+ Pillow>=10.0.0
13
+ numpy>=1.24.0
14
+
15
+ # Data handling
16
+ pandas>=2.0.0
17
+ jsonschema>=4.20.0
18
+
19
+ # Hugging Face
20
+ transformers>=4.40.0
21
+ huggingface_hub>=0.22.0
22
+ datasets>=2.18.0
23
+
24
+ # Utilities
25
+ tqdm>=4.66.0
26
+ rich>=13.0.0 # CLI output formatting
27
+ click>=8.1.0 # CLI argument parsing
28
+ python-dotenv>=1.0.0
29
+
30
+ # Hashing / manifest
31
+ hashlib # stdlib β€” no install needed
32
+
33
+ # Optional: Tesseract fallback
34
+ # pytesseract>=0.3.10
35
+ # Install tesseract system package: sudo apt-get install tesseract-ocr
smoke_signal/schemas/schema_book_manifest_v1.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "schema_book_manifest_v1.json",
4
+ "schema_version": "1.0",
5
+ "title": "Smoke Signal β€” Source Manifest Record",
6
+ "type": "object",
7
+ "required": [
8
+ "book_id", "source_id", "filename", "sha256",
9
+ "file_size_bytes", "page_count", "rights_class",
10
+ "source_location", "acquisition_date", "status"
11
+ ],
12
+ "properties": {
13
+ "book_id": {
14
+ "type": "string",
15
+ "pattern": "^SS-BOOK-[0-9]{4}$",
16
+ "description": "Stable unique book identifier, e.g. SS-BOOK-0001"
17
+ },
18
+ "source_id": {
19
+ "type": "string",
20
+ "description": "Optional secondary source tracking ID"
21
+ },
22
+ "filename": {
23
+ "type": "string",
24
+ "description": "Original filename as received"
25
+ },
26
+ "sha256": {
27
+ "type": "string",
28
+ "pattern": "^[a-f0-9]{64}$",
29
+ "description": "SHA-256 hash of the source PDF file"
30
+ },
31
+ "file_size_bytes": {
32
+ "type": "integer",
33
+ "minimum": 0
34
+ },
35
+ "page_count": {
36
+ "type": ["integer", "null"],
37
+ "minimum": 0,
38
+ "description": "null if not yet profiled"
39
+ },
40
+ "rights_class": {
41
+ "type": "string",
42
+ "enum": ["public-domain", "licensed-owned", "controlled-internal", "unknown", "excluded"],
43
+ "description": "Rights classification β€” unknown blocks training/export use"
44
+ },
45
+ "source_location": {
46
+ "type": "string",
47
+ "description": "Where the file was acquired from"
48
+ },
49
+ "acquisition_date": {
50
+ "type": "string",
51
+ "format": "date",
52
+ "description": "ISO 8601 date: YYYY-MM-DD"
53
+ },
54
+ "status": {
55
+ "type": "string",
56
+ "enum": ["pending", "profiled", "rendered", "ocred", "reviewed", "exported", "quarantined"],
57
+ "description": "Current processing status"
58
+ },
59
+ "allowed_use": {
60
+ "type": ["string", "null"],
61
+ "description": "What this source may be used for: extraction, training, both, none"
62
+ },
63
+ "notes": {
64
+ "type": ["string", "null"]
65
+ }
66
+ },
67
+ "additionalProperties": false
68
+ }
smoke_signal/schemas/schema_codex_export_v1.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "schema_codex_export_v1.json",
4
+ "schema_version": "1.0",
5
+ "title": "Smoke Signal β€” Codex Export Record",
6
+ "type": "object",
7
+ "required": [
8
+ "book_id", "source_hash", "page_number", "region_id",
9
+ "page_class", "region_class", "text_final", "confidence",
10
+ "review_status", "extraction_method", "run_id", "schema_version"
11
+ ],
12
+ "properties": {
13
+ "book_id": { "type": "string" },
14
+ "source_id": { "type": ["string", "null"] },
15
+ "source_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
16
+ "page_number": { "type": "integer", "minimum": 1 },
17
+ "region_id": { "type": "string", "description": "BOOKID_page_REGIONID format" },
18
+ "page_class": {
19
+ "type": "string",
20
+ "enum": ["cover", "title", "copyright", "story-page", "illustration-only",
21
+ "endpaper", "table-list", "advertisement-appendix", "unknown"]
22
+ },
23
+ "region_class": {
24
+ "type": "string",
25
+ "enum": ["narration", "dialogue-speech-bubble", "title", "subtitle",
26
+ "caption", "sign-label", "page-number", "copyright-legal",
27
+ "publisher-imprint", "decorative-uncertain"]
28
+ },
29
+ "text_raw": { "type": ["string", "null"], "description": "Raw OCR output before cleanup" },
30
+ "text_clean": { "type": ["string", "null"], "description": "After LLM normalisation" },
31
+ "text_final": { "type": "string", "description": "Approved final text for Codex" },
32
+ "uncertain_words": { "type": "array", "items": { "type": "string" } },
33
+ "confidence": {
34
+ "type": "number", "minimum": 0.0, "maximum": 1.0,
35
+ "description": "Composite confidence score"
36
+ },
37
+ "review_status": {
38
+ "type": "string",
39
+ "enum": ["auto-accepted", "human-accepted", "human-edited", "quarantined", "excluded"]
40
+ },
41
+ "exclusion_reason": { "type": ["string", "null"] },
42
+ "extraction_method": {
43
+ "type": "string",
44
+ "enum": ["embedded-text", "ocr", "ocr+llm", "manual", "excluded"]
45
+ },
46
+ "config_version": { "type": ["string", "null"] },
47
+ "schema_version": { "type": "string" },
48
+ "run_id": { "type": "string" },
49
+ "prompt_version": { "type": ["string", "null"] }
50
+ },
51
+ "additionalProperties": false
52
+ }
smoke_signal/scripts/01_register_sources.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal β€” Stage 1: Source Registry
4
+ ========================================
5
+ Scans /source_pdfs, hashes every PDF, and writes/updates source_manifest.csv.
6
+
7
+ Usage:
8
+ python scripts/01_register_sources.py
9
+ python scripts/01_register_sources.py --source-dir /path/to/pdfs
10
+ python scripts/01_register_sources.py --validate-only
11
+ """
12
+
13
+ import hashlib
14
+ import csv
15
+ import json
16
+ import os
17
+ import sys
18
+ import argparse
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ try:
24
+ from rich.console import Console
25
+ from rich.table import Table
26
+ from rich.progress import track
27
+ RICH = True
28
+ except ImportError:
29
+ RICH = False
30
+
31
+ # ── Paths ──────────────────────────────────────────────────────────────────────
32
+ ROOT = Path(__file__).resolve().parents[1]
33
+ SOURCE_DIR = ROOT / "source_pdfs"
34
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
35
+ SCHEMA_FILE = ROOT / "schemas" / "schema_book_manifest_v1.json"
36
+
37
+ console = Console() if RICH else None
38
+
39
+ MANIFEST_FIELDS = [
40
+ "book_id", "source_id", "filename", "sha256", "file_size_bytes",
41
+ "page_count", "rights_class", "source_location", "acquisition_date",
42
+ "status", "allowed_use", "notes"
43
+ ]
44
+
45
+ RIGHTS_CLASSES = {"public-domain", "licensed-owned", "controlled-internal", "unknown", "excluded"}
46
+
47
+
48
+ # ── Hashing ────────────────────────────────────────────────────────────────────
49
+ def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
50
+ """Return hex SHA-256 of a file, reading in chunks."""
51
+ h = hashlib.sha256()
52
+ with open(path, "rb") as f:
53
+ for block in iter(lambda: f.read(chunk), b""):
54
+ h.update(block)
55
+ return h.hexdigest()
56
+
57
+
58
+ # ── Page count (best-effort, no hard dependency on PDF libs) ──────────────────
59
+ def get_page_count(path: Path) -> Optional[int]:
60
+ """Try to count pages without a hard crash if libs are missing."""
61
+ try:
62
+ import fitz # pymupdf
63
+ doc = fitz.open(str(path))
64
+ count = doc.page_count
65
+ doc.close()
66
+ return count
67
+ except Exception:
68
+ pass
69
+ try:
70
+ import pdfplumber
71
+ with pdfplumber.open(str(path)) as pdf:
72
+ return len(pdf.pages)
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ # ── Book ID generation ─────────────────────────────────────────────────────────
78
+ def next_book_id(existing: dict) -> str:
79
+ """Generate the next SS-BOOK-NNNN ID not already in the manifest."""
80
+ used = {v["book_id"] for v in existing.values()}
81
+ for i in range(1, 10_000):
82
+ candidate = f"SS-BOOK-{i:04d}"
83
+ if candidate not in used:
84
+ return candidate
85
+ raise RuntimeError("Ran out of book IDs β€” this shouldn't happen.")
86
+
87
+
88
+ # ── Manifest I/O ───────────────────────────────────────────────────────────────
89
+ def load_manifest() -> dict:
90
+ """Load manifest CSV keyed by sha256."""
91
+ records = {}
92
+ if not MANIFEST_CSV.exists():
93
+ return records
94
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
95
+ reader = csv.DictReader(f)
96
+ for row in reader:
97
+ if row.get("sha256"):
98
+ records[row["sha256"]] = row
99
+ return records
100
+
101
+
102
+ def save_manifest(records: dict) -> None:
103
+ """Write manifest back to CSV, sorted by book_id."""
104
+ rows = sorted(records.values(), key=lambda r: r.get("book_id", ""))
105
+ with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
106
+ writer = csv.DictWriter(f, fieldnames=MANIFEST_FIELDS)
107
+ writer.writeheader()
108
+ writer.writerows(rows)
109
+
110
+
111
+ # ── Main ───────────────────────────────────────────────────────────────────────
112
+ def register_sources(source_dir: Path, validate_only: bool = False) -> None:
113
+ pdf_files = sorted(source_dir.glob("*.pdf"))
114
+
115
+ if not pdf_files:
116
+ print(f"[warn] No PDFs found in {source_dir}")
117
+ print(" Add your source PDFs to source_pdfs/ and re-run.")
118
+ return
119
+
120
+ print(f"\n{'='*60}")
121
+ print(f" Smoke Signal β€” Stage 1: Source Registry")
122
+ print(f"{'='*60}")
123
+ print(f" Found {len(pdf_files)} PDF(s) in {source_dir}\n")
124
+
125
+ existing = load_manifest()
126
+ new_count = 0
127
+ updated_count = 0
128
+ duplicate_count = 0
129
+
130
+ iterable = track(pdf_files, description="Hashing PDFs...") if RICH else pdf_files
131
+
132
+ for pdf_path in iterable:
133
+ if not RICH:
134
+ print(f" Processing: {pdf_path.name}")
135
+
136
+ file_hash = sha256_file(pdf_path)
137
+ file_size = pdf_path.stat().st_size
138
+
139
+ if file_hash in existing:
140
+ rec = existing[file_hash]
141
+ # Update file size if it changed (shouldn't, but track it)
142
+ if rec["filename"] != pdf_path.name:
143
+ print(f" [dup] {pdf_path.name} β†’ same content as {rec['filename']} ({rec['book_id']})")
144
+ duplicate_count += 1
145
+ continue
146
+
147
+ # New source
148
+ page_count = get_page_count(pdf_path)
149
+ book_id = next_book_id(existing)
150
+
151
+ record = {
152
+ "book_id": book_id,
153
+ "source_id": "",
154
+ "filename": pdf_path.name,
155
+ "sha256": file_hash,
156
+ "file_size_bytes": file_size,
157
+ "page_count": page_count if page_count is not None else "",
158
+ "rights_class": "unknown", # MUST be set manually
159
+ "source_location": str(source_dir),
160
+ "acquisition_date": date.today().isoformat(),
161
+ "status": "pending",
162
+ "allowed_use": "",
163
+ "notes": ""
164
+ }
165
+ existing[file_hash] = record
166
+ new_count += 1
167
+
168
+ status_icon = "βœ“" if page_count else "?"
169
+ print(f" [{status_icon}] Registered {book_id} β€” {pdf_path.name} ({page_count or '?'} pages)")
170
+
171
+ if not validate_only:
172
+ save_manifest(existing)
173
+ print(f"\n Manifest saved β†’ {MANIFEST_CSV}")
174
+
175
+ # ── Summary ───────────────────────────────────────────────────────────────
176
+ total = len(existing)
177
+ unknown_rights = sum(1 for r in existing.values() if r["rights_class"] == "unknown")
178
+
179
+ print(f"\n{'─'*60}")
180
+ print(f" Total sources registered : {total}")
181
+ print(f" New this run : {new_count}")
182
+ print(f" Duplicates skipped : {duplicate_count}")
183
+ print(f" Rights class = unknown : {unknown_rights} ← ACTION REQUIRED")
184
+ print(f"{'─'*60}\n")
185
+
186
+ if unknown_rights > 0:
187
+ print(" ⚠️ ACTION: Open manifest/source_manifest.csv and set")
188
+ print(" rights_class for each book before extraction.")
189
+ print(" Valid values: public-domain | licensed-owned |")
190
+ print(" controlled-internal | unknown | excluded\n")
191
+
192
+ # ── Rights breakdown table ────────────────────────────────────────────────
193
+ rights_counts = {}
194
+ for r in existing.values():
195
+ rc = r.get("rights_class", "unknown")
196
+ rights_counts[rc] = rights_counts.get(rc, 0) + 1
197
+
198
+ print(" Rights class breakdown:")
199
+ for rc, count in sorted(rights_counts.items()):
200
+ flag = " ← EXCLUDED FROM PROCESSING" if rc in ("unknown", "excluded") else ""
201
+ print(f" {rc:<25} {count}{flag}")
202
+ print()
203
+
204
+
205
+ def validate_manifest() -> bool:
206
+ """Validate all manifest records against JSON schema."""
207
+ try:
208
+ import jsonschema
209
+ except ImportError:
210
+ print("[skip] jsonschema not installed β€” skipping schema validation")
211
+ return True
212
+
213
+ with open(SCHEMA_FILE) as f:
214
+ schema = json.load(f)
215
+
216
+ records = load_manifest()
217
+ errors = []
218
+ for sha, rec in records.items():
219
+ # Convert numeric strings for validation
220
+ test_rec = dict(rec)
221
+ if test_rec.get("file_size_bytes"):
222
+ test_rec["file_size_bytes"] = int(test_rec["file_size_bytes"])
223
+ if test_rec.get("page_count"):
224
+ try:
225
+ test_rec["page_count"] = int(test_rec["page_count"])
226
+ except (ValueError, TypeError):
227
+ test_rec["page_count"] = None
228
+
229
+ try:
230
+ jsonschema.validate(test_rec, schema)
231
+ except jsonschema.ValidationError as e:
232
+ errors.append(f" {rec.get('book_id', sha[:8])}: {e.message}")
233
+
234
+ if errors:
235
+ print(f"\n βœ— Schema validation FAILED ({len(errors)} errors):")
236
+ for err in errors:
237
+ print(err)
238
+ return False
239
+ else:
240
+ print(f"\n βœ“ All {len(records)} manifest records pass schema validation.")
241
+ return True
242
+
243
+
244
+ # ── CLI ────────────────────────────────────────────────────────────────────────
245
+ if __name__ == "__main__":
246
+ parser = argparse.ArgumentParser(description="Smoke Signal β€” Stage 1: Source Registry")
247
+ parser.add_argument("--source-dir", type=Path, default=SOURCE_DIR,
248
+ help="Directory containing source PDFs")
249
+ parser.add_argument("--validate-only", action="store_true",
250
+ help="Only validate existing manifest, don't scan for new files")
251
+ args = parser.parse_args()
252
+
253
+ if args.validate_only:
254
+ ok = validate_manifest()
255
+ sys.exit(0 if ok else 1)
256
+ else:
257
+ register_sources(args.source_dir)
258
+ validate_manifest()