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.
- openmd/__init__.py +3 -0
- openmd/__main__.py +5 -0
- openmd/adapters/__init__.py +1 -0
- openmd/adapters/base.py +95 -0
- openmd/adapters/bilibili/__init__.py +11 -0
- openmd/adapters/bilibili/adapter.py +104 -0
- openmd/adapters/bilibili/discovery.py +33 -0
- openmd/adapters/douyin/__init__.py +11 -0
- openmd/adapters/douyin/adapter.py +117 -0
- openmd/adapters/douyin/discovery.py +36 -0
- openmd/adapters/douyin/sidecar.py +77 -0
- openmd/adapters/generic_media/__init__.py +14 -0
- openmd/adapters/high_privacy.py +77 -0
- openmd/adapters/local_audio/__init__.py +14 -0
- openmd/adapters/local_audio/adapter.py +169 -0
- openmd/adapters/local_audio/asr.py +58 -0
- openmd/adapters/local_audio/backends/__init__.py +1 -0
- openmd/adapters/local_audio/backends/auto.py +177 -0
- openmd/adapters/local_audio/backends/faster_whisper.py +285 -0
- openmd/adapters/local_audio/backends/funasr_sense_voice.py +14 -0
- openmd/adapters/local_audio/backends/qwen3_asr.py +254 -0
- openmd/adapters/opencli.py +770 -0
- openmd/adapters/pdf/__init__.py +11 -0
- openmd/adapters/pdf/adapter.py +153 -0
- openmd/adapters/pdf/backends/__init__.py +1 -0
- openmd/adapters/pdf/backends/markitdown.py +165 -0
- openmd/adapters/pdf/parser.py +56 -0
- openmd/adapters/qq_group/__init__.py +11 -0
- openmd/adapters/qq_group/adapter.py +289 -0
- openmd/adapters/qq_group/parsers/__init__.py +1 -0
- openmd/adapters/qq_group/parsers/html.py +60 -0
- openmd/adapters/qq_group/parsers/json.py +71 -0
- openmd/adapters/registry.py +149 -0
- openmd/adapters/screenshot/__init__.py +11 -0
- openmd/adapters/screenshot/adapter.py +154 -0
- openmd/adapters/screenshot/backends/__init__.py +1 -0
- openmd/adapters/screenshot/backends/paddle.py +15 -0
- openmd/adapters/screenshot/backends/rapidocr.py +176 -0
- openmd/adapters/screenshot/backends/tesseract.py +15 -0
- openmd/adapters/screenshot/ocr.py +84 -0
- openmd/adapters/wechat_group/__init__.py +11 -0
- openmd/adapters/wechat_group/adapter.py +160 -0
- openmd/adapters/wechat_group/wx_cli.py +268 -0
- openmd/adapters/wechat_moments/__init__.py +11 -0
- openmd/adapters/wechat_moments/adapter.py +167 -0
- openmd/adapters/wechat_moments/wx_cli.py +265 -0
- openmd/adapters/weixin/__init__.py +11 -0
- openmd/adapters/weixin/adapter.py +148 -0
- openmd/adapters/weixin/discovery.py +510 -0
- openmd/adapters/weixin/report.py +278 -0
- openmd/adapters/xiaohongshu.py +158 -0
- openmd/backends/__init__.py +1 -0
- openmd/backends/base.py +204 -0
- openmd/backends/bilibili.py +1480 -0
- openmd/backends/bilibili_wbi.py +277 -0
- openmd/backends/discourse.py +426 -0
- openmd/backends/douyin.py +868 -0
- openmd/backends/douyin_detail.py +124 -0
- openmd/backends/douyin_providers.py +602 -0
- openmd/backends/executor.py +546 -0
- openmd/backends/feishu.py +861 -0
- openmd/backends/generic_media.py +77 -0
- openmd/backends/interactive.py +60 -0
- openmd/backends/ocr.py +41 -0
- openmd/backends/opencli.py +82 -0
- openmd/backends/pdf.py +122 -0
- openmd/backends/registry.py +234 -0
- openmd/backends/screenshot.py +79 -0
- openmd/backends/video_metadata.py +167 -0
- openmd/backends/web_article.py +363 -0
- openmd/backends/wechat_group.py +35 -0
- openmd/backends/wechat_moments.py +35 -0
- openmd/backends/weixin.py +767 -0
- openmd/backends/x.py +455 -0
- openmd/backends/xiaoe.py +940 -0
- openmd/backends/xiaohongshu.py +911 -0
- openmd/backends/xiaohongshu_providers.py +876 -0
- openmd/backends/xiaoyuzhou.py +1017 -0
- openmd/backends/youtube.py +280 -0
- openmd/backends/youtube_discovery.py +1245 -0
- openmd/backends/youtube_transcript.py +213 -0
- openmd/browser_extension/background.js +231 -0
- openmd/browser_extension/icons/icon-128.png +0 -0
- openmd/browser_extension/icons/icon-16.png +0 -0
- openmd/browser_extension/icons/icon-32.png +0 -0
- openmd/browser_extension/icons/icon-48.png +0 -0
- openmd/browser_extension/icons/icon-512.png +0 -0
- openmd/browser_extension/idle.html +6 -0
- openmd/browser_extension/manifest.json +43 -0
- openmd/browser_extension/options.html +42 -0
- openmd/browser_extension/options.js +117 -0
- openmd/browser_extension/popup.css +71 -0
- openmd/browser_extension/popup.html +17 -0
- openmd/browser_extension/popup.js +59 -0
- openmd/browser_extension/vendor/opencli-bridge/LICENSE +190 -0
- openmd/browser_extension/vendor/opencli-bridge/VENDOR.md +31 -0
- openmd/browser_extension/vendor/opencli-bridge/background.js +2742 -0
- openmd/browser_host.py +503 -0
- openmd/browser_setup.py +106 -0
- openmd/cli.py +2012 -0
- openmd/console.py +32 -0
- openmd/core/__init__.py +1 -0
- openmd/core/capabilities.py +162 -0
- openmd/core/content_validation.py +323 -0
- openmd/core/coverage.py +163 -0
- openmd/core/detector.py +247 -0
- openmd/core/doctor.py +104 -0
- openmd/core/execution_policy.py +164 -0
- openmd/core/filename.py +64 -0
- openmd/core/input_validation.py +91 -0
- openmd/core/local_config.py +96 -0
- openmd/core/manifest.py +156 -0
- openmd/core/models.py +168 -0
- openmd/core/opencli_output.py +34 -0
- openmd/core/operation_contract.py +60 -0
- openmd/core/output.py +194 -0
- openmd/core/package_contract.py +335 -0
- openmd/core/package_validator.py +489 -0
- openmd/core/package_writer.py +666 -0
- openmd/core/planner.py +345 -0
- openmd/core/preferences.py +61 -0
- openmd/core/readable.py +129 -0
- openmd/core/receipt.py +285 -0
- openmd/core/renderer.py +185 -0
- openmd/core/runlog.py +208 -0
- openmd/core/secrets.py +313 -0
- openmd/doctor_view.py +68 -0
- openmd/interactive.py +453 -0
- openmd/ocr/__init__.py +1 -0
- openmd/ocr/breakers.py +84 -0
- openmd/ocr/config.py +54 -0
- openmd/ocr/documents.py +522 -0
- openmd/ocr/endpoint.py +72 -0
- openmd/ocr/layout.py +92 -0
- openmd/ocr/layout_filter.py +118 -0
- openmd/ocr/layout_worker.py +89 -0
- openmd/ocr/native_math.py +220 -0
- openmd/ocr/office.py +305 -0
- openmd/ocr/ollama.py +200 -0
- openmd/ocr/otsl.py +51 -0
- openmd/ocr/ovis-prompt.txt +1 -0
- openmd/ocr/pipeline.py +384 -0
- openmd/ocr/settings.json +25 -0
- openmd/ocr/tiling.py +631 -0
- openmd/post_video.py +154 -0
- openmd/probe.py +397 -0
- openmd/routing/__init__.py +1 -0
- openmd/routing/cards/builtin.json +620 -0
- openmd/routing/cards/defuddle.json +70 -0
- openmd/routing/cards/x-tweet-fetcher.json +71 -0
- openmd/routing/cards/xyz-dl.json +60 -0
- openmd/routing/cards/yutto.json +68 -0
- openmd/routing/entry_shim.py +71 -0
- openmd/routing/lifecycle.py +459 -0
- openmd/routing/loader.py +138 -0
- openmd/routing/providers.py +158 -0
- openmd/routing/readers.py +108 -0
- openmd/routing/routes.json +504 -0
- openmd/routing/runner.py +192 -0
- openmd/routing/view.py +40 -0
- openmd/runtime/__init__.py +1 -0
- openmd/runtime/_windows_job.py +234 -0
- openmd/runtime/asr.py +217 -0
- openmd/runtime/asr_worker.py +119 -0
- openmd/runtime/gpu_lock.py +81 -0
- openmd/runtime/gpu_quarantine.py +85 -0
- openmd/runtime/interpreters.py +407 -0
- openmd/runtime/managed_tools.py +204 -0
- openmd/runtime/models.py +202 -0
- openmd/runtime/services.py +175 -0
- openmd/runtime/tempdirs.py +227 -0
- openmd_cli-0.1.0.dist-info/METADATA +168 -0
- openmd_cli-0.1.0.dist-info/RECORD +177 -0
- openmd_cli-0.1.0.dist-info/WHEEL +5 -0
- openmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openmd_cli-0.1.0.dist-info/top_level.txt +1 -0
openmd/core/receipt.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Bounded, redacted receipts for opt-in compact CLI runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_MAX_COUNT = (1 << 63) - 1
|
|
15
|
+
_ERROR_CODES = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"unknown",
|
|
18
|
+
"runtime_transient",
|
|
19
|
+
"source_unavailable",
|
|
20
|
+
"user_resolvable",
|
|
21
|
+
"code_resolvable",
|
|
22
|
+
"blocked",
|
|
23
|
+
"package_validation_failed",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompactReceiptRun:
|
|
29
|
+
TERMINAL_STATUSES = (
|
|
30
|
+
"verified",
|
|
31
|
+
"partial",
|
|
32
|
+
"terminal_gap",
|
|
33
|
+
"retryable_failure",
|
|
34
|
+
"blocked",
|
|
35
|
+
"failed",
|
|
36
|
+
"unattempted",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def __init__(self, out_dir: Path, run_dir: Path, run_id: str, command: str) -> None:
|
|
40
|
+
self.out_dir = out_dir
|
|
41
|
+
self.run_dir = run_dir
|
|
42
|
+
self.run_id = run_id
|
|
43
|
+
self.command = command
|
|
44
|
+
self.items_path = run_dir / "items.jsonl"
|
|
45
|
+
self.summary_path = run_dir / "summary.json"
|
|
46
|
+
self.checkpoint_path = run_dir / "checkpoint.json"
|
|
47
|
+
self._counts: Counter[str] = Counter()
|
|
48
|
+
self._errors: Counter[str] = Counter()
|
|
49
|
+
self._seen: set[str] = set()
|
|
50
|
+
self._occurrences = 0
|
|
51
|
+
self.expected: dict[str, int] = {}
|
|
52
|
+
self.requires_fresh_stdin = False
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def create(cls, out_dir: str | Path, command: str) -> "CompactReceiptRun":
|
|
56
|
+
root = Path(out_dir)
|
|
57
|
+
run_id = uuid4().hex
|
|
58
|
+
run_dir = root / "runs" / run_id
|
|
59
|
+
run_dir.mkdir(parents=True, exist_ok=False)
|
|
60
|
+
run = cls(root, run_dir, run_id, command)
|
|
61
|
+
run.items_path.touch(exist_ok=False)
|
|
62
|
+
run.write_checkpoint()
|
|
63
|
+
return run
|
|
64
|
+
|
|
65
|
+
def has_item(self, identity: str) -> bool:
|
|
66
|
+
return identity in self._seen
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_items(self) -> bool:
|
|
70
|
+
return bool(self._seen)
|
|
71
|
+
|
|
72
|
+
def replace_expected(self, identities: dict[str, int]) -> None:
|
|
73
|
+
"""Persist only redacted identities, never replayable input URLs."""
|
|
74
|
+
self.expected = dict(identities)
|
|
75
|
+
self._atomic_write_json(self.run_dir / "expected.json", self.expected)
|
|
76
|
+
self.write_checkpoint()
|
|
77
|
+
|
|
78
|
+
def add_occurrences(self, identity: str, occurrence: int) -> None:
|
|
79
|
+
"""Reconcile a newly resolved alias against a completed item in this run."""
|
|
80
|
+
if identity not in self._seen:
|
|
81
|
+
return
|
|
82
|
+
rows, warnings = self.read_items_for_resume()
|
|
83
|
+
if warnings:
|
|
84
|
+
raise ValueError("cannot amend interrupted receipt")
|
|
85
|
+
for row in rows:
|
|
86
|
+
if row["stable_identity"] == identity:
|
|
87
|
+
row["input_occurrence"] += occurrence
|
|
88
|
+
break
|
|
89
|
+
self._atomic_write_text(
|
|
90
|
+
self.items_path,
|
|
91
|
+
"".join(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n" for row in rows),
|
|
92
|
+
)
|
|
93
|
+
self._occurrences += occurrence
|
|
94
|
+
self.write_checkpoint()
|
|
95
|
+
|
|
96
|
+
def write_checkpoint(self) -> None:
|
|
97
|
+
self._atomic_write_json(self.checkpoint_path, {
|
|
98
|
+
"schema_version": "openmd.compact-checkpoint.v1",
|
|
99
|
+
"run_id": self.run_id,
|
|
100
|
+
"completed": len(self._seen),
|
|
101
|
+
"expected_ref": "expected.json" if self.expected else None,
|
|
102
|
+
"expected_count": len(self.expected),
|
|
103
|
+
"requires_fresh_stdin": self.requires_fresh_stdin,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
def record_item(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
stable_identity: str,
|
|
110
|
+
input_occurrence: int,
|
|
111
|
+
status: str,
|
|
112
|
+
skipped: bool = False,
|
|
113
|
+
source_level: str | None = None,
|
|
114
|
+
quality_status: str | None = None,
|
|
115
|
+
error_code: str | None = None,
|
|
116
|
+
error_summary: str | None = None,
|
|
117
|
+
failure_class: str | None = None,
|
|
118
|
+
recovery_type: str | None = None,
|
|
119
|
+
artifact_ref: str | None = None,
|
|
120
|
+
route_trace: dict[str, Any] | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
if status not in self.TERMINAL_STATUSES:
|
|
123
|
+
raise ValueError(f"invalid terminal status: {status}")
|
|
124
|
+
if not stable_identity or stable_identity in self._seen:
|
|
125
|
+
raise ValueError("stable identity must be unique and non-empty")
|
|
126
|
+
if type(input_occurrence) is not int or input_occurrence < 1 or input_occurrence > _MAX_COUNT:
|
|
127
|
+
raise ValueError("input occurrence must be a positive integer")
|
|
128
|
+
normalized_error = self._normalize_error_code(error_code)
|
|
129
|
+
record = {
|
|
130
|
+
"stable_identity": stable_identity,
|
|
131
|
+
"input_occurrence": input_occurrence,
|
|
132
|
+
"status": status,
|
|
133
|
+
"skipped": skipped,
|
|
134
|
+
"artifact_ref": artifact_ref,
|
|
135
|
+
"source_level": source_level,
|
|
136
|
+
"quality_status": quality_status or "not_evaluated",
|
|
137
|
+
"error_code": normalized_error,
|
|
138
|
+
"failure_class": failure_class,
|
|
139
|
+
"recovery_type": recovery_type,
|
|
140
|
+
}
|
|
141
|
+
if route_trace:
|
|
142
|
+
record["route_trace"] = {key: route_trace[key] for key in ("card_id", "route_version", "overlay_applied") if key in route_trace}
|
|
143
|
+
# Error text may contain signed URLs, headers, or source content; never persist it.
|
|
144
|
+
del error_summary
|
|
145
|
+
with self.items_path.open("a", encoding="utf-8", newline="\n") as handle:
|
|
146
|
+
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
147
|
+
handle.flush()
|
|
148
|
+
os.fsync(handle.fileno())
|
|
149
|
+
self._seen.add(stable_identity)
|
|
150
|
+
self._occurrences += input_occurrence
|
|
151
|
+
self._counts[status] += 1
|
|
152
|
+
if skipped:
|
|
153
|
+
self._counts["skipped"] += 1
|
|
154
|
+
if normalized_error:
|
|
155
|
+
self._errors[normalized_error] += 1
|
|
156
|
+
self.write_checkpoint()
|
|
157
|
+
|
|
158
|
+
def finalize(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
discovery_complete: bool,
|
|
162
|
+
manifest_only: bool = False,
|
|
163
|
+
resume_supported: bool = False,
|
|
164
|
+
requires_fresh_stdin: bool = False,
|
|
165
|
+
limitations: list[str] | None = None,
|
|
166
|
+
failed: bool = False,
|
|
167
|
+
) -> tuple[dict[str, Any], int]:
|
|
168
|
+
counts = {status: self._bounded_count(self._counts[status]) for status in self.TERMINAL_STATUSES}
|
|
169
|
+
counts["discovered"] = self._bounded_count(sum(counts.values()))
|
|
170
|
+
counts["skipped"] = self._bounded_count(self._counts["skipped"])
|
|
171
|
+
counts["input_occurrences"] = self._bounded_count(self._occurrences)
|
|
172
|
+
counts["duplicate_inputs"] = self._bounded_count(max(0, self._occurrences - counts["discovered"]))
|
|
173
|
+
accounting_complete = not self.expected or (
|
|
174
|
+
set(self.expected) == self._seen and sum(self.expected.values()) == self._occurrences
|
|
175
|
+
)
|
|
176
|
+
execution_complete = accounting_complete and counts["unattempted"] == 0
|
|
177
|
+
status = self._status_for(counts, discovery_complete=discovery_complete, manifest_only=manifest_only)
|
|
178
|
+
if failed or not accounting_complete:
|
|
179
|
+
status = "failed"
|
|
180
|
+
accessible = counts["discovered"] if discovery_complete and not any(
|
|
181
|
+
counts[state] for state in ("terminal_gap", "retryable_failure", "blocked", "failed", "unattempted")
|
|
182
|
+
) else None
|
|
183
|
+
coverage = counts["verified"] / accessible if accessible else None
|
|
184
|
+
all_limitations = list(limitations or [])
|
|
185
|
+
if counts["discovered"] == 0 and "empty_scope" not in all_limitations:
|
|
186
|
+
all_limitations.append("empty_scope")
|
|
187
|
+
if requires_fresh_stdin and "requires_fresh_stdin" not in all_limitations:
|
|
188
|
+
all_limitations.append("requires_fresh_stdin")
|
|
189
|
+
summary = {
|
|
190
|
+
"schema_version": "openmd.run-summary.v1",
|
|
191
|
+
"run_id": self.run_id,
|
|
192
|
+
"command": self.command,
|
|
193
|
+
"status": status,
|
|
194
|
+
"counts": counts,
|
|
195
|
+
"discovery_complete": discovery_complete,
|
|
196
|
+
"accounting_complete": accounting_complete,
|
|
197
|
+
"execution_complete": execution_complete,
|
|
198
|
+
"coverage": coverage,
|
|
199
|
+
"accessible": accessible,
|
|
200
|
+
"resume_supported": resume_supported,
|
|
201
|
+
"items_ref": "items.jsonl",
|
|
202
|
+
"checkpoint_ref": "checkpoint.json",
|
|
203
|
+
"cost": {"external_calls": None, "metered_amount": None},
|
|
204
|
+
"limitations": all_limitations,
|
|
205
|
+
}
|
|
206
|
+
self.requires_fresh_stdin = requires_fresh_stdin
|
|
207
|
+
self.write_checkpoint()
|
|
208
|
+
self._atomic_write_json(self.summary_path, summary)
|
|
209
|
+
top_errors = sorted(self._errors.items(), key=lambda item: (-item[1], item[0]))
|
|
210
|
+
selected = dict(top_errors[:5])
|
|
211
|
+
other = sum(count for _, count in top_errors[5:])
|
|
212
|
+
summary_ref = self.summary_path.relative_to(self.out_dir).as_posix()
|
|
213
|
+
payload = {
|
|
214
|
+
"schema_version": "openmd.receipt.v1",
|
|
215
|
+
"status": status,
|
|
216
|
+
"counts": counts,
|
|
217
|
+
"summary_ref": summary_ref,
|
|
218
|
+
"error_counts": selected,
|
|
219
|
+
"other_error_count": other,
|
|
220
|
+
}
|
|
221
|
+
rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
222
|
+
if len(rendered.encode("utf-8")) > 2048:
|
|
223
|
+
raise ValueError("compact receipt exceeds 2048 UTF-8 bytes")
|
|
224
|
+
return payload, 0 if status in {"complete", "discovery_only"} else 1
|
|
225
|
+
|
|
226
|
+
def read_items_for_resume(self) -> tuple[list[dict[str, Any]], list[str]]:
|
|
227
|
+
raw = self.items_path.read_bytes()
|
|
228
|
+
lines = raw.splitlines(keepends=True)
|
|
229
|
+
rows: list[dict[str, Any]] = []
|
|
230
|
+
warnings: list[str] = []
|
|
231
|
+
for index, line in enumerate(lines):
|
|
232
|
+
final_line = index == len(lines) - 1
|
|
233
|
+
try:
|
|
234
|
+
rows.append(json.loads(line))
|
|
235
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
236
|
+
if final_line and not line.endswith((b"\n", b"\r")):
|
|
237
|
+
warnings.append("truncated_final_jsonl_line")
|
|
238
|
+
break
|
|
239
|
+
raise ValueError("malformed_jsonl_line") from exc
|
|
240
|
+
return rows, warnings
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def stable_identity(*parts: str | None) -> str:
|
|
244
|
+
material = "\x1f".join(part or "" for part in parts)
|
|
245
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _normalize_error_code(value: str | None) -> str | None:
|
|
249
|
+
if not value:
|
|
250
|
+
return None
|
|
251
|
+
normalized = str(value).lower()
|
|
252
|
+
return normalized if normalized in _ERROR_CODES else "unknown"
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _bounded_count(value: int) -> int:
|
|
256
|
+
if type(value) is not int or value < 0 or value > _MAX_COUNT:
|
|
257
|
+
raise ValueError("receipt count is outside signed 64-bit range")
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def _atomic_write_json(cls, path: Path, payload: dict[str, Any]) -> None:
|
|
262
|
+
cls._atomic_write_text(path, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
266
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
267
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
268
|
+
handle.write(text)
|
|
269
|
+
handle.flush()
|
|
270
|
+
os.fsync(handle.fileno())
|
|
271
|
+
os.replace(temporary, path)
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def _status_for(cls, counts: dict[str, int], *, discovery_complete: bool, manifest_only: bool) -> str:
|
|
275
|
+
if counts["discovered"] == 0:
|
|
276
|
+
return "discovery_only" if manifest_only and discovery_complete else "blocked"
|
|
277
|
+
if manifest_only and discovery_complete:
|
|
278
|
+
return "discovery_only"
|
|
279
|
+
if counts["failed"] or counts["retryable_failure"]:
|
|
280
|
+
return "partial" if counts["verified"] or counts["partial"] else "failed"
|
|
281
|
+
if counts["blocked"] and not (counts["verified"] or counts["partial"]):
|
|
282
|
+
return "blocked"
|
|
283
|
+
if not discovery_complete or counts["partial"] or counts["terminal_gap"] or counts["unattempted"] or counts["blocked"]:
|
|
284
|
+
return "partial"
|
|
285
|
+
return "complete"
|
openmd/core/renderer.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Markdown rendering for normalized Openmd extraction results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .models import ContentItem, MarkdownDocument, TranscriptResult
|
|
12
|
+
from .readable import format_transcript, markdown_label
|
|
13
|
+
from .secrets import sanitize_error_summary, sanitize_url_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
FRONTMATTER_FIELDS: tuple[str, ...] = (
|
|
17
|
+
"title",
|
|
18
|
+
"author",
|
|
19
|
+
"url",
|
|
20
|
+
"published",
|
|
21
|
+
"platform",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SOURCE_LABELS = {
|
|
25
|
+
"native_text": "原文",
|
|
26
|
+
"native_caption": "原生字幕",
|
|
27
|
+
"auto_caption": "自动字幕",
|
|
28
|
+
"platform_transcript": "平台逐字稿",
|
|
29
|
+
"third_party_extractor": "第三方提取",
|
|
30
|
+
"browser_rendered": "浏览器提取",
|
|
31
|
+
"manual_export": "手动导出",
|
|
32
|
+
"ocr": "文字识别",
|
|
33
|
+
"asr": "语音识别",
|
|
34
|
+
"media_asr": "语音识别",
|
|
35
|
+
"live_capture_asr": "实时语音识别",
|
|
36
|
+
"partial_extraction": "部分提取",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def utc_now_iso() -> str:
|
|
41
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _frontmatter_value(value: Any) -> str:
|
|
45
|
+
if value is None or value == "":
|
|
46
|
+
return ""
|
|
47
|
+
if isinstance(value, bool):
|
|
48
|
+
return "true" if value else "false"
|
|
49
|
+
text = str(value).replace("\r", " ").replace("\n", " ")
|
|
50
|
+
# JSON strings are valid YAML scalars and avoid implicit YAML booleans,
|
|
51
|
+
# numbers, dates and special punctuation in external titles/authors.
|
|
52
|
+
return json.dumps(text, ensure_ascii=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def source_level_label(value: object) -> str:
|
|
56
|
+
return _SOURCE_LABELS.get(str(value), "提取文本")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def published_date(value: object) -> str:
|
|
60
|
+
"""Normalize known timestamp formats; unknown source labels stay intact."""
|
|
61
|
+
text = str(value or "").strip().replace("\r", " ").replace("\n", " ")
|
|
62
|
+
if not text:
|
|
63
|
+
return ""
|
|
64
|
+
try:
|
|
65
|
+
if re.fullmatch(r"\d{8}", text):
|
|
66
|
+
return datetime.strptime(text, "%Y%m%d").date().isoformat()
|
|
67
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")).date().isoformat()
|
|
68
|
+
except ValueError:
|
|
69
|
+
try:
|
|
70
|
+
return parsedate_to_datetime(text).date().isoformat()
|
|
71
|
+
except (TypeError, ValueError, OverflowError):
|
|
72
|
+
return text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_source_notes(result: TranscriptResult) -> list[str]:
|
|
76
|
+
notes = [f"backend: {result.backend}", f"source_level: {result.source_level}"]
|
|
77
|
+
for note in result.source_notes:
|
|
78
|
+
if note:
|
|
79
|
+
notes.append(f"notes: {note}")
|
|
80
|
+
if result.error_code:
|
|
81
|
+
notes.append(f"error_code: {result.error_code}")
|
|
82
|
+
if result.error_summary:
|
|
83
|
+
summary = sanitize_error_summary(result.error_summary)
|
|
84
|
+
if summary:
|
|
85
|
+
notes.append(f"error_summary: {summary}")
|
|
86
|
+
return notes
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_markdown_document(
|
|
90
|
+
item: ContentItem,
|
|
91
|
+
result: TranscriptResult,
|
|
92
|
+
*,
|
|
93
|
+
captured_at: str | None = None,
|
|
94
|
+
) -> MarkdownDocument:
|
|
95
|
+
title = result.title or item.title or item.id or "untitled"
|
|
96
|
+
image_post_author_text = (
|
|
97
|
+
item.platform == "douyin" and result.backend == "douyin.post-text"
|
|
98
|
+
and result.source_level == "native_text"
|
|
99
|
+
and result.metadata.get("transcript_method") == "image_post_text"
|
|
100
|
+
and result.metadata.get("media_kind") == "image_text"
|
|
101
|
+
and result.metadata.get("content_kind") == "author_text"
|
|
102
|
+
)
|
|
103
|
+
frontmatter = {
|
|
104
|
+
"platform": item.platform,
|
|
105
|
+
"url": sanitize_url_text(item.url),
|
|
106
|
+
"title": title,
|
|
107
|
+
"author": result.author or item.author,
|
|
108
|
+
"published_at": result.published_at or item.published_at,
|
|
109
|
+
"captured_at": captured_at or utc_now_iso(),
|
|
110
|
+
"source_type": "image_text" if image_post_author_text else item.source_type,
|
|
111
|
+
"image_post_author_text": image_post_author_text,
|
|
112
|
+
"status": result.status,
|
|
113
|
+
"source_level": result.source_level,
|
|
114
|
+
"backend": result.backend,
|
|
115
|
+
}
|
|
116
|
+
return MarkdownDocument(
|
|
117
|
+
frontmatter=frontmatter,
|
|
118
|
+
title=title,
|
|
119
|
+
content=result.text,
|
|
120
|
+
source_notes=build_source_notes(result),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _source_footer(document: MarkdownDocument) -> str:
|
|
125
|
+
level = str(document.frontmatter.get("source_level") or "")
|
|
126
|
+
label = "作者原文(图文)" if document.frontmatter.get("image_post_author_text") else source_level_label(level)
|
|
127
|
+
parts = ["来源:" + label]
|
|
128
|
+
if level in {"auto_caption", "ocr", "asr", "media_asr", "live_capture_asr"}:
|
|
129
|
+
parts.append("识别错误保持原样")
|
|
130
|
+
status = document.frontmatter.get("status")
|
|
131
|
+
if status == "partial":
|
|
132
|
+
parts.append("内容可能不完整")
|
|
133
|
+
elif status == "failed":
|
|
134
|
+
parts.append("提取失败")
|
|
135
|
+
if any("识别模型可能自动纠正原文错字" in note for note in document.source_notes):
|
|
136
|
+
parts.append("识别模型可能自动纠正原文错字")
|
|
137
|
+
parts.append("未触发回退不代表无幻觉")
|
|
138
|
+
# Keep user-relevant coverage warnings, not command traces or backend IDs.
|
|
139
|
+
if any("Single post only" in note for note in document.source_notes):
|
|
140
|
+
parts.append("仅此条帖子,未验证整串讨论覆盖")
|
|
141
|
+
if any("视频标题未取得" in note for note in document.source_notes):
|
|
142
|
+
parts.append("视频标题未取得")
|
|
143
|
+
elif any("视频作者未取得" in note for note in document.source_notes):
|
|
144
|
+
parts.append("视频作者未取得")
|
|
145
|
+
failed_images = sum(
|
|
146
|
+
note.startswith(("asset_download_failed:", "notes: asset_download_failed:"))
|
|
147
|
+
for note in document.source_notes
|
|
148
|
+
)
|
|
149
|
+
if failed_images:
|
|
150
|
+
parts.append(f"{failed_images} 张图片下载失败,保留远程链接")
|
|
151
|
+
return "*" + ";".join(parts) + "。*"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_document(document: MarkdownDocument, *, raw: bool = False) -> str:
|
|
155
|
+
"""Render a reading artifact; ``raw`` preserves the original body layout.
|
|
156
|
+
|
|
157
|
+
Structured extraction metadata remains on MarkdownDocument for runlogs and
|
|
158
|
+
package consumers; only the five human-facing source fields are printed.
|
|
159
|
+
"""
|
|
160
|
+
lines = ["---"]
|
|
161
|
+
for field in FRONTMATTER_FIELDS:
|
|
162
|
+
value = document.frontmatter.get(field)
|
|
163
|
+
if field == "title":
|
|
164
|
+
value = document.title
|
|
165
|
+
if field == "published":
|
|
166
|
+
value = published_date(value or document.frontmatter.get("published_at"))
|
|
167
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
168
|
+
continue
|
|
169
|
+
lines.append(f"{field}: {_frontmatter_value(value)}")
|
|
170
|
+
content = document.content if raw or document.frontmatter.get("image_post_author_text") else format_transcript(
|
|
171
|
+
document.content,
|
|
172
|
+
source_type=document.frontmatter.get("source_type"),
|
|
173
|
+
platform=document.frontmatter.get("platform"),
|
|
174
|
+
)
|
|
175
|
+
heading = f"# {markdown_label(document.title)}"
|
|
176
|
+
if not raw and document.frontmatter.get("platform") != "x" and not document.frontmatter.get("image_post_author_text"):
|
|
177
|
+
# Source extractors may repeat a title as the first paragraph/heading.
|
|
178
|
+
# X post titles are derived from the body, so keep their first sentence.
|
|
179
|
+
# Promote only an exact match for sources with independent titles.
|
|
180
|
+
first, separator, rest = content.lstrip("\r\n").partition("\n")
|
|
181
|
+
if first.strip() in {document.title, "# " + document.title}:
|
|
182
|
+
content = rest.lstrip("\r\n") if separator else ""
|
|
183
|
+
prefix = "\n".join([*lines, "---", "", heading, "", ""])
|
|
184
|
+
# Do not rstrip raw content: trailing spaces/newlines can be meaningful.
|
|
185
|
+
return prefix + content + ("" if content.endswith("\n") else "\n") + "\n" + _source_footer(document) + "\n"
|
openmd/core/runlog.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Append-only lightweight runlog writer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .models import ContentItem, FAILURE_CLASS_VALUES, TranscriptResult
|
|
11
|
+
from .package_contract import extraction_contract_version_for
|
|
12
|
+
from .secrets import asr_execution_metrics, sanitize_runlog_entry, sanitize_source_scope
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
USER_RESOLVABLE_ERROR_TERMS: tuple[str, ...] = (
|
|
16
|
+
"auth",
|
|
17
|
+
"credential",
|
|
18
|
+
"login",
|
|
19
|
+
"permission",
|
|
20
|
+
"selector",
|
|
21
|
+
"target_scope",
|
|
22
|
+
"api_key_missing",
|
|
23
|
+
"session_expired",
|
|
24
|
+
"cache_stale",
|
|
25
|
+
"refresh_needed",
|
|
26
|
+
"export_required",
|
|
27
|
+
"target_missing",
|
|
28
|
+
)
|
|
29
|
+
SOURCE_UNAVAILABLE_ERROR_TERMS: tuple[str, ...] = (
|
|
30
|
+
"source_unavailable",
|
|
31
|
+
"source_empty",
|
|
32
|
+
"not_found",
|
|
33
|
+
"removed",
|
|
34
|
+
"deleted",
|
|
35
|
+
"empty_result",
|
|
36
|
+
"empty_content",
|
|
37
|
+
"no_transcript",
|
|
38
|
+
"subtitle_missing",
|
|
39
|
+
"no_delta",
|
|
40
|
+
"no_data",
|
|
41
|
+
)
|
|
42
|
+
RUNTIME_TRANSIENT_ERROR_TERMS: tuple[str, ...] = (
|
|
43
|
+
"timeout",
|
|
44
|
+
"rate_limit",
|
|
45
|
+
"locked",
|
|
46
|
+
"unreachable",
|
|
47
|
+
"temporarily",
|
|
48
|
+
"http_5",
|
|
49
|
+
"backend_exit",
|
|
50
|
+
"fetch_failed",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def utc_now_iso() -> str:
|
|
55
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_runlog_path(out_dir: str | Path) -> Path:
|
|
59
|
+
return Path(out_dir) / "runlog.jsonl"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def append_runlog_entry(path: str | Path, entry: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
runlog_path = Path(path)
|
|
64
|
+
runlog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
sanitized = sanitize_runlog_entry(entry)
|
|
66
|
+
with runlog_path.open("a", encoding="utf-8", newline="\n") as file:
|
|
67
|
+
file.write(json.dumps(sanitized, ensure_ascii=False, sort_keys=True) + "\n")
|
|
68
|
+
return sanitized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_runlog_entry(
|
|
72
|
+
*,
|
|
73
|
+
input_url: str,
|
|
74
|
+
platform: str,
|
|
75
|
+
backend: str,
|
|
76
|
+
status: str,
|
|
77
|
+
source_type: str,
|
|
78
|
+
source_level: str,
|
|
79
|
+
output_path: str | Path | None = None,
|
|
80
|
+
package_id: str | None = None,
|
|
81
|
+
package_operation: str | None = None,
|
|
82
|
+
started_at: str | None = None,
|
|
83
|
+
finished_at: str | None = None,
|
|
84
|
+
item_count: int = 1,
|
|
85
|
+
success_count: int | None = None,
|
|
86
|
+
failed_count: int | None = None,
|
|
87
|
+
event_type: str = "item",
|
|
88
|
+
failure_class: str | None = None,
|
|
89
|
+
guardrail_signals: list[str] | None = None,
|
|
90
|
+
resume_pointer: str | None = None,
|
|
91
|
+
error_code: str | None = None,
|
|
92
|
+
error_summary: str | None = None,
|
|
93
|
+
extraction_contract_version: str | None = None,
|
|
94
|
+
components: list[dict[str, Any]] | None = None,
|
|
95
|
+
terminal_gap_count: int = 0,
|
|
96
|
+
route_trace: dict[str, Any] | None = None,
|
|
97
|
+
document_metadata: dict[str, Any] | None = None,
|
|
98
|
+
asr_execution: dict[str, Any] | None = None,
|
|
99
|
+
source_scope: dict[str, Any] | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
success = success_count if success_count is not None else int(status in {"verified", "partial"})
|
|
102
|
+
failed = failed_count if failed_count is not None else int(status == "failed")
|
|
103
|
+
normalized_failure_class = normalize_failure_class(failure_class) or classify_failure(error_code)
|
|
104
|
+
return {
|
|
105
|
+
"event_type": event_type,
|
|
106
|
+
"input_url": input_url,
|
|
107
|
+
"platform": platform,
|
|
108
|
+
"backend": backend,
|
|
109
|
+
"status": status,
|
|
110
|
+
"source_type": source_type,
|
|
111
|
+
"source_level": source_level,
|
|
112
|
+
"failure_class": normalized_failure_class,
|
|
113
|
+
"guardrail_signals": guardrail_signals or [],
|
|
114
|
+
"resume_pointer": resume_pointer,
|
|
115
|
+
"output_path": str(output_path) if output_path else "",
|
|
116
|
+
"package_id": package_id,
|
|
117
|
+
"package_operation": package_operation,
|
|
118
|
+
"started_at": started_at or utc_now_iso(),
|
|
119
|
+
"finished_at": finished_at or utc_now_iso(),
|
|
120
|
+
"item_count": item_count,
|
|
121
|
+
"success_count": success,
|
|
122
|
+
"failed_count": failed,
|
|
123
|
+
"error_code": error_code,
|
|
124
|
+
"error_summary": error_summary,
|
|
125
|
+
"extraction_contract_version": extraction_contract_version,
|
|
126
|
+
"components": components or [],
|
|
127
|
+
"terminal_gap_count": terminal_gap_count,
|
|
128
|
+
"route_trace": route_trace or {},
|
|
129
|
+
"document_metadata": document_metadata or {},
|
|
130
|
+
"asr_execution": asr_execution or {},
|
|
131
|
+
"source_scope": sanitize_source_scope(source_scope),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_item_runlog_entry(
|
|
136
|
+
item: ContentItem,
|
|
137
|
+
result: TranscriptResult,
|
|
138
|
+
*,
|
|
139
|
+
output_path: str | Path | None = None,
|
|
140
|
+
package_id: str | None = None,
|
|
141
|
+
package_operation: str | None = None,
|
|
142
|
+
started_at: str | None = None,
|
|
143
|
+
finished_at: str | None = None,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
failure_class = None
|
|
146
|
+
if isinstance(result.metadata, dict):
|
|
147
|
+
raw_failure_class = result.metadata.get("failure_class")
|
|
148
|
+
if isinstance(raw_failure_class, str):
|
|
149
|
+
failure_class = raw_failure_class
|
|
150
|
+
return build_runlog_entry(
|
|
151
|
+
input_url=item.url,
|
|
152
|
+
platform=item.platform,
|
|
153
|
+
backend=result.backend,
|
|
154
|
+
status=result.status,
|
|
155
|
+
source_type=item.source_type,
|
|
156
|
+
source_level=result.source_level,
|
|
157
|
+
asr_execution=asr_execution_metrics(result.metadata),
|
|
158
|
+
source_scope=result.metadata,
|
|
159
|
+
route_trace={key: result.metadata[key] for key in ("card_id", "route_version", "overlay_applied", "fallback_attempts") if key in result.metadata},
|
|
160
|
+
document_metadata={key: result.metadata[key] for key in ("pages", "office_loss_check", "office_recovery", "office_external_resources_fetched", "source_format", "mixed_routes", "pymupdf_version", "terminal_partial") if key in result.metadata} if item.platform in {"screenshot", "pdf"} else {},
|
|
161
|
+
output_path=output_path,
|
|
162
|
+
package_id=package_id,
|
|
163
|
+
package_operation=package_operation,
|
|
164
|
+
started_at=started_at,
|
|
165
|
+
finished_at=finished_at,
|
|
166
|
+
failure_class=failure_class,
|
|
167
|
+
error_code=result.error_code,
|
|
168
|
+
error_summary=result.error_summary,
|
|
169
|
+
extraction_contract_version=extraction_contract_version_for(item, result),
|
|
170
|
+
components=[
|
|
171
|
+
{
|
|
172
|
+
"id": component.id,
|
|
173
|
+
"kind": component.kind,
|
|
174
|
+
"status": component.status,
|
|
175
|
+
"source_level": component.source_level,
|
|
176
|
+
"backend": component.backend,
|
|
177
|
+
"text_ref": component.text_ref,
|
|
178
|
+
"warnings": component.warnings,
|
|
179
|
+
"terminal_reason": component.terminal_reason,
|
|
180
|
+
"error_code": component.error_code,
|
|
181
|
+
"failure_class": component.failure_class,
|
|
182
|
+
}
|
|
183
|
+
for component in result.normalized_components
|
|
184
|
+
],
|
|
185
|
+
terminal_gap_count=sum(
|
|
186
|
+
component.status == "unavailable" for component in result.normalized_components
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def normalize_failure_class(value: str | None) -> str | None:
|
|
192
|
+
if value in FAILURE_CLASS_VALUES:
|
|
193
|
+
return value
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def classify_failure(error_code: str | None) -> str | None:
|
|
198
|
+
"""Map backend-specific error codes into the shared V1 recovery taxonomy."""
|
|
199
|
+
if not error_code:
|
|
200
|
+
return None
|
|
201
|
+
normalized = str(error_code).lower()
|
|
202
|
+
if any(term in normalized for term in USER_RESOLVABLE_ERROR_TERMS):
|
|
203
|
+
return "user_resolvable"
|
|
204
|
+
if any(term in normalized for term in RUNTIME_TRANSIENT_ERROR_TERMS):
|
|
205
|
+
return "runtime_transient"
|
|
206
|
+
if any(term in normalized for term in SOURCE_UNAVAILABLE_ERROR_TERMS):
|
|
207
|
+
return "source_unavailable"
|
|
208
|
+
return "code_resolvable"
|