Files
Jyotisha/scripts/character_level_inventory_manifest.py
2026-07-08 17:03:04 +08:00

1326 lines
52 KiB
Python

#!/usr/bin/env python3
"""Build a lightweight character-level inventory manifest for Jyotish sources.
This is an index and governance manifest, not a semantic promotion pass. It
hashes every in-scope file, extracts text metadata when cheap, and queues heavy
PDF/image/document extraction instead of running expensive OCR by default.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import zipfile
from datetime import datetime, timezone
from xml.etree import ElementTree
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REPORT_JSON = ROOT / "docs" / "research" / "character_level_inventory_manifest_latest.json"
REPORT_MD = ROOT / "docs" / "research" / "character_level_inventory_manifest_latest.md"
EXTERNAL_REPORT_JSON = ROOT / "docs" / "research" / "character_level_external_manifest_latest.json"
EXTERNAL_REPORT_MD = ROOT / "docs" / "research" / "character_level_external_manifest_latest.md"
EXTRACTION_QUEUE_JSON = ROOT / "docs" / "research" / "character_level_extraction_queue_latest.json"
EXTRACTION_QUEUE_MD = ROOT / "docs" / "research" / "character_level_extraction_queue_latest.md"
EXTRACTION_RESULTS_JSON = ROOT / "docs" / "research" / "character_level_extraction_results_latest.json"
EXTRACTION_RESULTS_MD = ROOT / "docs" / "research" / "character_level_extraction_results_latest.md"
EXTRACTION_REVIEW_JSON = ROOT / "docs" / "research" / "character_level_extraction_review_latest.json"
EXTRACTION_REVIEW_MD = ROOT / "docs" / "research" / "character_level_extraction_review_latest.md"
SOURCE_GRADING_BATCH1_JSON = ROOT / "docs" / "research" / "character_level_source_grading_batch1_latest.json"
SOURCE_GRADING_BATCH1_MD = ROOT / "docs" / "research" / "character_level_source_grading_batch1_latest.md"
VISION_OCR_SOURCE = r'''
import Foundation
import Vision
import AppKit
let args = CommandLine.arguments
if args.count < 2 {
fputs("usage: vision_ocr image\n", stderr)
exit(2)
}
let url = URL(fileURLWithPath: args[1])
guard let image = NSImage(contentsOf: url), let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff), let cgImage = bitmap.cgImage else {
fputs("cannot load image\n", stderr)
exit(3)
}
let request = VNRecognizeTextRequest { request, error in
if let error = error {
fputs("vision error: \(error)\n", stderr)
exit(4)
}
let observations = request.results as? [VNRecognizedTextObservation] ?? []
for obs in observations {
if let top = obs.topCandidates(1).first {
print(top.string)
}
}
}
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
if #available(macOS 11.0, *) {
request.recognitionLanguages = ["zh-Hans", "en-US"]
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
fputs("perform error: \(error)\n", stderr)
exit(5)
}
'''
PROJECT_SCAN_ROOTS = [
"references",
"references/open_source_sources",
"docs/research",
"SKILL.md",
"AGENTS.md",
]
EXTERNAL_SCAN_ROOTS = [
Path.home() / "Downloads",
Path.home() / "Desktop",
Path.home() / "文件仓库" / "印度占星文章",
Path.home() / ".workbuddy" / "skills" / "jyotish-vedic-astrology",
Path.home() / "WorkBuddy" / "2026-06-09-20-03-34" / "jyotish-fragments",
Path.home() / "WorkBuddy" / "2026-06-10-21-30-47",
Path.home() / "WorkBuddy" / "2026-06-12-15-22-12",
Path.home() / "WorkBuddy" / "20260422235041",
Path.home() / "WorkBuddy" / "20260503121822",
Path.home() / "WorkBuddy" / "engines-repo" / "jyotish",
Path.home() / "engines-repo" / "jyotish",
Path.home() / "Documents" / "ObsidianVault" / "03_研究_术数占星",
]
EXTERNAL_KEYWORDS = [
"印度占星",
"占星",
"jyotish",
"vedic",
"astrology",
"jhora",
"pyjhora",
"vedastro",
"dasha",
"shadbala",
"tajika",
"jaimini",
]
EXCLUDED_PARTS = {
".git",
".pytest_cache",
".ruff_cache",
"__pycache__",
"node_modules",
"venv",
"venv_vedastro",
".venv",
"build",
"dist",
}
TEXT_EXTENSIONS = {
".css",
".csv",
".html",
".ini",
".js",
".json",
".jsonl",
".jsx",
".mjs",
".md",
".py",
".sh",
".toml",
".ts",
".tsx",
".txt",
".yaml",
".yml",
}
PDF_EXTENSIONS = {".pdf"}
IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".tif", ".tiff", ".webp"}
DOCUMENT_EXTENSIONS = {".doc", ".docx", ".epub", ".mobi", ".pages", ".ppt", ".pptx", ".rtf", ".xls", ".xlsx"}
QUEUED_EXTRACTION_STATUSES = {
"pdf_text_extraction_queued",
"image_ocr_queued",
"document_text_extraction_queued",
}
def _relative(path: Path) -> str:
return str(path.relative_to(ROOT))
def _should_skip(path: Path) -> bool:
try:
rel_parts = path.relative_to(ROOT).parts
except ValueError:
rel_parts = path.parts
return any(part in EXCLUDED_PARTS for part in rel_parts)
def _iter_project_files() -> list[Path]:
files: set[Path] = set()
for root_name in PROJECT_SCAN_ROOTS:
root = ROOT / root_name
if not root.exists() or _should_skip(root):
continue
if root.is_file():
files.add(root)
else:
files.update(path for path in root.rglob("*") if path.is_file() and not _should_skip(path))
return sorted(files)
def _is_external_relevant(path: Path) -> bool:
lowered = str(path).lower()
return any(keyword.lower() in lowered for keyword in EXTERNAL_KEYWORDS)
def _iter_external_files() -> list[Path]:
files: set[Path] = set()
for root in EXTERNAL_SCAN_ROOTS:
if not root.exists() or _should_skip(root):
continue
if root.is_file():
if _is_external_relevant(root):
files.add(root)
continue
for path in root.rglob("*"):
if path.is_file() and not _should_skip(path) and _is_external_relevant(path):
files.add(path)
return sorted(files)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _text_stats(path: Path) -> dict[str, Any]:
raw = path.read_bytes()
decoded = raw.decode("utf-8", errors="replace")
replacement_count = decoded.count("\ufffd")
return {
"extraction_status": "text_decode_lossy" if replacement_count else "text_indexed",
"character_count": len(decoded),
"line_count": decoded.count("\n") + (1 if decoded else 0),
"decode_replacement_count": replacement_count,
}
def _extraction_status(path: Path) -> dict[str, Any]:
suffix = path.suffix.lower()
if suffix in TEXT_EXTENSIONS:
return _text_stats(path)
if suffix in PDF_EXTENSIONS:
return {
"extraction_status": "pdf_text_extraction_queued",
"character_count": None,
"line_count": None,
"decode_replacement_count": None,
}
if suffix in IMAGE_EXTENSIONS:
return {
"extraction_status": "image_ocr_queued",
"character_count": None,
"line_count": None,
"decode_replacement_count": None,
}
if suffix in DOCUMENT_EXTENSIONS:
return {
"extraction_status": "document_text_extraction_queued",
"character_count": None,
"line_count": None,
"decode_replacement_count": None,
}
return {
"extraction_status": "binary_indexed",
"character_count": None,
"line_count": None,
"decode_replacement_count": None,
}
def _classify_path(path: str) -> dict[str, str]:
if path.startswith("references/open_source_sources/rishi-ai-mcp/"):
return {
"classification": "open_source_reference",
"priority": "priority_1",
"promotion_status": "reference_layer_candidate",
"reason": "User-prioritized open-source workflow corpus; requires license-aware review.",
}
if path.startswith("references/open_source_sources/vedic-astro-skills/"):
return {
"classification": "open_source_reference",
"priority": "priority_1",
"promotion_status": "reference_layer_candidate",
"reason": "User-prioritized Jyotish skill corpus; requires selective source promotion.",
}
if path.startswith("references/open_source_sources/"):
return {
"classification": "open_source_reference",
"priority": "priority_2",
"promotion_status": "reference_layer_candidate",
"reason": "Open-source corpus; index first, promote only after license and conflict review.",
}
if path.startswith("references/real_case_studies/"):
return {
"classification": "real_case_calibration",
"priority": "priority_1",
"promotion_status": "reference_layer_candidate",
"reason": "Real-case material for calibration; not direct rule truth.",
}
if path.startswith("references/oracle/"):
return {
"classification": "oracle_artifact",
"priority": "priority_2",
"promotion_status": "oracle_evidence_only",
"reason": "Oracle artifact; use for parity evidence, not interpretive rules.",
}
if path.startswith("references/"):
return {
"classification": "reference_candidate",
"priority": "priority_1",
"promotion_status": "reference_layer_candidate",
"reason": "Local reference source candidate; requires content-level review before promotion.",
}
if path.startswith("docs/research/local_drafts/"):
return {
"classification": "quarantined_draft",
"priority": "priority_3",
"promotion_status": "not_truth_source",
"reason": "Historical draft; do not promote without explicit review.",
}
if path.startswith("docs/research/"):
return {
"classification": "research_governance",
"priority": "priority_3",
"promotion_status": "governance_or_history",
"reason": "Research history or governance record; index separately from rule truth.",
}
return {
"classification": "project_governance",
"priority": "priority_3",
"promotion_status": "governance_or_history",
"reason": "Project-level governance file.",
}
def _root_bucket(path: str) -> str:
if path.startswith("references/open_source_sources/"):
return "references/open_source_sources"
if path.startswith("references/"):
return "references"
if path.startswith("docs/research/"):
return "docs/research"
return path.split("/", 1)[0]
def _external_path(path: Path) -> str:
return str(path)
def _classify_external_path(path: str) -> dict[str, str]:
suffix = Path(path).suffix.lower()
if "/.workbuddy/skills/jyotish-vedic-astrology/" in path:
return {
"classification": "external_skill_fragment",
"priority": "priority_1",
"promotion_status": "external_candidate",
"reason": "External Jyotish skill copy; index only until diffed against repo truth.",
}
if "/engines-repo/jyotish/" in path or "/WorkBuddy/engines-repo/jyotish/" in path:
return {
"classification": "external_engine_fragment",
"priority": "priority_1",
"promotion_status": "external_candidate",
"reason": "External engine fragment; requires source diff and license boundary review.",
}
if "/WorkBuddy/" in path or "/Desktop/" in path:
return {
"classification": "external_historical_report",
"priority": "priority_2",
"promotion_status": "external_reference_only",
"reason": "Historical report or work artifact; do not promote without privacy review.",
}
if suffix in PDF_EXTENSIONS or suffix in DOCUMENT_EXTENSIONS or "/文件仓库/印度占星文章/" in path:
return {
"classification": "external_book_or_document",
"priority": "priority_1",
"promotion_status": "external_reference_only",
"reason": "External book/document source; requires extraction and source grading before use.",
}
return {
"classification": "external_archive_or_binary",
"priority": "priority_3",
"promotion_status": "external_index_only",
"reason": "External relevant asset; indexed without content promotion.",
}
def _external_root_bucket(path: str) -> str:
home = str(Path.home())
if path.startswith(home + "/Downloads/"):
return "~/Downloads"
if path.startswith(home + "/Desktop/"):
return "~/Desktop"
if path.startswith(home + "/文件仓库/"):
return "~/文件仓库"
if path.startswith(home + "/.workbuddy/"):
return "~/.workbuddy"
if path.startswith(home + "/WorkBuddy/"):
return "~/WorkBuddy"
if path.startswith(home + "/engines-repo/"):
return "~/engines-repo"
if path.startswith(home + "/Documents/ObsidianVault/"):
return "~/Documents/ObsidianVault"
return path
def _build_index(
*,
files: list[Path],
path_label,
classify,
root_bucket,
) -> tuple[dict[str, dict[str, Any]], dict[str, Any], dict[str, dict[str, int]]]:
by_path: dict[str, dict[str, Any]] = {}
classification_counts: dict[str, int] = {}
extraction_counts: dict[str, int] = {}
root_summary: dict[str, dict[str, int]] = {}
unclassified_files = 0
unknown_extraction_status = 0
for file_path in files:
rel = path_label(file_path)
file_class = classify(rel)
extraction = _extraction_status(file_path)
if not file_class.get("classification"):
unclassified_files += 1
if not extraction.get("extraction_status"):
unknown_extraction_status += 1
byte_count = file_path.stat().st_size
item = {
"path": rel,
"suffix": file_path.suffix.lower(),
"byte_count": byte_count,
"sha256": _sha256(file_path),
**extraction,
**file_class,
}
by_path[rel] = item
classification_counts[item["classification"]] = classification_counts.get(item["classification"], 0) + 1
extraction_counts[item["extraction_status"]] = extraction_counts.get(item["extraction_status"], 0) + 1
bucket = root_bucket(rel)
root_summary.setdefault(bucket, {"files": 0, "bytes": 0})
root_summary[bucket]["files"] += 1
root_summary[bucket]["bytes"] += byte_count
summary = {
"total_files": len(by_path),
"unhashed_files": sum(1 for item in by_path.values() if not item.get("sha256")),
"unclassified_files": unclassified_files,
"unknown_extraction_status": unknown_extraction_status,
"classification_counts": dict(sorted(classification_counts.items())),
"extraction_status_counts": dict(sorted(extraction_counts.items())),
}
return by_path, summary, dict(sorted(root_summary.items()))
def build_manifest(*, scope: str = "project", write: bool = True) -> dict[str, Any]:
if scope == "project":
files = _iter_project_files()
path_label = _relative
classify = _classify_path
root_bucket = _root_bucket
scan_roots = PROJECT_SCAN_ROOTS
json_report = REPORT_JSON
markdown_report = REPORT_MD
title = "Character-Level Inventory Manifest"
mode = {
"heavy_ocr": False,
"whole_machine_scan": False,
"external_high_relevance_scan": False,
"semantic_promotion": False,
"boundary": "Fast manifest only: hashes and cheap text stats now; heavy OCR and whole-machine scan are queued.",
}
elif scope == "external":
files = _iter_external_files()
path_label = _external_path
classify = _classify_external_path
root_bucket = _external_root_bucket
scan_roots = [str(path) for path in EXTERNAL_SCAN_ROOTS]
json_report = EXTERNAL_REPORT_JSON
markdown_report = EXTERNAL_REPORT_MD
title = "External High-Relevance Inventory Manifest"
mode = {
"heavy_ocr": False,
"whole_machine_scan": False,
"external_high_relevance_scan": True,
"semantic_promotion": False,
"boundary": "External scan is high-relevance only. It records hashes and extraction states without copying private source text into repo truth.",
}
elif scope == "extraction-queue":
return build_extraction_queue(write=write)
elif scope == "extraction-results":
return build_extraction_results(write=write)
elif scope == "extraction-review":
return build_extraction_review(write=write)
elif scope == "source-grading-batch1":
return build_source_grading_batch1(write=write)
else:
raise ValueError(f"Unsupported scope: {scope}")
by_path, summary, root_summary = _build_index(
files=files,
path_label=path_label,
classify=classify,
root_bucket=root_bucket,
)
status = (
"pass"
if summary["unhashed_files"] == 0
and summary["unclassified_files"] == 0
and summary["unknown_extraction_status"] == 0
else "fail"
)
report = {
"scope": scope,
"status": status,
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": mode,
"scan_roots": scan_roots,
"summary": summary,
"root_summary": root_summary,
"by_path": by_path,
"artifacts": {
"json_report": _relative(json_report),
"markdown_report": _relative(markdown_report),
},
"title": title,
}
if write:
_write_reports(report, json_report=json_report, markdown_report=markdown_report)
return report
def _source_grading_batch1_items(reviews: list[dict[str, Any]]) -> list[dict[str, Any]]:
candidates = [item for item in reviews if item["decision"] == "promote_review_candidate"]
doc_pdf = [
item
for item in candidates
if item["extraction_method"] in {"docx", "pdfplumber", "pypdf"}
]
high_signal_ocr = [
item
for item in candidates
if item["extraction_method"] == "macos_vision" and item["extracted_character_count"] >= 2500
]
selected = doc_pdf[:10] + high_signal_ocr[:2]
return selected[:12]
def _source_grade(item: dict[str, Any]) -> tuple[str, str]:
path = str(item["path"])
if "/Desktop/" in path or "/WorkBuddy/" in path:
return "quarantine_or_private", "Private or historical personal report; keep out of reference pack."
if item["extraction_method"] == "macos_vision":
if item["extracted_character_count"] >= 2500:
return "reference_only", "OCR text is high-signal but still requires manual verification against image."
return "ocr_low_confidence_reference_only", "OCR text too short or noisy for promotion review."
if "jyotish_training" in path:
return "reference_only", "Training material can inform style/workflow but cannot outrank source rules."
if "/文件仓库/印度占星文章/" in path or path.endswith("印度占星.pdf") or path.endswith("private_chart_reference.pdf"):
return "promote_to_reference_pack_candidate", "Source-like document; candidate for reference pack after conflict arbitration."
return "reference_only", "Useful context, not first-order rule source."
def _arbitrate_batch1_source(item: dict[str, Any], grade: str) -> dict[str, Any]:
if grade == "promote_to_reference_pack_candidate":
pack_decision = "reference_pack_candidate"
status = "batch1_arbitrated"
reason = (
"Document/PDF source lineage is clear enough for reference/source pack candidacy; "
"still cannot enter runtime truth until source-pack visibility tests bind it explicitly."
)
else:
pack_decision = "reference_only"
status = "batch1_arbitrated"
reason = "Kept as reference-only because the source is OCR-derived, training-oriented, private, or not first-order rule material."
return {
"status": status,
"pack_decision": pack_decision,
"reason": reason,
"runtime_truth_allowed": False,
"runtime_truth_reason": "Batch 1 arbitrates source-pack eligibility only; it does not promote runtime truth.",
}
def build_source_grading_batch1(*, write: bool = True) -> dict[str, Any]:
review_report = build_extraction_review(write=False)
total_review_candidates = sum(1 for item in review_report["reviews"] if item["decision"] == "promote_review_candidate")
selected = _source_grading_batch1_items(review_report["reviews"])
graded_sources: list[dict[str, Any]] = []
for item in selected:
grade, reason = _source_grade(item)
arbitration = _arbitrate_batch1_source(item, grade)
graded_sources.append(
{
"path": item["path"],
"source_scope": item["source_scope"],
"suffix": item["suffix"],
"byte_count": item["byte_count"],
"sha256": item["sha256"],
"extraction_method": item["extraction_method"],
"extracted_character_count": item["extracted_character_count"],
"text_sha256": item.get("text_sha256"),
"review_decision": item["decision"],
"source_grade": grade,
"grade_reason": reason,
"conflict_arbitration": arbitration,
"pack_decision": arbitration["pack_decision"],
"requires_conflict_arbitration": False,
"requires_source_pack_visibility_test": True,
"runtime_truth_allowed": False,
}
)
grade_counts: dict[str, int] = {}
pack_decision_counts: dict[str, int] = {}
for item in graded_sources:
grade_counts[item["source_grade"]] = grade_counts.get(item["source_grade"], 0) + 1
pack_decision_counts[item["pack_decision"]] = pack_decision_counts.get(item["pack_decision"], 0) + 1
stored_text_payload_fields = sum(1 for item in graded_sources if "text" in item or "text_preview" in item)
reference_pack_ready_count = pack_decision_counts.get("reference_pack_candidate", 0)
report = {
"scope": "source-grading-batch1",
"status": "pass",
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": {
"runtime_truth_promotion": False,
"semantic_promotion": False,
"boundary": "Batch 1 grades extracted candidates only; no source enters runtime truth without a later source-pack test.",
},
"summary": {
"total_review_candidates": total_review_candidates,
"selected_count": len(graded_sources),
"graded_files": len(graded_sources),
"runtime_promotions": 0,
"stored_text_payload_fields": stored_text_payload_fields,
},
"grade_counts": dict(sorted(grade_counts.items())),
"pack_decision_counts": dict(sorted(pack_decision_counts.items())),
"conflict_arbitration": {
"status": "batch1_arbitrated",
"scope": "source_pack_eligibility_only",
"runtime_truth_allowed": False,
"reference_pack_ready_count": reference_pack_ready_count,
"runtime_truth_ready_count": 0,
"reference_pack_next_steps": [
"wire selected candidates into interpretation_source_inventory/source pack",
"add strict workflow visibility tests",
"keep content-level precedence below core rules until line-level rule review exists",
],
},
"graded_sources": graded_sources,
"artifacts": {
"json_report": _relative(SOURCE_GRADING_BATCH1_JSON),
"markdown_report": _relative(SOURCE_GRADING_BATCH1_MD),
},
"title": "Source Grading Batch 1 Manifest",
}
if write:
_write_reports(report, json_report=SOURCE_GRADING_BATCH1_JSON, markdown_report=SOURCE_GRADING_BATCH1_MD)
return report
def _review_decision(item: dict[str, Any]) -> tuple[str, str]:
path = str(item["path"])
if item["extraction_result"] == "extraction_failed":
return "blocked_extraction_failure", "Extraction failed; keep blocked until repaired."
if item["post_extraction_classification"] == "extracted_private_reference_only":
return "private_reference_only", "Private or historical personal report; do not promote into runtime truth."
if item["extraction_result"] == "text_empty":
return "asset_or_empty_reference_only", "OCR/extraction produced empty text; keep as indexed asset only."
if "/文件仓库/印度占星文章/" in path or "/Downloads/" in path:
return "promote_review_candidate", "Extracted source-like material; requires human source grading before promotion."
if item["post_extraction_classification"] == "extracted_candidate_for_review":
return "promote_review_candidate", "Candidate text extracted; requires conflict arbitration and source grading."
return "reference_only", "Useful as context or asset evidence, not direct runtime truth."
def build_extraction_review(*, write: bool = True) -> dict[str, Any]:
results_report = build_extraction_results(write=False)
reviews: list[dict[str, Any]] = []
for item in results_report["results"]:
decision, reason = _review_decision(item)
reviews.append(
{
"path": item["path"],
"source_scope": item["source_scope"],
"suffix": item["suffix"],
"byte_count": item["byte_count"],
"sha256": item["sha256"],
"extraction_result": item["extraction_result"],
"extraction_method": item["extraction_method"],
"extracted_character_count": item["extracted_character_count"],
"text_sha256": item.get("text_sha256"),
"post_extraction_classification": item["post_extraction_classification"],
"decision": decision,
"decision_reason": reason,
"runtime_truth_allowed": False,
}
)
decision_counts: dict[str, int] = {}
for item in reviews:
decision_counts[item["decision"]] = decision_counts.get(item["decision"], 0) + 1
stored_text_payload_fields = sum(1 for item in reviews if "text" in item or "text_preview" in item)
report = {
"scope": "extraction-review",
"status": "pass",
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": {
"whole_machine_scan": False,
"semantic_promotion": False,
"runtime_truth_promotion": False,
"boundary": "Review decisions are governance labels only. No extracted source is promoted to runtime truth here.",
},
"summary": {
"reviewed_files": len(reviews),
"runtime_promotions": 0,
"stored_text_payload_fields": stored_text_payload_fields,
},
"decision_counts": dict(sorted(decision_counts.items())),
"reviews": reviews,
"artifacts": {
"json_report": _relative(EXTRACTION_REVIEW_JSON),
"markdown_report": _relative(EXTRACTION_REVIEW_MD),
},
"title": "Extraction Review Manifest",
}
if write:
_write_reports(report, json_report=EXTRACTION_REVIEW_JSON, markdown_report=EXTRACTION_REVIEW_MD)
return report
def _hash_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
def _extract_docx_text(path: Path) -> tuple[str, str | None]:
try:
from docx import Document
document = Document(str(path))
paragraphs = [paragraph.text for paragraph in document.paragraphs]
table_text: list[str] = []
for table in document.tables:
for row in table.rows:
table_text.append("\t".join(cell.text for cell in row.cells))
return "\n".join(paragraphs + table_text), None
except Exception as exc: # pragma: no cover - exercised by real files.
fallback_text, fallback_error = _extract_docx_xml_text(path)
if fallback_text.strip():
return fallback_text, f"python-docx fallback used after {type(exc).__name__}: {exc}"
return "", fallback_error or f"{type(exc).__name__}: {exc}"
def _extract_docx_xml_text(path: Path) -> tuple[str, str | None]:
try:
with zipfile.ZipFile(path) as archive:
xml_bytes = archive.read("word/document.xml")
root = ElementTree.fromstring(xml_bytes)
text_nodes = []
namespace = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
for node in root.iter(f"{namespace}t"):
if node.text:
text_nodes.append(node.text)
return "\n".join(text_nodes), None
except Exception as exc:
return "", f"docx_xml_fallback_failed {type(exc).__name__}: {exc}"
def _extract_pdf_text(path: Path) -> tuple[str, str, str | None]:
try:
import pdfplumber
parts: list[str] = []
with pdfplumber.open(str(path)) as pdf:
for page in pdf.pages:
parts.append(page.extract_text() or "")
text = "\n".join(parts)
if text.strip():
return text, "pdfplumber", None
except Exception as exc:
pdfplumber_error = f"{type(exc).__name__}: {exc}"
else:
pdfplumber_error = None
try:
from pypdf import PdfReader
reader = PdfReader(str(path))
text = "\n".join(page.extract_text() or "" for page in reader.pages)
return text, "pypdf", None
except Exception as exc: # pragma: no cover - depends on local PDF shape.
errors = "; ".join(error for error in [pdfplumber_error, f"{type(exc).__name__}: {exc}"] if error)
return "", "pypdf", errors
def _extract_image_text(path: Path) -> tuple[str, str, str | None]:
if not shutil.which("tesseract"):
return _extract_image_text_with_vision(path)
try:
from PIL import Image
import pytesseract
with Image.open(path) as image:
return pytesseract.image_to_string(image, lang="+".join(_ocr_languages())), "pytesseract", None
except Exception as exc: # pragma: no cover - depends on local OCR install/languages.
return "", "pytesseract", f"{type(exc).__name__}: {exc}"
def _vision_ocr_binary() -> Path | None:
if sys.platform != "darwin" or not shutil.which("swiftc"):
return None
cache_dir = Path.home() / ".cache" / "jyotish-ocr"
cache_dir.mkdir(parents=True, exist_ok=True)
source = cache_dir / "vision_ocr.swift"
binary = cache_dir / "vision_ocr"
if binary.exists() and binary.stat().st_mtime >= source.stat().st_mtime if source.exists() else False:
return binary
source.write_text(VISION_OCR_SOURCE, encoding="utf-8")
completed = subprocess.run(
["swiftc", str(source), "-o", str(binary)],
text=True,
capture_output=True,
timeout=60,
check=False,
)
if completed.returncode != 0:
return None
return binary
def _extract_image_text_with_vision(path: Path) -> tuple[str, str, str | None]:
binary = _vision_ocr_binary()
if not binary:
return "", "none", "no OCR backend available: tesseract missing and macOS Vision unavailable"
completed = subprocess.run(
[str(binary), str(path)],
text=True,
capture_output=True,
timeout=90,
check=False,
)
if completed.returncode != 0:
return "", "macos_vision", completed.stderr.strip() or f"vision_ocr exit {completed.returncode}"
return completed.stdout, "macos_vision", None
def _ocr_cache_path(item: dict[str, Any], method: str) -> Path:
cache_dir = Path.home() / ".cache" / "jyotish-ocr" / "results"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / f"{item['sha256']}.{method}.json"
def _load_ocr_cached_item(item: dict[str, Any]) -> dict[str, Any] | None:
for method in ["macos_vision", "pytesseract"]:
path = _ocr_cache_path(item, method)
if not path.exists():
continue
try:
cached = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if cached.get("source_sha256") != item.get("sha256"):
continue
return {
**item,
"extraction_result": cached["extraction_result"],
"extraction_method": cached["extraction_method"],
"blocked_reason": cached.get("blocked_reason"),
"extracted_character_count": cached["extracted_character_count"],
"extracted_line_count": cached["extracted_line_count"],
"text_sha256": cached.get("text_sha256"),
"post_extraction_classification": cached["post_extraction_classification"],
"ocr_engine_available": cached["ocr_engine_available"],
"ocr_requested_languages": cached["ocr_requested_languages"],
"ocr_available_languages": cached["ocr_available_languages"],
"ocr_backend": cached["ocr_backend"],
"ocr_cache_status": "hit",
}
return None
def _write_ocr_cached_item(item: dict[str, Any]) -> None:
method = str(item.get("ocr_backend") or item.get("extraction_method") or "unknown")
path = _ocr_cache_path(item, method)
cache_payload = {
"source_sha256": item["sha256"],
"extraction_result": item["extraction_result"],
"extraction_method": item["extraction_method"],
"blocked_reason": item.get("blocked_reason"),
"extracted_character_count": item["extracted_character_count"],
"extracted_line_count": item["extracted_line_count"],
"text_sha256": item.get("text_sha256"),
"post_extraction_classification": item["post_extraction_classification"],
"ocr_engine_available": item.get("ocr_engine_available"),
"ocr_requested_languages": item.get("ocr_requested_languages", ["chi_sim", "eng"]),
"ocr_available_languages": item.get("ocr_available_languages", []),
"ocr_backend": item.get("ocr_backend", method),
}
path.write_text(json.dumps(cache_payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _ocr_available_languages() -> list[str]:
if not shutil.which("tesseract"):
return []
try:
import pytesseract
return sorted(str(lang) for lang in pytesseract.get_languages(config=""))
except Exception:
return []
def _ocr_languages() -> list[str]:
available = set(_ocr_available_languages())
preferred = ["chi_sim", "eng"]
selected = [lang for lang in preferred if lang in available]
return selected or (["eng"] if "eng" in available else preferred)
def _post_extraction_classification(item: dict[str, Any]) -> str:
path = str(item["path"])
if "/Desktop/" in path or "/WorkBuddy/" in path:
return "extracted_private_reference_only"
if item["classification"] in {"external_book_or_document", "reference_candidate"}:
return "extracted_candidate_for_review"
return "extracted_reference_only"
def _extract_queued_item(item: dict[str, Any]) -> dict[str, Any]:
path = Path(item["path"])
status = item["extraction_status"]
if status == "document_text_extraction_queued" and path.suffix.lower() == ".docx":
text, error = _extract_docx_text(path)
method = "docx"
elif status == "pdf_text_extraction_queued":
text, method, error = _extract_pdf_text(path)
elif status == "image_ocr_queued":
cached = _load_ocr_cached_item(item)
if cached:
return cached
text, method, error = _extract_image_text(path)
if error and "no OCR backend available" in error:
blocked_item = {
**item,
"extraction_result": "ocr_blocked_missing_engine",
"extraction_method": method,
"blocked_reason": error,
"extracted_character_count": 0,
"extracted_line_count": 0,
"text_sha256": None,
"post_extraction_classification": "extracted_reference_only",
"ocr_engine_available": False,
"ocr_requested_languages": ["chi_sim", "eng"],
"ocr_available_languages": [],
"ocr_backend": "none",
"ocr_cache_status": "miss",
}
_write_ocr_cached_item(blocked_item)
return blocked_item
else:
text = ""
method = "unsupported"
error = "unsupported extraction target"
normalized = text or ""
if error and not normalized.strip():
result = "extraction_failed"
elif normalized.strip():
result = "text_extracted"
else:
result = "text_empty"
result_item = {
**item,
"extraction_result": result,
"extraction_method": method,
"blocked_reason": error,
"extracted_character_count": len(normalized),
"extracted_line_count": normalized.count("\n") + (1 if normalized else 0),
"text_sha256": _hash_text(normalized) if normalized else None,
"post_extraction_classification": _post_extraction_classification(item),
**(
{
"ocr_engine_available": shutil.which("tesseract") is not None,
"ocr_requested_languages": ["chi_sim", "eng"],
"ocr_available_languages": _ocr_available_languages(),
"ocr_backend": "tesseract" if method == "pytesseract" else method,
"ocr_cache_status": "miss",
}
if status == "image_ocr_queued"
else {}
),
}
if status == "image_ocr_queued":
_write_ocr_cached_item(result_item)
return result_item
def build_extraction_results(*, write: bool = True) -> dict[str, Any]:
queue_report = build_extraction_queue(write=False)
results = [_extract_queued_item(item) for item in queue_report["queue"]]
result_counts: dict[str, int] = {}
method_counts: dict[str, int] = {}
classification_counts: dict[str, int] = {}
for item in results:
result_counts[item["extraction_result"]] = result_counts.get(item["extraction_result"], 0) + 1
method_counts[item["extraction_method"]] = method_counts.get(item["extraction_method"], 0) + 1
classification_counts[item["post_extraction_classification"]] = (
classification_counts.get(item["post_extraction_classification"], 0) + 1
)
stored_text_payload_fields = sum(1 for item in results if "text" in item or "text_preview" in item)
report = {
"scope": "extraction-results",
"status": "pass",
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": {
"heavy_ocr": shutil.which("tesseract") is not None,
"macos_vision_available": _vision_ocr_binary() is not None,
"ocr_backend_policy": "prefer_tesseract_else_macos_vision_else_blocked",
"whole_machine_scan": False,
"external_high_relevance_scan": True,
"semantic_promotion": False,
"boundary": "Extraction results store text hashes and counts only. Extracted private text is not copied into the repo truth chain.",
},
"summary": {
"total_files": len(results),
"unhashed_files": sum(1 for item in results if not item.get("sha256")),
"stored_text_payload_fields": stored_text_payload_fields,
},
"result_counts": dict(sorted(result_counts.items())),
"method_counts": dict(sorted(method_counts.items())),
"post_extraction_classification_counts": dict(sorted(classification_counts.items())),
"results": results,
"artifacts": {
"json_report": _relative(EXTRACTION_RESULTS_JSON),
"markdown_report": _relative(EXTRACTION_RESULTS_MD),
},
"title": "Extraction Results Manifest",
}
if write:
_write_reports(report, json_report=EXTRACTION_RESULTS_JSON, markdown_report=EXTRACTION_RESULTS_MD)
return report
def build_extraction_queue(*, write: bool = True) -> dict[str, Any]:
project = build_manifest(scope="project", write=False)
external = build_manifest(scope="external", write=False)
queue: list[dict[str, Any]] = []
for source_scope, manifest in [("project", project), ("external", external)]:
for item in manifest["by_path"].values():
if item["extraction_status"] not in QUEUED_EXTRACTION_STATUSES:
continue
queue.append(
{
"source_scope": source_scope,
"path": item["path"],
"suffix": item["suffix"],
"byte_count": item["byte_count"],
"sha256": item["sha256"],
"extraction_status": item["extraction_status"],
"classification": item["classification"],
"priority": item["priority"],
"promotion_status": item["promotion_status"],
}
)
queue.sort(key=lambda item: (item["extraction_status"], item["source_scope"], item["path"]))
queue_counts: dict[str, int] = {}
source_counts: dict[str, int] = {}
for item in queue:
queue_counts[item["extraction_status"]] = queue_counts.get(item["extraction_status"], 0) + 1
source_counts[item["source_scope"]] = source_counts.get(item["source_scope"], 0) + 1
report = {
"scope": "extraction-queue",
"status": "pass",
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": {
"heavy_ocr": False,
"whole_machine_scan": False,
"external_high_relevance_scan": True,
"semantic_promotion": False,
"boundary": "Queue only. It identifies PDF/image/document extraction work without performing OCR or promoting extracted text.",
},
"summary": {
"queued_files": len(queue),
"unhashed_files": sum(1 for item in queue if not item.get("sha256")),
"source_counts": dict(sorted(source_counts.items())),
},
"queue_counts": dict(sorted(queue_counts.items())),
"queue": queue,
"artifacts": {
"json_report": _relative(EXTRACTION_QUEUE_JSON),
"markdown_report": _relative(EXTRACTION_QUEUE_MD),
},
"title": "Extraction Queue Manifest",
}
if write:
_write_reports(report, json_report=EXTRACTION_QUEUE_JSON, markdown_report=EXTRACTION_QUEUE_MD)
return report
def _write_reports(report: dict[str, Any], *, json_report: Path, markdown_report: Path) -> None:
json_report.parent.mkdir(parents=True, exist_ok=True)
json_report.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
markdown_report.write_text(_render_markdown(report), encoding="utf-8")
def _render_markdown(report: dict[str, Any]) -> str:
summary = report["summary"]
if report["scope"] == "extraction-queue":
return _render_extraction_queue_markdown(report)
if report["scope"] == "extraction-results":
return _render_extraction_results_markdown(report)
if report["scope"] == "extraction-review":
return _render_extraction_review_markdown(report)
if report["scope"] == "source-grading-batch1":
return _render_source_grading_batch1_markdown(report)
lines = [
f"# {report.get('title', 'Character-Level Inventory Manifest')}",
"",
f"- status: {report['status']}",
f"- scope: {report['scope']}",
f"- generated_at: {report['generated_at']}",
f"- total_files: {summary['total_files']}",
f"- unhashed_files: {summary['unhashed_files']}",
f"- unclassified_files: {summary['unclassified_files']}",
f"- unknown_extraction_status: {summary['unknown_extraction_status']}",
f"- Heavy OCR: {'enabled' if report['mode']['heavy_ocr'] else 'disabled'}",
f"- Whole-machine scan: {'enabled' if report['mode']['whole_machine_scan'] else 'disabled'}",
"",
"## Root Summary",
"",
"| Root | Files | Bytes |",
"| --- | ---: | ---: |",
]
for root, item in report["root_summary"].items():
lines.append(f"| `{root}` | {item['files']} | {item['bytes']} |")
lines.extend(["", "## Extraction Status Counts", ""])
for name, count in summary["extraction_status_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(["", "## Classification Counts", ""])
for name, count in summary["classification_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Boundary",
"",
report["mode"]["boundary"],
"",
"This manifest proves indexing, hashing, and extraction-state classification. It does not by itself promote any source into the runtime truth chain.",
"",
]
)
return "\n".join(lines)
def _render_source_grading_batch1_markdown(report: dict[str, Any]) -> str:
lines = [
f"# {report.get('title', 'Source Grading Batch 1 Manifest')}",
"",
f"- status: {report['status']}",
f"- scope: {report['scope']}",
f"- generated_at: {report['generated_at']}",
f"- total_review_candidates: {report['summary']['total_review_candidates']}",
f"- selected_count: {report['summary']['selected_count']}",
f"- graded_files: {report['summary']['graded_files']}",
f"- runtime_promotions: {report['summary']['runtime_promotions']}",
f"- stored_text_payload_fields: {report['summary']['stored_text_payload_fields']}",
"",
"## Grade Counts",
"",
]
for name, count in report["grade_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Pack Decisions",
"",
]
)
for name, count in report["pack_decision_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Conflict Arbitration",
"",
f"- status: {report['conflict_arbitration']['status']}",
f"- scope: {report['conflict_arbitration']['scope']}",
f"- reference_pack_ready_count: {report['conflict_arbitration']['reference_pack_ready_count']}",
f"- runtime_truth_ready_count: {report['conflict_arbitration']['runtime_truth_ready_count']}",
f"- runtime_truth_allowed: {report['conflict_arbitration']['runtime_truth_allowed']}",
"",
"## Boundary",
"",
report["mode"]["boundary"],
"",
]
)
return "\n".join(lines)
def _render_extraction_review_markdown(report: dict[str, Any]) -> str:
lines = [
f"# {report.get('title', 'Extraction Review Manifest')}",
"",
f"- status: {report['status']}",
f"- scope: {report['scope']}",
f"- generated_at: {report['generated_at']}",
f"- reviewed_files: {report['summary']['reviewed_files']}",
f"- runtime_promotions: {report['summary']['runtime_promotions']}",
f"- stored_text_payload_fields: {report['summary']['stored_text_payload_fields']}",
"",
"## Decision Counts",
"",
]
for name, count in report["decision_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Boundary",
"",
report["mode"]["boundary"],
"",
"Promotion still requires human review, source grading, conflict arbitration, and explicit source-pack tests.",
"",
]
)
return "\n".join(lines)
def _render_extraction_results_markdown(report: dict[str, Any]) -> str:
lines = [
f"# {report.get('title', 'Extraction Results Manifest')}",
"",
f"- status: {report['status']}",
f"- scope: {report['scope']}",
f"- generated_at: {report['generated_at']}",
f"- total_files: {report['summary']['total_files']}",
f"- unhashed_files: {report['summary']['unhashed_files']}",
f"- stored_text_payload_fields: {report['summary']['stored_text_payload_fields']}",
f"- Heavy OCR: {'enabled' if report['mode']['heavy_ocr'] else 'disabled'}",
f"- macos_vision_available: {report['mode'].get('macos_vision_available', False)}",
f"- ocr_backend_policy: {report['mode'].get('ocr_backend_policy', 'unknown')}",
"",
"## Result Counts",
"",
]
for name, count in report["result_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(["", "## Method Counts", ""])
for name, count in report["method_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(["", "## Post-Extraction Classification Counts", ""])
for name, count in report["post_extraction_classification_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Boundary",
"",
report["mode"]["boundary"],
"",
"No extracted text or OCR text is stored in this report; only hashes, counts, methods, and statuses are persisted.",
"",
]
)
return "\n".join(lines)
def _render_extraction_queue_markdown(report: dict[str, Any]) -> str:
lines = [
f"# {report.get('title', 'Extraction Queue Manifest')}",
"",
f"- status: {report['status']}",
f"- scope: {report['scope']}",
f"- generated_at: {report['generated_at']}",
f"- queued_files: {report['summary']['queued_files']}",
f"- unhashed_files: {report['summary']['unhashed_files']}",
f"- Heavy OCR: {'enabled' if report['mode']['heavy_ocr'] else 'disabled'}",
f"- Whole-machine scan: {'enabled' if report['mode']['whole_machine_scan'] else 'disabled'}",
"",
"## Queue Counts",
"",
]
for name, count in report["queue_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(["", "## Source Counts", ""])
for name, count in report["summary"]["source_counts"].items():
lines.append(f"- `{name}`: {count}")
lines.extend(
[
"",
"## Boundary",
"",
report["mode"]["boundary"],
"",
"Queued files are indexed by path, size, hash, and extraction status only. Private source text is not copied into this report.",
"",
]
)
return "\n".join(lines)
def _summary_view(report: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in report.items()
if key not in {"by_path"}
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scope",
default="project",
choices=[
"project",
"external",
"extraction-queue",
"extraction-results",
"extraction-review",
"source-grading-batch1",
],
)
parser.add_argument("--no-write", action="store_true", help="Print the manifest without writing report artifacts.")
parser.add_argument("--summary-only", action="store_true", help="Print only summary fields; still writes full artifacts unless --no-write is set.")
args = parser.parse_args(argv)
report = build_manifest(scope=args.scope, write=not args.no_write)
printable = _summary_view(report) if args.summary_only else report
print(json.dumps(printable, ensure_ascii=False, indent=2))
return 0 if report["status"] == "pass" else 1
if __name__ == "__main__":
raise SystemExit(main())