Spaces:
Sleeping
Sleeping
Upload 13 files
Browse files- smoke_signal/.gitignore +34 -0
- smoke_signal/README.md +54 -0
- smoke_signal/README_HF.md +15 -0
- smoke_signal/app.py +45 -0
- smoke_signal/manifest/PROJECT_CHARTER.md +82 -0
- smoke_signal/manifest/decision_log.csv +1 -0
- smoke_signal/manifest/issue_log.csv +1 -0
- smoke_signal/manifest/run_log.csv +1 -0
- smoke_signal/manifest/source_manifest.csv +1 -0
- smoke_signal/requirements.txt +35 -0
- smoke_signal/schemas/schema_book_manifest_v1.json +68 -0
- smoke_signal/schemas/schema_codex_export_v1.json +52 -0
- smoke_signal/scripts/01_register_sources.py +258 -0
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()
|