openmd-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. openmd/__init__.py +3 -0
  2. openmd/__main__.py +5 -0
  3. openmd/adapters/__init__.py +1 -0
  4. openmd/adapters/base.py +95 -0
  5. openmd/adapters/bilibili/__init__.py +11 -0
  6. openmd/adapters/bilibili/adapter.py +104 -0
  7. openmd/adapters/bilibili/discovery.py +33 -0
  8. openmd/adapters/douyin/__init__.py +11 -0
  9. openmd/adapters/douyin/adapter.py +117 -0
  10. openmd/adapters/douyin/discovery.py +36 -0
  11. openmd/adapters/douyin/sidecar.py +77 -0
  12. openmd/adapters/generic_media/__init__.py +14 -0
  13. openmd/adapters/high_privacy.py +77 -0
  14. openmd/adapters/local_audio/__init__.py +14 -0
  15. openmd/adapters/local_audio/adapter.py +169 -0
  16. openmd/adapters/local_audio/asr.py +58 -0
  17. openmd/adapters/local_audio/backends/__init__.py +1 -0
  18. openmd/adapters/local_audio/backends/auto.py +177 -0
  19. openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
  20. openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
  21. openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
  22. openmd/adapters/opencli.py +770 -0
  23. openmd/adapters/pdf/__init__.py +11 -0
  24. openmd/adapters/pdf/adapter.py +153 -0
  25. openmd/adapters/pdf/backends/__init__.py +1 -0
  26. openmd/adapters/pdf/backends/markitdown.py +165 -0
  27. openmd/adapters/pdf/parser.py +56 -0
  28. openmd/adapters/qq_group/__init__.py +11 -0
  29. openmd/adapters/qq_group/adapter.py +289 -0
  30. openmd/adapters/qq_group/parsers/__init__.py +1 -0
  31. openmd/adapters/qq_group/parsers/html.py +60 -0
  32. openmd/adapters/qq_group/parsers/json.py +71 -0
  33. openmd/adapters/registry.py +149 -0
  34. openmd/adapters/screenshot/__init__.py +11 -0
  35. openmd/adapters/screenshot/adapter.py +154 -0
  36. openmd/adapters/screenshot/backends/__init__.py +1 -0
  37. openmd/adapters/screenshot/backends/paddle.py +15 -0
  38. openmd/adapters/screenshot/backends/rapidocr.py +176 -0
  39. openmd/adapters/screenshot/backends/tesseract.py +15 -0
  40. openmd/adapters/screenshot/ocr.py +84 -0
  41. openmd/adapters/wechat_group/__init__.py +11 -0
  42. openmd/adapters/wechat_group/adapter.py +160 -0
  43. openmd/adapters/wechat_group/wx_cli.py +268 -0
  44. openmd/adapters/wechat_moments/__init__.py +11 -0
  45. openmd/adapters/wechat_moments/adapter.py +167 -0
  46. openmd/adapters/wechat_moments/wx_cli.py +265 -0
  47. openmd/adapters/weixin/__init__.py +11 -0
  48. openmd/adapters/weixin/adapter.py +148 -0
  49. openmd/adapters/weixin/discovery.py +510 -0
  50. openmd/adapters/weixin/report.py +278 -0
  51. openmd/adapters/xiaohongshu.py +158 -0
  52. openmd/backends/__init__.py +1 -0
  53. openmd/backends/base.py +204 -0
  54. openmd/backends/bilibili.py +1480 -0
  55. openmd/backends/bilibili_wbi.py +277 -0
  56. openmd/backends/discourse.py +426 -0
  57. openmd/backends/douyin.py +868 -0
  58. openmd/backends/douyin_detail.py +124 -0
  59. openmd/backends/douyin_providers.py +602 -0
  60. openmd/backends/executor.py +546 -0
  61. openmd/backends/feishu.py +861 -0
  62. openmd/backends/generic_media.py +77 -0
  63. openmd/backends/interactive.py +60 -0
  64. openmd/backends/ocr.py +41 -0
  65. openmd/backends/opencli.py +82 -0
  66. openmd/backends/pdf.py +122 -0
  67. openmd/backends/registry.py +234 -0
  68. openmd/backends/screenshot.py +79 -0
  69. openmd/backends/video_metadata.py +167 -0
  70. openmd/backends/web_article.py +363 -0
  71. openmd/backends/wechat_group.py +35 -0
  72. openmd/backends/wechat_moments.py +35 -0
  73. openmd/backends/weixin.py +767 -0
  74. openmd/backends/x.py +455 -0
  75. openmd/backends/xiaoe.py +940 -0
  76. openmd/backends/xiaohongshu.py +911 -0
  77. openmd/backends/xiaohongshu_providers.py +876 -0
  78. openmd/backends/xiaoyuzhou.py +1017 -0
  79. openmd/backends/youtube.py +280 -0
  80. openmd/backends/youtube_discovery.py +1245 -0
  81. openmd/backends/youtube_transcript.py +213 -0
  82. openmd/browser_extension/background.js +231 -0
  83. openmd/browser_extension/icons/icon-128.png +0 -0
  84. openmd/browser_extension/icons/icon-16.png +0 -0
  85. openmd/browser_extension/icons/icon-32.png +0 -0
  86. openmd/browser_extension/icons/icon-48.png +0 -0
  87. openmd/browser_extension/icons/icon-512.png +0 -0
  88. openmd/browser_extension/idle.html +6 -0
  89. openmd/browser_extension/manifest.json +43 -0
  90. openmd/browser_extension/options.html +42 -0
  91. openmd/browser_extension/options.js +117 -0
  92. openmd/browser_extension/popup.css +71 -0
  93. openmd/browser_extension/popup.html +17 -0
  94. openmd/browser_extension/popup.js +59 -0
  95. openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
  96. openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
  97. openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
  98. openmd/browser_host.py +503 -0
  99. openmd/browser_setup.py +106 -0
  100. openmd/cli.py +2012 -0
  101. openmd/console.py +32 -0
  102. openmd/core/__init__.py +1 -0
  103. openmd/core/capabilities.py +162 -0
  104. openmd/core/content_validation.py +323 -0
  105. openmd/core/coverage.py +163 -0
  106. openmd/core/detector.py +247 -0
  107. openmd/core/doctor.py +104 -0
  108. openmd/core/execution_policy.py +164 -0
  109. openmd/core/filename.py +64 -0
  110. openmd/core/input_validation.py +91 -0
  111. openmd/core/local_config.py +96 -0
  112. openmd/core/manifest.py +156 -0
  113. openmd/core/models.py +168 -0
  114. openmd/core/opencli_output.py +34 -0
  115. openmd/core/operation_contract.py +60 -0
  116. openmd/core/output.py +194 -0
  117. openmd/core/package_contract.py +335 -0
  118. openmd/core/package_validator.py +489 -0
  119. openmd/core/package_writer.py +666 -0
  120. openmd/core/planner.py +345 -0
  121. openmd/core/preferences.py +61 -0
  122. openmd/core/readable.py +129 -0
  123. openmd/core/receipt.py +285 -0
  124. openmd/core/renderer.py +185 -0
  125. openmd/core/runlog.py +208 -0
  126. openmd/core/secrets.py +313 -0
  127. openmd/doctor_view.py +68 -0
  128. openmd/interactive.py +453 -0
  129. openmd/ocr/__init__.py +1 -0
  130. openmd/ocr/breakers.py +84 -0
  131. openmd/ocr/config.py +54 -0
  132. openmd/ocr/documents.py +522 -0
  133. openmd/ocr/endpoint.py +72 -0
  134. openmd/ocr/layout.py +92 -0
  135. openmd/ocr/layout_filter.py +118 -0
  136. openmd/ocr/layout_worker.py +89 -0
  137. openmd/ocr/native_math.py +220 -0
  138. openmd/ocr/office.py +305 -0
  139. openmd/ocr/ollama.py +200 -0
  140. openmd/ocr/otsl.py +51 -0
  141. openmd/ocr/ovis-prompt.txt +1 -0
  142. openmd/ocr/pipeline.py +384 -0
  143. openmd/ocr/settings.json +25 -0
  144. openmd/ocr/tiling.py +631 -0
  145. openmd/post_video.py +154 -0
  146. openmd/probe.py +397 -0
  147. openmd/routing/__init__.py +1 -0
  148. openmd/routing/cards/builtin.json +620 -0
  149. openmd/routing/cards/defuddle.json +70 -0
  150. openmd/routing/cards/x-tweet-fetcher.json +71 -0
  151. openmd/routing/cards/xyz-dl.json +60 -0
  152. openmd/routing/cards/yutto.json +68 -0
  153. openmd/routing/entry_shim.py +71 -0
  154. openmd/routing/lifecycle.py +459 -0
  155. openmd/routing/loader.py +138 -0
  156. openmd/routing/providers.py +158 -0
  157. openmd/routing/readers.py +108 -0
  158. openmd/routing/routes.json +504 -0
  159. openmd/routing/runner.py +192 -0
  160. openmd/routing/view.py +40 -0
  161. openmd/runtime/__init__.py +1 -0
  162. openmd/runtime/_windows_job.py +234 -0
  163. openmd/runtime/asr.py +217 -0
  164. openmd/runtime/asr_worker.py +119 -0
  165. openmd/runtime/gpu_lock.py +81 -0
  166. openmd/runtime/gpu_quarantine.py +85 -0
  167. openmd/runtime/interpreters.py +407 -0
  168. openmd/runtime/managed_tools.py +204 -0
  169. openmd/runtime/models.py +202 -0
  170. openmd/runtime/services.py +175 -0
  171. openmd/runtime/tempdirs.py +227 -0
  172. openmd_cli-0.1.0.dist-info/METADATA +168 -0
  173. openmd_cli-0.1.0.dist-info/RECORD +177 -0
  174. openmd_cli-0.1.0.dist-info/WHEEL +5 -0
  175. openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
  176. openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  177. openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
