Search / ocr_release.py
vomebook's picture
Show VoiceOfML OCR origin beside CCRD and CW
97c7a19 verified
Raw History Blame Contribute Delete
15.2 kB
"""Strict, startup-pinned OCR text/index releases. No network, pointers or deletion."""
from __future__ import annotations
from collections import defaultdict
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import sqlite3
import tempfile
import unicodedata
BUCKET = "vomebook/ccrd-index"
TOKENIZER = "cjk-bigram-boundary-fts5-v6-snippet-anchors"
SOURCE = "OCR"
SOURCE_NAME = "VoiceOfML"
def canonical(value) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode()
def digest(value) -> str:
return hashlib.sha256(value).hexdigest()
def sha(value) -> str:
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value):
raise ValueError("invalid release SHA-256")
return value
def safe_path(value: str) -> str:
if (not isinstance(value, str) or not value or value.startswith("/") or "\\" in value
or any(part in {"", ".", ".."} for part in value.split("/"))
or any(ord(char) < 32 for char in value)):
raise ValueError("invalid release relative path")
return value
def read_resource(root: Path, ref: dict) -> bytes:
if not isinstance(ref, dict) or ref.get("bucket") != BUCKET:
raise ValueError("wrong OCR release bucket")
path = safe_path(ref.get("path"))
if not path.startswith(("text/objects/", "text/exports/", "indexes/ocr/")):
raise ValueError("unmanaged OCR resource path")
expected = sha(ref.get("sha256"))
size = ref.get("bytes")
if type(size) is not int or size < 1:
raise ValueError("invalid OCR resource size")
root = root.resolve()
target = (root / path).resolve()
if root not in target.parents or not target.is_file():
raise ValueError("missing OCR release resource or path escape")
raw = target.read_bytes()
if len(raw) != size or digest(raw) != expected:
raise ValueError("OCR release resource checksum mismatch")
return raw
def export_record(root: Path, ref: dict) -> tuple[dict, dict]:
export = json.loads(read_resource(root, ref))
if (export.get("kind") != "ccrd-ocr-text-export" or export.get("version") != 1
or export.get("complete") is not True or export.get("recipe") != "ocr-plain-utf8-pages-v1"):
raise ValueError("unsupported OCR export schema")
if not re.fullmatch(r"OCR:[1-9][0-9]*", str(export.get("doc_id", ""))) or int(export["doc_id"].split(":")[1]) >= 1 << 63:
raise ValueError("invalid OCR document identity")
repo, original = export.get("origin_repo"), safe_path(export.get("origin_rel_path"))
if not isinstance(repo, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*", repo):
raise ValueError("invalid OCR origin repository")
if not re.fullmatch(r"[0-9a-f]{40}", str(export.get("origin_revision", ""))):
raise ValueError("OCR origin must be commit-pinned")
readable = safe_path(export.get("readable_rel_path"))
expected_path = repo + "/" + str(PurePosixPath(original).with_suffix(".txt"))
if readable != expected_path:
raise ValueError("OCR readable path does not preserve its origin")
raw = read_resource(root, export.get("text"))
body = raw.decode("utf-8", errors="strict")
if body.startswith("\ufeff"):
raise ValueError("plain OCR export must not hide a BOM in its indexed text")
identity = {key: export[key] for key in ("recipe", "origin_repo", "origin_rel_path", "origin_revision", "origin_sha256",
"source_sha256", "doc_id", "text_generation", "txt_sha256")}
if export.get("source_sha256_role") != "reader-primary":
raise ValueError("OCR source/derived digest roles must be explicit")
for field in ("origin_sha256", "source_sha256", "text_generation", "txt_sha256", "generation", "book_id"):
sha(export.get(field))
generation = digest(canonical(identity).rstrip(b"\n"))
if (generation != export["generation"] or digest(raw) != export["txt_sha256"]
or export["book_id"] != digest((repo + "\0" + original).encode())):
raise ValueError("OCR export generation mismatch")
pages, count = export.get("pages"), export.get("page_count")
if type(count) is not int or count < 1 or not isinstance(pages, list) or len(pages) != count:
raise ValueError("OCR export is missing pages")
if export.get("offset_unit") != "unicode-codepoint" or export.get("page_separator") != "\n\n":
raise ValueError("unsupported page offset recipe")
cursor, reviewed = 0, 0
for number, page in enumerate(pages, 1):
start, end = page.get("start"), page.get("end")
if (page.get("page") != number or type(start) is not int or type(end) is not int
or start != cursor or not start <= end <= len(body)):
raise ValueError("OCR page order/offset mismatch")
sha(page.get("text_generation"))
if digest(body[start:end].encode()) != sha(page.get("text_sha256")):
raise ValueError("OCR page text digest mismatch")
if page.get("quality") not in {"unreviewed", "partially-reviewed", "accepted"}:
raise ValueError("invalid OCR page quality")
reviewed += page["quality"] != "unreviewed"
cursor = end
if number < count:
if body[cursor:cursor + 2] != "\n\n":
raise ValueError("OCR page separator mismatch")
cursor += 2
if cursor != len(body) or export.get("accepted_pages") != reviewed or export.get("quality") != ("partially-reviewed" if reviewed else "unreviewed"):
raise ValueError("OCR whole-book coverage/quality mismatch")
resources = export.get("retained_reader_resources")
if not isinstance(resources, list) or not resources:
raise ValueError("OCR provenance retention references are missing")
for resource in resources:
if resource.get("bucket") not in {"vomebook/reader-assets-v2", "vomebook/pdf-pages-v2", "melsm/pdf-archive-v2"}:
raise ValueError("unknown retained Reader bucket")
safe_path(resource.get("path")); sha(resource.get("sha256"))
if type(resource.get("bytes")) is not int or resource["bytes"] < 1:
raise ValueError("invalid retained Reader resource")
path = PurePosixPath(readable)
record = {"doc_id": export["doc_id"], "source": SOURCE, "source_name": SOURCE_NAME,
"display_name": path.stem, "display_dirs": list(path.parts[:-1]), "display_rel_path": readable,
"extension": "txt", "size": len(raw), "has_preview": True, "has_fulltext": True,
"storage_root": ".", "storage_rel_path": export["text"]["path"],
"text_generation": export["text_generation"], "text_quality": export["quality"]}
return record, export
def directory_payloads(records: list[dict]) -> tuple[list[dict], dict]:
browser = defaultdict(lambda: {"folders": [], "files": []})
counts, direct = defaultdict(int), defaultdict(list)
children = defaultdict(set)
for record in records:
parts = record["display_dirs"]
for depth in range(len(parts) + 1):
path = "/".join(parts[:depth]); counts[path] += 1
if depth < len(parts): children[path].add(parts[depth])
direct["/".join(parts)].append(record)
for path in counts:
browser[path]["folders"] = [{"name": name, "path": (path + "/" if path else "") + name,
"count": counts[(path + "/" if path else "") + name]} for name in sorted(children[path])]
browser[path]["files"] = [{"name": item["display_name"], "ext": "txt", "size": item["size"],
"doc_id": item["doc_id"]} for item in sorted(direct[path], key=lambda item: (item["display_name"], item["doc_id"]))]
def node(name, path):
return {"name": name, "path": path, "count": counts[path], "hasDirectFiles": bool(direct[path]),
"hasChildren": bool(children[path]), "showSelfToggle": bool(path and direct[path] and children[path]),
"children": [node(child, (path + "/" if path else "") + child) for child in sorted(children[path])]}
# The source slug identifies this collection. Do not add a synthetic
# `OCR成果/` directory above the preserved origin hierarchy; VoiceOfML,
# or any other source owner, is the actual top-level folder.
return [node(child, child) for child in sorted(children[""])], dict(browser)
def zip_member(record: dict) -> str:
"""Keep legal origin hierarchy; bound non-portable components deterministically."""
replacements = {":": ":", "*": "*", "?": "?", '"': """, "<": "<", ">": ">", "|": "|", "\\": "\"}
def component(original):
value = re.sub(r'[:*?"<>|\\\x00-\x1f]', lambda match: replacements.get(match[0], "_"), original).rstrip(" .") or "_"
if value != original or len(value.encode()) > 180:
suffix = "-" + digest(original.encode())[:12]
raw = value.encode()[:180 - len(suffix)]
value = raw.decode("utf-8", errors="ignore").rstrip(" .") + suffix
return value
parts = safe_path(record["display_rel_path"]).split("/")
parts[-1] = component(PurePosixPath(parts[-1]).stem) + ".txt"
return "/".join([component(part) for part in parts])
def validate_database(path: Path, records: list[dict], text_root: Path) -> None:
with sqlite3.connect(f"file:{path.resolve()}?mode=ro&immutable=1", uri=True) as db:
if db.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("invalid OCR SQLite integrity")
version = db.execute("SELECT value FROM metadata WHERE key='tokenizer_version'").fetchone()
if version != (TOKENIZER,):
raise ValueError("OCR tokenizer mismatch")
ids = {row[0] for row in db.execute("SELECT doc_id FROM documents")}
if ids != {record["doc_id"] for record in records}:
raise ValueError("OCR metadata/database ID set mismatch")
if db.execute("SELECT COUNT(*) FROM content_fts").fetchone()[0] != len(records):
raise ValueError("OCR content rows mismatch")
for record in records:
body = (text_root / record["storage_rel_path"]).read_text(encoding="utf-8", errors="strict")
normalized = unicodedata.normalize("NFKC", body).casefold().replace("\0", " ")
row = db.execute("SELECT content FROM content_fts WHERE rowid=?", (int(record["doc_id"].split(":")[1]),)).fetchone()
if row != (normalized,):
raise ValueError("OCR indexed content differs from readable TXT")
columns = {row[1] for row in db.execute("PRAGMA table_info(snippet_anchors)")}
if not {"token_hash", "doc_number", "char_offset"} <= columns:
raise ValueError("OCR snippet anchor schema missing")
def load_release(bucket: Path, manifest_path: str, expected_sha256: str, base_metadata_sha256: str) -> dict:
safe_path(manifest_path); sha(expected_sha256); sha(base_metadata_sha256)
bucket = bucket.resolve()
target = (bucket / manifest_path).resolve()
if bucket not in target.parents or not manifest_path.startswith("releases/ocr-"):
raise ValueError("OCR release path outside immutable release namespace")
raw = target.read_bytes()
if digest(raw) != expected_sha256:
raise ValueError("OCR release manifest checksum mismatch")
manifest = json.loads(raw)
if (manifest.get("kind") != "ccrd-ocr-search-release" or manifest.get("version") != 1
or manifest.get("tokenizer_version") != TOKENIZER or manifest.get("base_metadata_sha256") != base_metadata_sha256):
raise ValueError("incompatible OCR release/base snapshot")
sha(manifest.get("generation")); sha(manifest.get("builder_sha256"))
identity = {key: manifest[key] for key in ("version", "kind", "base_metadata_sha256", "builder_sha256", "tokenizer_version", "exports")}
if (manifest["generation"] != digest(canonical(identity))
or manifest_path != f'releases/ocr-{manifest["generation"]}/manifest.json'):
raise ValueError("OCR release identity/path mismatch")
refs = manifest.get("exports")
if not isinstance(refs, list) or not refs:
raise ValueError("empty OCR release")
records, exports = [], []
ids, paths = set(), set()
for ref in refs:
record, export = export_record(bucket, ref)
if record["doc_id"] in ids or record["display_rel_path"] in paths:
raise ValueError("OCR ID/readable-path collision; no silent overwrite")
ids.add(record["doc_id"]); paths.add(record["display_rel_path"])
records.append(record); exports.append(export)
if manifest.get("documents") != len(records):
raise ValueError("OCR release count mismatch")
database = manifest.get("database")
read_resource(bucket, database)
db_path = (bucket / database["path"]).resolve()
if database["path"] != f'indexes/ocr/{manifest["generation"]}/OCR.sqlite3':
raise ValueError("OCR database must retain its source namespace")
validate_database(db_path, records, bucket)
tree, browser = directory_payloads(records)
return {"manifest": manifest, "records": records, "exports": exports, "database": db_path,
"text_paths": {record["doc_id"]: bucket / record["storage_rel_path"] for record in records},
"tree": tree, "browser": browser}
def pin_release(bucket: Path, manifest_path: str, expected_sha256: str,
base_metadata_sha256: str, cache: Path) -> dict:
"""Verify the whole candidate before pinning a process-local immutable copy."""
candidate = load_release(bucket, manifest_path, expected_sha256, base_metadata_sha256)
cache.mkdir(parents=True, exist_ok=True)
destination = cache / candidate["manifest"]["generation"]
if destination.exists():
return load_release(destination, manifest_path, expected_sha256, base_metadata_sha256)
stage = Path(tempfile.mkdtemp(prefix="ocr-stage-", dir=cache))
try:
refs = [*candidate["manifest"]["exports"], candidate["manifest"]["database"],
*[export["text"] for export in candidate["exports"]]]
for ref in refs:
raw = read_resource(bucket, ref)
target = stage / ref["path"]
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("wb") as output:
output.write(raw); output.flush(); os.fsync(output.fileno())
target = stage / manifest_path
target.parent.mkdir(parents=True, exist_ok=True)
manifest_raw = (bucket / manifest_path).read_bytes()
if digest(manifest_raw) != expected_sha256:
raise ValueError("OCR candidate changed while being copied")
target.write_bytes(manifest_raw)
load_release(stage, manifest_path, expected_sha256, base_metadata_sha256)
stage.rename(destination)
finally:
if stage.exists():
shutil.rmtree(stage)
return load_release(destination, manifest_path, expected_sha256, base_metadata_sha256)