Pointf5ive commited on
Commit
bad810b
Β·
1 Parent(s): 46d8f34

Stages 9+10: Codex exporter and validation report

Browse files
smoke_signal/scripts/07_codex_exporter.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal β€” Stage 9: Codex Exporter
4
+ ========================================
5
+ Takes approved review decisions and exports clean, schema-valid
6
+ JSONL/CSV/Markdown packages ready for Codex ingestion.
7
+
8
+ Safety rules (non-negotiable):
9
+ - NEVER export unreviewed low-confidence text
10
+ - NEVER mix rights classes in the same export batch
11
+ - NEVER export quarantined or rejected pages
12
+ - ALWAYS link every exported line back to source PDF hash + page
13
+ - ALWAYS validate against schema before writing export
14
+
15
+ Output formats:
16
+ - codex_export.jsonl β€” primary machine-readable format
17
+ - codex_export.csv β€” human-readable review copy
18
+ - codex_proof.md β€” Markdown proof pack for QA
19
+
20
+ Usage:
21
+ python scripts/07_codex_exporter.py
22
+ python scripts/07_codex_exporter.py --book-id SS-BOOK-0001
23
+ python scripts/07_codex_exporter.py --batch-id SS-BATCH-001
24
+ python scripts/07_codex_exporter.py --dry-run
25
+ """
26
+
27
+ import argparse
28
+ import csv
29
+ import json
30
+ import sys
31
+ import time
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ # ── Paths ──────────────────────────────────────────────────────────────────────
37
+ ROOT = Path(__file__).resolve().parents[1]
38
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
39
+ REGIONS_DIR = ROOT / "regions"
40
+ CLEANED_DIR = ROOT / "regions" / "cleaned"
41
+ REVIEW_DIR = ROOT / "review"
42
+ EXPORTS_DIR = ROOT / "exports"
43
+ LOGS_DIR = ROOT / "logs"
44
+ SCHEMAS_DIR = ROOT / "schemas"
45
+
46
+ EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
47
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
48
+
49
+ DECISIONS_CSV = REVIEW_DIR / "review_decisions.csv"
50
+
51
+ # ── Config ────────────────────────────────────────────────────────────────────
52
+ CONFIG = {
53
+ "config_version": "ss_export_v0.1",
54
+ "schema_version": "1.0",
55
+ "approved_statuses": ["accepted", "edited"],
56
+ "excluded_statuses": ["rejected", "quarantined", "illustration-only"],
57
+ "excluded_region_classes": [
58
+ "page-number", "copyright-legal", "publisher-imprint", "decorative-uncertain"
59
+ ],
60
+ "story_region_classes": [
61
+ "narration", "dialogue-speech-bubble", "caption",
62
+ "title", "subtitle", "sign-label"
63
+ ],
64
+ "eligible_rights": ["public-domain", "licensed-owned", "controlled-internal"],
65
+ }
66
+
67
+ EXPORT_FIELDS = [
68
+ "book_id", "source_id", "source_hash", "page_number", "region_id",
69
+ "page_class", "region_class", "text_raw", "text_clean", "text_final",
70
+ "uncertain_words", "confidence", "review_status", "exclusion_reason",
71
+ "extraction_method", "config_version", "schema_version", "run_id",
72
+ ]
73
+
74
+
75
+ # ── Manifest I/O ───────────────────────────────────────────────────────────────
76
+ def load_manifest() -> dict:
77
+ records = {}
78
+ if not MANIFEST_CSV.exists():
79
+ return records
80
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
81
+ for row in csv.DictReader(f):
82
+ if row.get("book_id"):
83
+ records[row["book_id"]] = row
84
+ return records
85
+
86
+
87
+ def save_manifest(records: dict) -> None:
88
+ fields = [
89
+ "book_id", "source_id", "filename", "sha256", "file_size_bytes",
90
+ "page_count", "rights_class", "source_location", "acquisition_date",
91
+ "status", "allowed_use", "notes"
92
+ ]
93
+ rows = sorted(records.values(), key=lambda r: r.get("book_id", ""))
94
+ with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
95
+ writer = csv.DictWriter(f, fieldnames=fields)
96
+ writer.writeheader()
97
+ writer.writerows(rows)
98
+
99
+
100
+ # ── Load review decisions ─────────────────────────────────────────────────────
101
+ def load_decisions() -> dict:
102
+ decisions = {}
103
+ if not DECISIONS_CSV.exists():
104
+ return decisions
105
+ with open(DECISIONS_CSV, newline="", encoding="utf-8") as f:
106
+ for row in csv.DictReader(f):
107
+ decisions[row.get("region_id", "")] = row
108
+ return decisions
109
+
110
+
111
+ # ── Load region data ───────────────────────────────────────────────────────────
112
+ def load_regions(book_id: str) -> Optional[dict]:
113
+ """Load cleaned regions if available, otherwise ordered regions."""
114
+ cleaned = CLEANED_DIR / f"{book_id}_cleaned_regions.json"
115
+ if cleaned.exists():
116
+ return json.load(open(cleaned))
117
+ ordered = REGIONS_DIR / f"{book_id}_ordered_regions.json"
118
+ if ordered.exists():
119
+ return json.load(open(ordered))
120
+ return None
121
+
122
+
123
+ # ── Schema validator ──────────────────────────────────────────────────────────
124
+ def validate_record(record: dict) -> list:
125
+ """Basic validation β€” returns list of errors."""
126
+ errors = []
127
+ required = ["book_id", "source_hash", "page_number", "region_id",
128
+ "region_class", "text_final", "review_status", "run_id"]
129
+ for field in required:
130
+ if not record.get(field):
131
+ errors.append(f"missing_required_field: {field}")
132
+
133
+ if record.get("confidence") is not None:
134
+ try:
135
+ conf = float(record["confidence"])
136
+ if not (0.0 <= conf <= 1.0):
137
+ errors.append(f"confidence_out_of_range: {conf}")
138
+ except (ValueError, TypeError):
139
+ errors.append("confidence_not_numeric")
140
+
141
+ valid_statuses = CONFIG["approved_statuses"] + ["excluded"]
142
+ if record.get("review_status") not in valid_statuses:
143
+ errors.append(f"invalid_review_status: {record.get('review_status')}")
144
+
145
+ return errors
146
+
147
+
148
+ # ── Build export record ────────────────────────────────────────────────────────
149
+ def build_export_record(
150
+ region: dict,
151
+ page_data: dict,
152
+ manifest_record: dict,
153
+ decision: Optional[dict],
154
+ run_id: str,
155
+ ) -> Optional[dict]:
156
+ """
157
+ Build a single Codex export record from region + decision data.
158
+ Returns None if the record should be excluded.
159
+ """
160
+ region_class = region.get("region_class", "decorative-uncertain")
161
+ region_id = region.get("region_id", "")
162
+ page_num = region.get("page_number") or page_data.get("page_number")
163
+
164
+ # Exclude non-story regions
165
+ if region_class in CONFIG["excluded_region_classes"]:
166
+ return None
167
+
168
+ # Check decision status
169
+ if decision:
170
+ review_status = decision.get("status", "pending")
171
+ if review_status not in CONFIG["approved_statuses"]:
172
+ return None
173
+ text_final = decision.get("final_text") or region.get("text_clean") or region.get("text", "")
174
+ else:
175
+ # No decision yet β€” exclude from export
176
+ return None
177
+
178
+ # Determine extraction method
179
+ if region.get("llm_model"):
180
+ method = "ocr+llm"
181
+ elif region.get("text_clean") and region.get("text_clean") != region.get("text"):
182
+ method = "ocr+llm"
183
+ else:
184
+ method = "ocr"
185
+
186
+ if page_data.get("route") == "embedded_text":
187
+ method = "embedded-text"
188
+
189
+ record = {
190
+ "book_id": manifest_record.get("book_id", ""),
191
+ "source_id": manifest_record.get("source_id", ""),
192
+ "source_hash": manifest_record.get("sha256", ""),
193
+ "page_number": page_num,
194
+ "region_id": region_id,
195
+ "page_class": page_data.get("page_class", "story-page"),
196
+ "region_class": region_class,
197
+ "text_raw": region.get("text", ""),
198
+ "text_clean": region.get("text_clean") or region.get("text", ""),
199
+ "text_final": text_final.strip(),
200
+ "uncertain_words": json.dumps(region.get("uncertain_words", [])),
201
+ "confidence": round(float(region.get("confidence", 0)), 4),
202
+ "review_status": review_status,
203
+ "exclusion_reason": "",
204
+ "extraction_method": method,
205
+ "config_version": CONFIG["config_version"],
206
+ "schema_version": CONFIG["schema_version"],
207
+ "run_id": run_id,
208
+ }
209
+
210
+ return record
211
+
212
+
213
+ # ── Per-book export ───────────────────────────────────────────────────────────
214
+ def export_book(
215
+ manifest_record: dict,
216
+ decisions: dict,
217
+ run_id: str,
218
+ dry_run: bool,
219
+ ) -> tuple:
220
+ book_id = manifest_record["book_id"]
221
+ filename = manifest_record["filename"]
222
+ rights = manifest_record.get("rights_class", "unknown")
223
+ source_hash = manifest_record.get("sha256", "")
224
+
225
+ print(f"\n [{book_id}] {filename}")
226
+
227
+ # Rights check
228
+ if rights not in CONFIG["eligible_rights"]:
229
+ print(f" βœ— Skipped β€” rights_class={rights} not eligible for export")
230
+ return [], {"error": "rights_not_eligible"}
231
+
232
+ # Load region data
233
+ regions_data = load_regions(book_id)
234
+ if regions_data is None:
235
+ print(f" βœ— No region data found. Run earlier stages first.")
236
+ return [], {"error": "no_region_data"}
237
+
238
+ export_records = []
239
+ skipped = 0
240
+ validation_errors = []
241
+
242
+ for page_data in regions_data.get("pages", []):
243
+ page_num = page_data.get("page_number")
244
+ route = page_data.get("route", "ocr")
245
+
246
+ for region in page_data.get("regions", []):
247
+ region_id = region.get("region_id", "")
248
+ decision = decisions.get(region_id)
249
+
250
+ record = build_export_record(
251
+ region, page_data, manifest_record, decision, run_id
252
+ )
253
+
254
+ if record is None:
255
+ skipped += 1
256
+ continue
257
+
258
+ # Validate
259
+ errors = validate_record(record)
260
+ if errors:
261
+ validation_errors.append({
262
+ "region_id": region_id,
263
+ "errors": errors
264
+ })
265
+ print(f" ⚠️ Validation failed for {region_id}: {errors}")
266
+ continue
267
+
268
+ export_records.append(record)
269
+
270
+ print(f" Exported: {len(export_records)} records | Skipped: {skipped} | Validation errors: {len(validation_errors)}")
271
+
272
+ if validation_errors:
273
+ print(f" ⚠️ {len(validation_errors)} records failed validation β€” excluded from export")
274
+
275
+ return export_records, {}
276
+
277
+
278
+ # ── Write export files ────────────────────────────────────────────────────────
279
+ def write_exports(
280
+ all_records: list,
281
+ run_id: str,
282
+ batch_id: str,
283
+ dry_run: bool,
284
+ ) -> dict:
285
+ if not all_records:
286
+ print("\n No records to export.")
287
+ return {}
288
+
289
+ ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
290
+ batch_tag = batch_id or ts
291
+
292
+ # ── JSONL ────────────────────────────────────────────────────────────────
293
+ jsonl_path = EXPORTS_DIR / f"codex_export_{batch_tag}.jsonl"
294
+ if not dry_run:
295
+ with open(jsonl_path, "w", encoding="utf-8") as f:
296
+ for rec in all_records:
297
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
298
+ print(f"\n JSONL β†’ {jsonl_path.relative_to(ROOT)}")
299
+
300
+ # ── CSV ──────────────────────────────────────────────────────────────────
301
+ csv_path = EXPORTS_DIR / f"codex_export_{batch_tag}.csv"
302
+ if not dry_run:
303
+ with open(csv_path, "w", newline="", encoding="utf-8") as f:
304
+ writer = csv.DictWriter(f, fieldnames=EXPORT_FIELDS)
305
+ writer.writeheader()
306
+ writer.writerows(all_records)
307
+ print(f" CSV β†’ {csv_path.relative_to(ROOT)}")
308
+
309
+ # ── Markdown proof pack ──────────────────────────────────────────────────
310
+ md_path = EXPORTS_DIR / f"codex_proof_{batch_tag}.md"
311
+ if not dry_run:
312
+ # Group by book
313
+ by_book = {}
314
+ for rec in all_records:
315
+ bid = rec["book_id"]
316
+ by_book.setdefault(bid, []).append(rec)
317
+
318
+ lines = [
319
+ f"# Smoke Signal β€” Codex Export Proof Pack",
320
+ f"**Batch:** {batch_tag} ",
321
+ f"**Run ID:** {run_id} ",
322
+ f"**Exported:** {datetime.utcnow().isoformat()}Z ",
323
+ f"**Records:** {len(all_records)} ",
324
+ f"**Config:** {CONFIG['config_version']} ",
325
+ f"\n---\n",
326
+ ]
327
+
328
+ for bid, records in sorted(by_book.items()):
329
+ lines.append(f"## {bid}")
330
+ # Group by page
331
+ by_page = {}
332
+ for rec in records:
333
+ p = rec["page_number"]
334
+ by_page.setdefault(p, []).append(rec)
335
+
336
+ for page_num in sorted(by_page.keys()):
337
+ lines.append(f"\n### Page {page_num}")
338
+ for rec in by_page[page_num]:
339
+ status_icon = "βœ“" if rec["review_status"] == "accepted" else "✎"
340
+ lines.append(f"\n**[{status_icon} {rec['region_class']}]** `conf:{rec['confidence']:.0%}`")
341
+ lines.append(f"\n> {rec['text_final']}\n")
342
+ if rec.get("uncertain_words") and rec["uncertain_words"] != "[]":
343
+ lines.append(f"*Uncertain words: {rec['uncertain_words']}*\n")
344
+ lines.append("\n---\n")
345
+
346
+ with open(md_path, "w", encoding="utf-8") as f:
347
+ f.write("\n".join(lines))
348
+ print(f" MD β†’ {md_path.relative_to(ROOT)}")
349
+
350
+ # ── Export manifest ──────────────────────────────────────────────────────
351
+ manifest_path = EXPORTS_DIR / f"export_manifest_{batch_tag}.json"
352
+ export_manifest = {
353
+ "run_id": run_id,
354
+ "batch_id": batch_tag,
355
+ "exported_at": datetime.utcnow().isoformat() + "Z",
356
+ "config_version": CONFIG["config_version"],
357
+ "schema_version": CONFIG["schema_version"],
358
+ "record_count": len(all_records),
359
+ "books": list({r["book_id"] for r in all_records}),
360
+ "files": {
361
+ "jsonl": str(jsonl_path.relative_to(ROOT)) if not dry_run else None,
362
+ "csv": str(csv_path.relative_to(ROOT)) if not dry_run else None,
363
+ "proof": str(md_path.relative_to(ROOT)) if not dry_run else None,
364
+ }
365
+ }
366
+
367
+ if not dry_run:
368
+ with open(manifest_path, "w") as f:
369
+ json.dump(export_manifest, f, indent=2)
370
+ print(f" Manifest β†’ {manifest_path.relative_to(ROOT)}")
371
+
372
+ return export_manifest
373
+
374
+
375
+ # ── Main ───────────────────────────────────────────────────────────────────────
376
+ def main():
377
+ parser = argparse.ArgumentParser(description="Smoke Signal β€” Stage 9: Codex Exporter")
378
+ parser.add_argument("--book-id", help="Export a single book by ID")
379
+ parser.add_argument("--batch-id", help="Tag this export batch (e.g. SS-BATCH-001)")
380
+ parser.add_argument("--dry-run", action="store_true")
381
+ args = parser.parse_args()
382
+
383
+ run_id = f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
384
+ dry_run = args.dry_run
385
+
386
+ print(f"\n{'='*60}")
387
+ print(f" Smoke Signal β€” Stage 9: Codex Exporter")
388
+ print(f" Run ID : {run_id}")
389
+ print(f" Config : {CONFIG['config_version']}")
390
+ print(f" Schema : {CONFIG['schema_version']}")
391
+ if dry_run:
392
+ print(f" Mode : DRY RUN")
393
+ print(f"{'='*60}")
394
+
395
+ manifest = load_manifest()
396
+ decisions = load_decisions()
397
+
398
+ if not manifest:
399
+ print("\n Manifest empty. Run earlier stages first.")
400
+ sys.exit(1)
401
+
402
+ if not decisions:
403
+ print("\n No review decisions found.")
404
+ print(" Run the review workbench (06_review_workbench.py) first.")
405
+ sys.exit(1)
406
+
407
+ print(f"\n Review decisions loaded: {len(decisions)}")
408
+ approved = sum(1 for d in decisions.values() if d.get("status") in CONFIG["approved_statuses"])
409
+ print(f" Approved decisions: {approved}")
410
+
411
+ # Select books
412
+ if args.book_id:
413
+ books = [manifest[args.book_id]] if args.book_id in manifest else []
414
+ else:
415
+ # Export books that have region data
416
+ books = []
417
+ for bid, rec in manifest.items():
418
+ if load_regions(bid) is not None:
419
+ if rec.get("rights_class") in CONFIG["eligible_rights"]:
420
+ books.append(rec)
421
+
422
+ if not books:
423
+ print("\n No eligible books found for export.")
424
+ sys.exit(0)
425
+
426
+ print(f" Books to export: {len(books)}")
427
+
428
+ all_records = []
429
+ t_start = time.time()
430
+
431
+ for record in books:
432
+ records, error = export_book(record, decisions, run_id, dry_run)
433
+ all_records.extend(records)
434
+
435
+ # Write all exports
436
+ export_manifest = write_exports(all_records, run_id, args.batch_id, dry_run)
437
+
438
+ # Update manifest status to exported
439
+ if not dry_run and all_records:
440
+ exported_books = {r["book_id"] for r in all_records}
441
+ for bid in exported_books:
442
+ if bid in manifest:
443
+ manifest[bid]["status"] = "exported"
444
+ save_manifest(manifest)
445
+
446
+ elapsed = round(time.time() - t_start, 1)
447
+
448
+ print(f"\n{'─'*60}")
449
+ print(f" Total records exported : {len(all_records)}")
450
+ print(f" Time : {elapsed}s")
451
+ print(f"{'─'*60}")
452
+ print(f"\n βœ“ Export complete.")
453
+ print(f" Next: Run validation report (Stage 10) before Codex ingestion.\n")
454
+
455
+
456
+ if __name__ == "__main__":
457
+ main()
smoke_signal/scripts/08_validation_report.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal β€” Stage 10: Validation Report
4
+ ============================================
5
+ Generates a full pipeline validation report before Codex ingestion.
6
+
7
+ Checks:
8
+ - Source manifest completeness and hash integrity
9
+ - Page routing accuracy (spot check)
10
+ - OCR confidence distribution
11
+ - Review queue clearance rate
12
+ - Export schema validity
13
+ - Contamination risk (low-confidence text in export)
14
+ - Rights class separation
15
+ - Page traceability (every exported line has source evidence)
16
+
17
+ Outputs:
18
+ - reports/validation_report_<batch>.md β€” human-readable
19
+ - reports/validation_report_<batch>.json β€” machine-readable
20
+
21
+ Usage:
22
+ python scripts/08_validation_report.py
23
+ python scripts/08_validation_report.py --batch-id SS-BATCH-001
24
+ python scripts/08_validation_report.py --export-file exports/codex_export_20260516.jsonl
25
+ """
26
+
27
+ import argparse
28
+ import csv
29
+ import json
30
+ import sys
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+
34
+ # ── Paths ──────────────────────────────────────────────────────────────────────
35
+ ROOT = Path(__file__).resolve().parents[1]
36
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
37
+ EXPORTS_DIR = ROOT / "exports"
38
+ REGIONS_DIR = ROOT / "regions"
39
+ OCR_RAW_DIR = ROOT / "ocr_raw"
40
+ REVIEW_DIR = ROOT / "review"
41
+ REPORTS_DIR = ROOT / "reports"
42
+ LOGS_DIR = ROOT / "logs"
43
+
44
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
45
+ DECISIONS_CSV = REVIEW_DIR / "review_decisions.csv"
46
+ QUEUE_CSV = REVIEW_DIR / "review_queue.csv"
47
+
48
+ CONFIG = {
49
+ "config_version": "ss_validate_v0.1",
50
+ "min_page_route_accuracy": 0.95,
51
+ "min_ocr_word_accuracy": 0.80,
52
+ "min_review_clearance": 0.90,
53
+ "max_contamination_risk": 0.00,
54
+ "confidence_contamination_threshold": 0.60,
55
+ }
56
+
57
+
58
+ # ── Loaders ────────────────────────────────────────────────────────────────────
59
+ def load_manifest() -> list:
60
+ if not MANIFEST_CSV.exists():
61
+ return []
62
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
63
+ return list(csv.DictReader(f))
64
+
65
+
66
+ def load_queue() -> list:
67
+ if not QUEUE_CSV.exists():
68
+ return []
69
+ with open(QUEUE_CSV, newline="", encoding="utf-8") as f:
70
+ return list(csv.DictReader(f))
71
+
72
+
73
+ def load_decisions() -> list:
74
+ if not DECISIONS_CSV.exists():
75
+ return []
76
+ with open(DECISIONS_CSV, newline="", encoding="utf-8") as f:
77
+ return list(csv.DictReader(f))
78
+
79
+
80
+ def load_export(export_file: Path) -> list:
81
+ records = []
82
+ if not export_file.exists():
83
+ return records
84
+ with open(export_file, encoding="utf-8") as f:
85
+ for line in f:
86
+ line = line.strip()
87
+ if line:
88
+ try:
89
+ records.append(json.loads(line))
90
+ except json.JSONDecodeError:
91
+ pass
92
+ return records
93
+
94
+
95
+ # ── Check functions ────────────────────────────────────────────────────────────
96
+ def check_manifest(manifest: list) -> dict:
97
+ total = len(manifest)
98
+ unknown_rights = sum(1 for r in manifest if r.get("rights_class") == "unknown")
99
+ excluded = sum(1 for r in manifest if r.get("rights_class") == "excluded")
100
+ no_hash = sum(1 for r in manifest if not r.get("sha256"))
101
+ status_counts = {}
102
+ for r in manifest:
103
+ s = r.get("status", "unknown")
104
+ status_counts[s] = status_counts.get(s, 0) + 1
105
+
106
+ issues = []
107
+ if unknown_rights > 0:
108
+ issues.append(f"{unknown_rights} sources have unknown rights class")
109
+ if no_hash > 0:
110
+ issues.append(f"{no_hash} sources missing SHA-256 hash")
111
+
112
+ return {
113
+ "check": "source_manifest",
114
+ "total_sources": total,
115
+ "unknown_rights": unknown_rights,
116
+ "excluded": excluded,
117
+ "no_hash": no_hash,
118
+ "status_counts": status_counts,
119
+ "issues": issues,
120
+ "passed": len(issues) == 0,
121
+ }
122
+
123
+
124
+ def check_review_clearance(queue: list, decisions: list) -> dict:
125
+ total_queued = len(queue)
126
+ decided_ids = {d.get("region_id") for d in decisions}
127
+ queue_ids = {q.get("region_id") for q in queue}
128
+ cleared = len(queue_ids & decided_ids)
129
+ pending = total_queued - cleared
130
+ clearance_rate = cleared / max(total_queued, 1)
131
+
132
+ approved = sum(1 for d in decisions if d.get("status") in ("accepted", "edited"))
133
+ rejected = sum(1 for d in decisions if d.get("status") == "rejected")
134
+ quarantined = sum(1 for d in decisions if d.get("status") == "quarantined")
135
+
136
+ passed = clearance_rate >= CONFIG["min_review_clearance"]
137
+ issues = []
138
+ if pending > 0:
139
+ issues.append(f"{pending} items still pending review ({clearance_rate:.0%} cleared)")
140
+
141
+ return {
142
+ "check": "review_clearance",
143
+ "total_queued": total_queued,
144
+ "cleared": cleared,
145
+ "pending": pending,
146
+ "clearance_rate": round(clearance_rate, 3),
147
+ "approved": approved,
148
+ "rejected": rejected,
149
+ "quarantined": quarantined,
150
+ "target": CONFIG["min_review_clearance"],
151
+ "issues": issues,
152
+ "passed": passed,
153
+ }
154
+
155
+
156
+ def check_export_contamination(export_records: list) -> dict:
157
+ """Check for low-confidence text that shouldn't be in export."""
158
+ threshold = CONFIG["confidence_contamination_threshold"]
159
+ risky = []
160
+
161
+ for rec in export_records:
162
+ conf = float(rec.get("confidence", 1.0))
163
+ status = rec.get("review_status", "")
164
+ if conf < threshold and status == "accepted":
165
+ risky.append({
166
+ "region_id": rec.get("region_id"),
167
+ "confidence": conf,
168
+ "review_status": status,
169
+ })
170
+
171
+ contamination_risk = len(risky) / max(len(export_records), 1)
172
+ passed = contamination_risk <= CONFIG["max_contamination_risk"]
173
+
174
+ issues = []
175
+ if risky:
176
+ issues.append(f"{len(risky)} low-confidence records in export (conf < {threshold})")
177
+
178
+ return {
179
+ "check": "contamination_risk",
180
+ "total_export_records": len(export_records),
181
+ "risky_records": len(risky),
182
+ "contamination_rate": round(contamination_risk, 4),
183
+ "threshold": threshold,
184
+ "risky_sample": risky[:5],
185
+ "issues": issues,
186
+ "passed": passed,
187
+ }
188
+
189
+
190
+ def check_traceability(export_records: list) -> dict:
191
+ """Every exported record must have source_hash, page_number, region_id, run_id."""
192
+ missing_trace = []
193
+ for rec in export_records:
194
+ missing = [f for f in ("source_hash", "page_number", "region_id", "run_id")
195
+ if not rec.get(f)]
196
+ if missing:
197
+ missing_trace.append({
198
+ "region_id": rec.get("region_id", "unknown"),
199
+ "missing_fields": missing
200
+ })
201
+
202
+ passed = len(missing_trace) == 0
203
+ issues = []
204
+ if missing_trace:
205
+ issues.append(f"{len(missing_trace)} records missing traceability fields")
206
+
207
+ return {
208
+ "check": "traceability",
209
+ "total_records": len(export_records),
210
+ "untraceable_records": len(missing_trace),
211
+ "sample": missing_trace[:5],
212
+ "issues": issues,
213
+ "passed": passed,
214
+ }
215
+
216
+
217
+ def check_rights_separation(export_records: list, manifest: list) -> dict:
218
+ """Confirm rights classes are not mixed in export."""
219
+ manifest_by_id = {r.get("book_id"): r for r in manifest}
220
+ rights_in_export = set()
221
+ unknown_in_export = []
222
+
223
+ for rec in export_records:
224
+ bid = rec.get("book_id")
225
+ m = manifest_by_id.get(bid, {})
226
+ rc = m.get("rights_class", "unknown")
227
+ rights_in_export.add(rc)
228
+ if rc in ("unknown", "excluded"):
229
+ unknown_in_export.append(bid)
230
+
231
+ issues = []
232
+ if unknown_in_export:
233
+ issues.append(f"Unknown/excluded rights class found in export: {set(unknown_in_export)}")
234
+
235
+ return {
236
+ "check": "rights_separation",
237
+ "rights_classes_found": list(rights_in_export),
238
+ "unknown_in_export": list(set(unknown_in_export)),
239
+ "issues": issues,
240
+ "passed": len(unknown_in_export) == 0,
241
+ }
242
+
243
+
244
+ def check_ocr_confidence_distribution(queue: list) -> dict:
245
+ """Summarise OCR confidence distribution across reviewed pages."""
246
+ confs = []
247
+ for item in queue:
248
+ try:
249
+ confs.append(float(item.get("confidence", 0)))
250
+ except (ValueError, TypeError):
251
+ pass
252
+
253
+ if not confs:
254
+ return {"check": "ocr_confidence", "passed": True, "note": "no_data"}
255
+
256
+ confs.sort()
257
+ n = len(confs)
258
+ mean = sum(confs) / n
259
+ below60 = sum(1 for c in confs if c < 0.60)
260
+ below80 = sum(1 for c in confs if c < 0.80)
261
+
262
+ return {
263
+ "check": "ocr_confidence_distribution",
264
+ "page_count": n,
265
+ "mean": round(mean, 3),
266
+ "min": round(confs[0], 3),
267
+ "max": round(confs[-1], 3),
268
+ "p25": round(confs[n // 4], 3),
269
+ "p50": round(confs[n // 2], 3),
270
+ "p75": round(confs[3 * n // 4], 3),
271
+ "below_0.60": below60,
272
+ "below_0.80": below80,
273
+ "passed": True,
274
+ }
275
+
276
+
277
+ # ── Report writer ───────────────────────────────────────────────────────��─────
278
+ def write_report(checks: list, batch_tag: str, export_path: Path) -> Path:
279
+ all_passed = all(c.get("passed", False) for c in checks)
280
+ issues_all = [i for c in checks for i in c.get("issues", [])]
281
+ verdict = "βœ… PASS β€” Safe to proceed to Codex ingestion" if all_passed else "❌ FAIL β€” Do not ingest until issues resolved"
282
+
283
+ # ── JSON ─────────────────────────────────────────────────────────────────
284
+ json_path = REPORTS_DIR / f"validation_report_{batch_tag}.json"
285
+ report_data = {
286
+ "batch_id": batch_tag,
287
+ "generated_at": datetime.utcnow().isoformat() + "Z",
288
+ "config_version": CONFIG["config_version"],
289
+ "export_file": str(export_path),
290
+ "verdict": "PASS" if all_passed else "FAIL",
291
+ "all_issues": issues_all,
292
+ "checks": checks,
293
+ }
294
+ with open(json_path, "w") as f:
295
+ json.dump(report_data, f, indent=2)
296
+
297
+ # ── Markdown ──────────────────────────────────────────────────────────────
298
+ md_path = REPORTS_DIR / f"validation_report_{batch_tag}.md"
299
+ lines = [
300
+ f"# Smoke Signal β€” Validation Report",
301
+ f"**Batch:** {batch_tag} ",
302
+ f"**Generated:** {datetime.utcnow().isoformat()}Z ",
303
+ f"**Export:** `{export_path.name}` ",
304
+ f"\n## Verdict\n",
305
+ f"### {verdict}\n",
306
+ ]
307
+
308
+ if issues_all:
309
+ lines.append("## Issues to Resolve\n")
310
+ for issue in issues_all:
311
+ lines.append(f"- ⚠️ {issue}")
312
+ lines.append("")
313
+
314
+ lines.append("## Check Results\n")
315
+ lines.append("| Check | Status | Notes |")
316
+ lines.append("|-------|--------|-------|")
317
+
318
+ for c in checks:
319
+ icon = "βœ…" if c.get("passed") else "❌"
320
+ name = c.get("check", "unknown").replace("_", " ").title()
321
+ notes = "; ".join(c.get("issues", [])) or "OK"
322
+ lines.append(f"| {name} | {icon} | {notes} |")
323
+
324
+ lines.append("\n## Detailed Results\n")
325
+ for c in checks:
326
+ lines.append(f"### {c.get('check','').replace('_',' ').title()}")
327
+ for k, v in c.items():
328
+ if k not in ("check", "issues", "passed", "sample", "risky_sample"):
329
+ lines.append(f"- **{k}:** {v}")
330
+ lines.append("")
331
+
332
+ lines.append("---")
333
+ lines.append(f"*Smoke Signal {CONFIG['config_version']} Β· Generated {datetime.utcnow().date()}*")
334
+
335
+ with open(md_path, "w", encoding="utf-8") as f:
336
+ f.write("\n".join(lines))
337
+
338
+ return md_path, json_path, all_passed
339
+
340
+
341
+ # ── Main ───────────────────────────────────────────────────────────────────────
342
+ def main():
343
+ parser = argparse.ArgumentParser(description="Smoke Signal β€” Stage 10: Validation Report")
344
+ parser.add_argument("--batch-id", help="Batch ID tag")
345
+ parser.add_argument("--export-file", help="Path to JSONL export file to validate")
346
+ args = parser.parse_args()
347
+
348
+ batch_tag = args.batch_id or datetime.utcnow().strftime("%Y%m%d_%H%M%S")
349
+
350
+ print(f"\n{'='*60}")
351
+ print(f" Smoke Signal β€” Stage 10: Validation Report")
352
+ print(f" Batch : {batch_tag}")
353
+ print(f" Config : {CONFIG['config_version']}")
354
+ print(f"{'='*60}\n")
355
+
356
+ # Find export file
357
+ if args.export_file:
358
+ export_path = Path(args.export_file)
359
+ else:
360
+ jsonl_files = sorted(EXPORTS_DIR.glob("codex_export_*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
361
+ if not jsonl_files:
362
+ print(" No export files found. Run 07_codex_exporter.py first.")
363
+ sys.exit(1)
364
+ export_path = jsonl_files[0]
365
+ print(f" Using latest export: {export_path.name}\n")
366
+
367
+ # Load data
368
+ manifest = load_manifest()
369
+ queue = load_queue()
370
+ decisions = load_decisions()
371
+ export_records = load_export(export_path)
372
+
373
+ print(f" Sources in manifest : {len(manifest)}")
374
+ print(f" Review queue items : {len(queue)}")
375
+ print(f" Review decisions : {len(decisions)}")
376
+ print(f" Export records : {len(export_records)}\n")
377
+
378
+ # Run checks
379
+ checks = [
380
+ check_manifest(manifest),
381
+ check_review_clearance(queue, decisions),
382
+ check_ocr_confidence_distribution(queue),
383
+ check_export_contamination(export_records),
384
+ check_traceability(export_records),
385
+ check_rights_separation(export_records, manifest),
386
+ ]
387
+
388
+ for c in checks:
389
+ icon = "βœ…" if c.get("passed") else "❌"
390
+ print(f" {icon} {c['check']}")
391
+ for issue in c.get("issues", []):
392
+ print(f" ⚠️ {issue}")
393
+
394
+ # Write reports
395
+ md_path, json_path, all_passed = write_report(checks, batch_tag, export_path)
396
+
397
+ print(f"\n{'─'*60}")
398
+ print(f" Report MD β†’ {md_path.relative_to(ROOT)}")
399
+ print(f" Report JSON β†’ {json_path.relative_to(ROOT)}")
400
+ print(f"{'─'*60}")
401
+
402
+ if all_passed:
403
+ print(f"\n βœ… ALL CHECKS PASSED β€” Approval Gate E cleared.")
404
+ print(f" Safe to ingest into Codex.\n")
405
+ else:
406
+ print(f"\n ❌ CHECKS FAILED β€” Do not ingest until issues are resolved.")
407
+ print(f" Fix the issues above and re-run this report.\n")
408
+ sys.exit(1)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()