openmd/ocr/pipeline.py ADDED
@@ -0,0 +1,384 @@
1
+ """Serial page/tile OCR, monitored Ovis3 → layout MiniCPM fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ from openmd.adapters.local_audio.adapter import local_path_from_input
20
+ from openmd.adapters.screenshot.ocr import OCRBackend, classify_ocr_profile
21
+ from openmd.core.local_config import checkout_root
22
+ from openmd.core.models import TranscriptResult
23
+ from openmd.runtime.gpu_lock import gpu_lock, gpu_lock_path
24
+ from openmd.runtime.gpu_quarantine import GPUQuarantined, clear_quarantine, mark_quarantine
25
+ from .breakers import breaker_reason, estimate_expected_chars
26
+ from .config import settings
27
+ from .layout import LayoutUnavailable, detect_regions
28
+ from .ollama import OllamaError, api_json, model_identity, running_models, stream_recognize, unload_model
29
+ from .otsl import table_html
30
+ from .tiling import merge_pair, norm_view, repeated_ngrams, split_image
31
+
32
+ from openmd.runtime.tempdirs import owned_temporary_directory
33
+
34
+
35
+ def han_ratio(text: str) -> float:
36
+ chars = [c for c in text if c.isalnum()]
37
+ return round(sum("\u3400" <= c <= "\u9fff" for c in chars) / len(chars), 5) if chars else 0.0
38
+
39
+
40
+ def ocr_notes(pages: list[dict]) -> list[str]:
41
+ identities = sorted({f"{a['engine']} model={a.get('model')} digest={a.get('digest')}" for page in pages for tile in page.get("tiles", [page]) for a in tile.get("attempts", [])})
42
+ used = any(page.get("fallback_used") for page in pages)
43
+ reasons = sorted({str(page.get("reason")) for page in pages if page.get("reason")})
44
+ return [*identities, f"fallback_used={str(used).lower()}; reason={'; '.join(reasons) or 'none'}", "识别模型可能自动纠正原文错字", "fallback_used=false 不代表无幻觉", "Raw OCR Markdown only; no summary, paraphrase, translation or polishing was requested.", "Image/chart asset export is not implemented; bbox image tags are source references, not exported files."]
45
+
46
+
47
+ class GpuSampler:
48
+ """Optional whole-device memory observation; not model allocation accounting."""
49
+ def __enter__(self):
50
+ self.values = []
51
+ self.stop = threading.Event()
52
+
53
+ def sample():
54
+ while not self.stop.is_set():
55
+ try:
56
+ value = subprocess.run(["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
57
+ self.values.append(float(value.stdout.strip().splitlines()[0]))
58
+ except (OSError, ValueError, IndexError, subprocess.SubprocessError):
59
+ return
60
+ self.stop.wait(0.35)
61
+
62
+ self.thread = threading.Thread(target=sample, name="openmd-gpu-sampler", daemon=True)
63
+ self.thread.start()
64
+ return self
65
+
66
+ def __exit__(self, *_):
67
+ self.stop.set()
68
+ self.thread.join(timeout=4)
69
+
70
+ @property
71
+ def peak(self):
72
+ return max(self.values) if self.values else None
73
+
74
+
75
+ def retain_breaker_output(text: str, attempt: dict) -> str | None:
76
+ if not text:
77
+ return None
78
+ root = checkout_root()
79
+ directory = root / "out/ocr-router/breaker-evidence" if root else gpu_lock_path().parent / "ocr-evidence"
80
+ directory.mkdir(parents=True, exist_ok=True)
81
+ target = directory / (uuid.uuid4().hex + ".md")
82
+ target.write_text(text, encoding="utf-8")
83
+ return str(target)
84
+
85
+
86
+ def retain_seam_outputs(a: str, b: str, arbitrator: str | None, seam: dict, *, merged_a: str | None = None) -> dict:
87
+ """Private OCR readings belong in ignored evidence, never JSON receipts."""
88
+ root = checkout_root()
89
+ directory = root / "out/ocr-router/seam-evidence" if root else gpu_lock_path().parent / "ocr-evidence"
90
+ directory.mkdir(parents=True, exist_ok=True)
91
+ target = directory / (uuid.uuid4().hex + ".json")
92
+ payload = json.dumps({"a": merged_a if merged_a is not None else a, "previous_tile": a, "b": b, "arbitrator": arbitrator, "alignment": seam, "mapping_basis": "a=accumulated_merge_before_seam,b=current_raw_tile"}, ensure_ascii=False, indent=2)
93
+ target.write_text(payload, encoding="utf-8")
94
+ return {"path": str(target), "sha256": hashlib.sha256(payload.encode()).hexdigest(),
95
+ "a_chars": len(a), "b_chars": len(b), "arbitrator_chars": len(arbitrator) if arbitrator is not None else None}
96
+
97
+
98
+ def seam_geometry(a: str, b: str, previous: tuple, current: tuple) -> dict:
99
+ """Project text lines monotonically onto the actual shared image band.
100
+
101
+ OCR has no word coordinates, so this projection limits candidate search,
102
+ never proves a deletion. The independent arbitration crop uses the exact
103
+ pixel intersection, not this text-position estimate.
104
+ """
105
+ y0, y1 = max(previous[1], current[1]), min(previous[3], current[3])
106
+ height = max(0, y1 - y0)
107
+ def window(text, fraction, tail):
108
+ lines = text.splitlines(keepends=True)
109
+ # Sparse Markdown lines (e.g. a one-symbol final line) are not a
110
+ # faithful pixel-height projection. Keep a second density estimate
111
+ # so the candidate window cannot collapse below a short real seam.
112
+ density = max(32, math.ceil(len(norm_view(text)[0]) * fraction * 2))
113
+ if len(lines) > 2:
114
+ count = max(1, math.ceil(len(lines) * fraction) + 1)
115
+ band = "".join(lines[-count:] if tail else lines[:count])
116
+ return max(density, len(norm_view(band)[0]))
117
+ return density
118
+ return {"overlap_bbox": [max(previous[0], current[0]), y0, min(previous[2], current[2]), y1],
119
+ "a_window_chars": window(a, height / max(1, previous[3] - previous[1]), True),
120
+ "b_window_chars": window(b, height / max(1, current[3] - current[1]), False),
121
+ "candidate_window_basis": "monotonic_line_projection_estimate"}
122
+
123
+
124
+ class LocalOCRBackend(OCRBackend):
125
+ def __init__(self, name: str = "ocr.chain") -> None:
126
+ if name not in {"ocr.chain", "ocr.ovis3", "ocr.minicpm"}:
127
+ raise ValueError("unknown OCR backend")
128
+ self.name = name
129
+
130
+ def recognize(self, source_ref: str, *, title: str) -> TranscriptResult:
131
+ source = local_path_from_input(source_ref)
132
+ if source is None or not source.is_file():
133
+ return self.failed("ocr_local_file_required", title)
134
+ try:
135
+ from PIL import Image
136
+
137
+ with Image.open(source) as raw:
138
+ image = raw.convert("RGB")
139
+ return self.recognize_image(image, title=title)
140
+ except (ImportError, OSError, ValueError) as exc:
141
+ return self.failed("ocr_image_unavailable", title, type(exc).__name__)
142
+
143
+ def failed(self, code: str, title: str, detail: str | None = None) -> TranscriptResult:
144
+ return TranscriptResult(text="", status="failed", source_level="ocr", backend=self.name, title=title, error_code=code, error_summary=detail or code, source_notes=["No model installation/download or Ollama lifecycle action was attempted."], metadata={"profile": "document"})
145
+
146
+ def recognize_image(self, image, *, title: str, page: int = 1, tile: bool = True) -> TranscriptResult:
147
+ try:
148
+ config = settings()
149
+ with gpu_lock(timeout=config["gpu_lock_seconds"]):
150
+ return self._recognize_locked(image, title, page, tile, config)
151
+ except (OSError, ValueError, TimeoutError, OllamaError, GPUQuarantined) as exc:
152
+ code = getattr(exc, "code", None) or (str(exc).split(":", 1)[0] if isinstance(exc, (OllamaError, TimeoutError, GPUQuarantined)) else "ocr_runtime_unavailable")
153
+ if isinstance(exc, GPUQuarantined):
154
+ return TranscriptResult(text="", status="partial", source_level="ocr", backend=self.name, title=title, error_code=code, error_summary=str(exc), metadata={"terminal_partial": True, "abort_file": True, "profile": "document"})
155
+ return self.failed(code, title, str(exc) if isinstance(exc, (OllamaError, TimeoutError)) else type(exc).__name__)
156
+
157
+ def _recognize_locked(self, image, title: str, page: int, tile: bool, config: dict) -> TranscriptResult:
158
+ tags = api_json(config["ollama_url"], "/api/tags")
159
+ primary = "minicpm_model" if self.name == "ocr.minicpm" else "ovis_model"
160
+ model_identity(tags, config[primary])
161
+ started = time.monotonic()
162
+ deadline = started + config["page_seconds"]
163
+ receipts, texts, merge = [], [], {"seams": [], "warnings": [], "arbitration_attempts": []}
164
+ text = ""
165
+ params = {key: config[key] for key in ("tile_height", "tile_overlap", "tile_search", "tile_aspect_ratio")}
166
+ with owned_temporary_directory(prefix="openmd-ocr-tiles-") as tmp:
167
+ directory = Path(tmp)
168
+ if tile and image.height > config["tile_height"] * 1.1 and image.height / image.width > config["tile_aspect_ratio"]:
169
+ source = directory / "input.png"
170
+ image.save(source)
171
+ plan = split_image(source, directory / "tiles", config["tile_height"], config["tile_overlap"], config["tile_search"])
172
+ rectangles = [(0, t["y0"], image.width, t["y1"]) for t in plan["tiles"]]
173
+ params.update(plan["params"])
174
+ else:
175
+ rectangles = [(0, 0, image.width, image.height)]
176
+ for index, box in enumerate(rectangles, 1):
177
+ receipt, text = self._tile(image.crop(box), config, tags, deadline)
178
+ receipt.update({"tile": index, "bbox": list(box)})
179
+ receipts.append(receipt)
180
+ texts.append(text)
181
+ if any(a.get("unsafe_to_switch") for a in receipt["attempts"]):
182
+ text = "\n\n".join(texts)
183
+ break
184
+ if index == 1:
185
+ merged = text
186
+ continue
187
+ if text and texts[-2]:
188
+ geometry = seam_geometry(texts[-2], text, rectangles[index - 2], box)
189
+ merged, seam, arbitration = self._merge_seam(merged, texts[-2], text, image, geometry, config, tags, deadline, receipt)
190
+ seam["seam"] = f"{index - 1}->{index}"
191
+ merge["seams"].append(seam)
192
+ if arbitration is not None:
193
+ merge["arbitration_attempts"].append(arbitration)
194
+ if seam["status"] != "merged":
195
+ merge["warnings"].append(f"seam {index - 1}->{index}: merge_uncertain (localized variants retained where alignment is proven)")
196
+ text = merged
197
+ if arbitration is not None and arbitration.get("unsafe_to_switch"):
198
+ receipt.update({"status": "partial", "reason": "ollama_unload_unconfirmed"})
199
+ break
200
+ else:
201
+ merged += "\n\n" + text
202
+ text = merged
203
+ merge["warnings"].append("One or more tiles have no text; overlap merge was skipped.")
204
+ text = text if texts else ""
205
+ reps = repeated_ngrams(text)
206
+ merge.update({"tiles": len(texts), "repeated_spans": reps[:20], "merged_norm_chars": len(norm_view(text)[0])})
207
+ if reps:
208
+ merge["warnings"].append(f"{len(reps)} repeated 24-char spans in merged text (possible duplicate overlap)")
209
+ partial = not text.strip() or any(r["status"] != "verified" for r in receipts) or bool(merge["warnings"])
210
+ attempts = [attempt for r in receipts for attempt in r["attempts"]] + merge["arbitration_attempts"]
211
+ unsafe = any(a.get("unsafe_to_switch") for a in attempts)
212
+ peaks = [a["peak_vram_mb"] for a in attempts if a.get("peak_vram_mb") is not None]
213
+ reasons = sorted({r["reason"] for r in receipts if r.get("reason")})
214
+ if merge["warnings"]:
215
+ reasons.append("merge_uncertain")
216
+ record = {"page": page, "route": "ocr", "status": "partial" if partial else "verified", "engine": sorted({a["engine"] for a in attempts}), "models": [{"name": a["model"], "digest": a["digest"]} for a in attempts], "fallback_used": any(r["fallback_used"] for r in receipts), "reason": "; ".join(reasons) or None, "ovis_seconds": round(sum(a["seconds"] for a in attempts if a["engine"] == "ocr.ovis3"), 3), "fallback_seconds": round(sum(a["seconds"] for a in attempts if a["engine"] == "ocr.minicpm"), 3), "seconds": round(time.monotonic() - started, 3), "peak_vram_mb": max(peaks) if peaks else None, "peak_vram_scope": "whole_device_observed", "han_ratio": han_ratio(text), "slice_parameters": params, "tiles": receipts, "merge": merge, "breakers": [a["breaker"] for a in attempts if a.get("breaker")]}
217
+ version = "ollama-models:" + hashlib.sha256(json.dumps(record["models"], sort_keys=True).encode()).hexdigest()
218
+ record["unprocessed_tiles"] = len(rectangles) - len(receipts)
219
+ return TranscriptResult(text=text, status=record["status"], source_level="ocr", backend=self.name, title=title, error_code="ollama_unload_unconfirmed" if unsafe else "ocr_partial" if partial else None, source_notes=[*ocr_notes([record]), f"slice_parameters={json.dumps(params, sort_keys=True)}", *merge["warnings"]], metadata={"profile": classify_ocr_profile(text), "pages": [record], "terminal_partial": partial, "abort_file": unsafe, "fallback_used": record["fallback_used"], "tool_run": {"tool_version": version, "tool_version_verified": True}})
220
+
221
+ def _merge_seam(self, merged: str, previous: str, current: str, image, geometry: dict,
222
+ config: dict, tags: dict, deadline: float, receipt: dict):
223
+ text, seam = merge_pair(merged, current, len(norm_view(previous)[0]), config["tile_overlap"], geometry=geometry)
224
+ if seam["status"] == "merged":
225
+ try:
226
+ seam["raw_outputs"] = retain_seam_outputs(previous, current, None, seam, merged_a=merged)
227
+ except OSError:
228
+ seam["raw_outputs"] = {"retained": False}
229
+ return text, seam, None
230
+ attempt, arbitration_text = None, None
231
+ successful = [a for a in receipt["attempts"] if not a.get("breaker") and a.get("request_kind") == "recognition"]
232
+ box = geometry["overlap_bbox"]
233
+ if successful and box[2] > box[0] and box[3] > box[1] and time.monotonic() < deadline:
234
+ # The containing _recognize_locked holds the same OS GPU lease.
235
+ # No chain/fallback call here: one crop, one identical engine.
236
+ attempt, arbitration_text = self._attempt(image.crop(box), config, tags, deadline, successful[-1]["engine"])
237
+ attempt["purpose"] = "seam_arbitration"
238
+ seam["arbitration"] = {"status": "failed" if attempt.get("breaker") else "completed",
239
+ "engine": attempt["engine"], "seconds": attempt["seconds"],
240
+ "bbox": box, "reason": attempt.get("breaker")}
241
+ if not attempt.get("breaker"):
242
+ text, revised = merge_pair(merged, current, len(norm_view(previous)[0]), config["tile_overlap"], geometry=geometry, arbitrator=arbitration_text)
243
+ revised["arbitration"] = seam["arbitration"]
244
+ seam = revised
245
+ else:
246
+ seam["arbitration"] = {"status": "unavailable", "reason": "page_deadline" if time.monotonic() >= deadline else "source_engine_or_overlap_unavailable"}
247
+ try:
248
+ seam["raw_outputs"] = retain_seam_outputs(previous, current, arbitration_text, seam, merged_a=merged)
249
+ except OSError:
250
+ seam["raw_outputs"] = {"retained": False}
251
+ return text, seam, attempt
252
+
253
+ def _attempt(self, image, config: dict, tags: dict, deadline: float, engine: str) -> tuple[dict, str]:
254
+ key = "ovis_model" if engine == "ocr.ovis3" else "minicpm_model"
255
+ identity = model_identity(tags, config[key])
256
+ start = time.monotonic()
257
+ record = {"engine": engine, "model": identity["name"], "digest": identity["digest"], "seconds": 0.0, "peak_vram_mb": None, "breaker": None, "request_kind": "recognition"}
258
+ text = ""
259
+ sampler = None
260
+ own_request_started = False
261
+ # Persist before any attempt, including a hard process termination. Only
262
+ # a confirmed unload can clear this state while the GPU lease is held.
263
+ mark_quarantine(config["ollama_url"], identity["name"])
264
+ try:
265
+ with GpuSampler() as sampler:
266
+ try:
267
+ if engine == "ocr.ovis3":
268
+ prompt = files("openmd.ocr").joinpath("ovis-prompt.txt").read_text(encoding="utf-8")
269
+ own_request_started = True
270
+ output = stream_recognize(image, model=identity["name"], prompt=prompt, config=config, deadline=deadline)
271
+ text = output.pop("text")
272
+ record.update(output)
273
+ else:
274
+ regions, layout_seconds = detect_regions(image, config, deadline)
275
+ blocks, pieces = [], []
276
+ for region in regions:
277
+ if time.monotonic() >= deadline:
278
+ record["breaker"] = {"rule": "page_deadline"}
279
+ break
280
+ category = region["category"].lower()
281
+ block = dict(region)
282
+ if category in {"image", "picture", "chart"}:
283
+ coords = [int(v / (image.width if i % 2 == 0 else image.height) * 1000) for i, v in enumerate(region["bbox"])]
284
+ part = '<img src="images/bbox_' + "_".join(map(str, coords)) + '.jpg" />'
285
+ block["recognition_skipped"] = True
286
+ else:
287
+ prompt = "Table Recognition:" if "table" in category else "Formula Recognition:" if "formula" in category and "number" not in category else "Text Recognition:"
288
+ own_request_started = True
289
+ output = stream_recognize(image.crop(region["bbox"]), model=identity["name"], prompt=prompt, config=config, deadline=deadline)
290
+ part = output.pop("text")
291
+ block.update(output)
292
+ if output.get("breaker"):
293
+ record["breaker"] = output["breaker"]
294
+ if "table" in category and any(tag in part for tag in ("<fcel>", "<ecel>")):
295
+ block["raw_output_sha256"] = hashlib.sha256(part.encode()).hexdigest()
296
+ part = table_html(part)
297
+ if "formula" in category and "number" not in category and part.strip() and not any(mark in part for mark in ("$$", "\\[", "\\begin{")):
298
+ part = "$$\n" + part.strip().strip("$") + "\n$$"
299
+ pieces.append(part)
300
+ blocks.append(block)
301
+ text = "\n\n".join(pieces)
302
+ if record["breaker"]:
303
+ break
304
+ record.update({"blocks": blocks, "layout_seconds": layout_seconds, "detected_regions": len(regions)})
305
+ # The fallback actually reads many crop images. Its
306
+ # capacity is their combined image-only estimate, not
307
+ # the downsampled full-page estimate used by Ovis. The
308
+ # latter undercounts dense newspapers after layout.
309
+ budgets = [b["capacity"] for b in blocks if b.get("capacity")]
310
+ if budgets:
311
+ capacity = {"estimated_chars": sum(b["estimated_chars"] for b in budgets), "max_length_chars": sum(b["max_length_chars"] for b in budgets), "scope": "layout_crop_images"}
312
+ else:
313
+ capacity = estimate_expected_chars(image, config)
314
+ record["capacity"] = capacity
315
+ record["breaker"] = record["breaker"] or breaker_reason(text, capacity, config)
316
+ finally:
317
+ # This also runs on Ctrl+C. A failed unload prevents fallback.
318
+ pending = sys.exc_info()[1]
319
+ canceling = isinstance(pending, (KeyboardInterrupt, SystemExit))
320
+ try:
321
+ if own_request_started:
322
+ unload_model(config, identity["name"])
323
+ elif running_models(api_json(config["ollama_url"], "/api/ps")):
324
+ record["unsafe_to_switch"] = True
325
+ raise OllamaError("gpu_external_busy")
326
+ if canceling or (pending is not None and "cancel_unconfirmed" in str(pending)):
327
+ record["unsafe_to_switch"] = True
328
+ else:
329
+ clear_quarantine()
330
+ except BaseException as exc:
331
+ record["unsafe_to_switch"] = True
332
+ # Retain the pre-inference marker even when another
333
+ # Ctrl+C interrupts cleanup. Preserve cancellation.
334
+ if not canceling:
335
+ if isinstance(exc, (KeyboardInterrupt, SystemExit)):
336
+ raise
337
+ raise OllamaError("ollama_unload_unconfirmed") from exc
338
+ record["peak_vram_mb"] = sampler.peak
339
+ except LayoutUnavailable:
340
+ record["breaker"] = {"rule": "fallback_unavailable"}
341
+ except (OSError, ValueError, TimeoutError, OllamaError) as exc:
342
+ record["breaker"] = {"rule": str(exc).split(":", 1)[0] if isinstance(exc, (OllamaError, TimeoutError)) else "engine_error"}
343
+ if "unload" in str(exc) or "cancel_unconfirmed" in str(exc):
344
+ record["unsafe_to_switch"] = True
345
+ record["seconds"] = round(time.monotonic() - start, 3)
346
+ if sampler is not None:
347
+ record["peak_vram_mb"] = sampler.peak
348
+ if not text.strip() and not record["breaker"]:
349
+ record["breaker"] = {"rule": "empty_output"}
350
+ if record["breaker"]:
351
+ try:
352
+ record["raw_output_path"] = retain_breaker_output(text, record)
353
+ except OSError:
354
+ record["raw_output_retained"] = False
355
+ return record, text
356
+
357
+ def _tile(self, image, config: dict, tags: dict, deadline: float) -> tuple[dict, str]:
358
+ record = {"status": "verified", "fallback_used": False, "reason": None, "attempts": []}
359
+ if time.monotonic() >= deadline:
360
+ return {**record, "status": "partial", "reason": "page_deadline"}, ""
361
+ primary = "ocr.minicpm" if self.name == "ocr.minicpm" else "ocr.ovis3"
362
+ try:
363
+ attempt, text = self._attempt(image, config, tags, deadline, primary)
364
+ except LayoutUnavailable:
365
+ return {**record, "status": "partial", "reason": "fallback_unavailable"}, ""
366
+ record["attempts"].append(attempt)
367
+ if not attempt["breaker"]:
368
+ return record, text
369
+ record.update({"status": "partial", "reason": attempt["breaker"]["rule"]})
370
+ if self.name != "ocr.chain" or attempt.get("unsafe_to_switch"):
371
+ return record, text
372
+ record["fallback_used"] = True
373
+ try:
374
+ fallback, repaired = self._attempt(image, config, tags, deadline, "ocr.minicpm")
375
+ record["attempts"].append(fallback)
376
+ if fallback["breaker"]:
377
+ rule = fallback["breaker"]["rule"]
378
+ record["reason"] += "; " + (rule if rule == "fallback_unavailable" else "fallback_" + rule)
379
+ else:
380
+ record["status"] = "verified"
381
+ return record, repaired or text
382
+ except (LayoutUnavailable, OllamaError):
383
+ record["reason"] += "; fallback_unavailable"
384
+ return record, text
@@ -0,0 +1,25 @@
1
+ {
2
+ "ovis_model": "ovis3-bf16",
3
+ "minicpm_model": "minicpm-ocr-f16",
4
+ "repeat_min_chars": 15,
5
+ "repeat_count": 3,
6
+ "length_multiplier": 2.0,
7
+ "num_predict": 8192,
8
+ "num_ctx": 8192,
9
+ "page_seconds": 600.0,
10
+ "gpu_lock_seconds": 600.0,
11
+ "max_pixels": 1003520,
12
+ "tile_height": 1440,
13
+ "tile_aspect_ratio": 2.0,
14
+ "tile_overlap": 0.18,
15
+ "tile_search": 0.15,
16
+ "layout_threshold": 0.5,
17
+ "layout_seconds": 120.0,
18
+ "pdf_dpi": 144,
19
+ "textlayer_min_chars": 20,
20
+ "textlayer_min_printable": 0.95,
21
+ "textlayer_image_coverage": 0.5,
22
+ "textlayer_hidden_ratio": 0.5,
23
+ "textlayer_background_delta": 0.08,
24
+ "office_sparse_chars": 200
25
+ }