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/planner.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Pure V1 planning projection. This module must never construct a provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import stat
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
from openmd.core.detector import DOCUMENT_EXTENSIONS, IMAGE_TEXT_EXTENSIONS, detect_platform
|
|
13
|
+
from openmd.backends.bilibili import (
|
|
14
|
+
BILIBILI_OPENCLI_DISCOVERY_BACKEND,
|
|
15
|
+
BILIBILI_OPENCLI_SUBTITLE_BACKEND,
|
|
16
|
+
extract_bilibili_uid,
|
|
17
|
+
)
|
|
18
|
+
from openmd.backends.douyin import DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND, douyin_user_target
|
|
19
|
+
from openmd.backends.web_article import OPENCLI_WEB_READ_BACKEND
|
|
20
|
+
from openmd.backends.youtube import OPENCLI_YOUTUBE_TRANSCRIPT_BACKEND
|
|
21
|
+
from openmd.backends.youtube_transcript import extract_youtube_video_id
|
|
22
|
+
from openmd.backends.youtube_discovery import (
|
|
23
|
+
YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND,
|
|
24
|
+
YOUTUBE_DISCOVERY_BACKEND,
|
|
25
|
+
YOUTUBE_PLAYLIST_DISCOVERY_BACKEND,
|
|
26
|
+
normalize_youtube_collection_url,
|
|
27
|
+
youtube_data_api_channel_lookup,
|
|
28
|
+
youtube_data_api_playlist_id,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .capabilities import CapabilityRecord, get_capability
|
|
32
|
+
from .operation_contract import COLLECTION_BINDINGS, collect_all_allowed
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
READ_ONLY_SIDE_EFFECTS = (
|
|
36
|
+
"no_provider_execution",
|
|
37
|
+
"no_network",
|
|
38
|
+
"no_browser_or_session_access",
|
|
39
|
+
"no_input_content_read",
|
|
40
|
+
"no_media_or_model_download",
|
|
41
|
+
)
|
|
42
|
+
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
|
|
43
|
+
_AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".flac"}
|
|
44
|
+
_VIDEO_SUFFIXES = {".mp4", ".m4v", ".mov", ".webm", ".mkv"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PlanResult:
|
|
49
|
+
ok: bool
|
|
50
|
+
detected_source: str
|
|
51
|
+
input_kind: str
|
|
52
|
+
capability_tier: str | None
|
|
53
|
+
implementation_state: str | None
|
|
54
|
+
source_exhausted_requested: bool
|
|
55
|
+
source_exhausted_approved: bool
|
|
56
|
+
planned_provider: str | None
|
|
57
|
+
runtime_provider: str | None
|
|
58
|
+
binding_state: str | None
|
|
59
|
+
execution_ready: bool
|
|
60
|
+
required_gates: tuple[str, ...]
|
|
61
|
+
cost_class: str | None
|
|
62
|
+
side_effects: tuple[str, ...]
|
|
63
|
+
blocked_reason: str | None
|
|
64
|
+
error_code: str | None
|
|
65
|
+
discovery_provider: str | None = None
|
|
66
|
+
required_backend: str | None = None
|
|
67
|
+
required_discovery_backend: str | None = None
|
|
68
|
+
|
|
69
|
+
def as_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"ok": self.ok,
|
|
72
|
+
"detected_source": self.detected_source,
|
|
73
|
+
"input_kind": self.input_kind,
|
|
74
|
+
"capability_tier": self.capability_tier,
|
|
75
|
+
"implementation_state": self.implementation_state,
|
|
76
|
+
"source_exhausted_requested": self.source_exhausted_requested,
|
|
77
|
+
"source_exhausted_approved": self.source_exhausted_approved,
|
|
78
|
+
"planned_provider": self.planned_provider,
|
|
79
|
+
"runtime_provider": self.runtime_provider,
|
|
80
|
+
"binding_state": self.binding_state,
|
|
81
|
+
"execution_ready": self.execution_ready,
|
|
82
|
+
"required_gates": list(self.required_gates),
|
|
83
|
+
"cost_class": self.cost_class,
|
|
84
|
+
"side_effects": list(self.side_effects),
|
|
85
|
+
"blocked_reason": self.blocked_reason,
|
|
86
|
+
"error_code": self.error_code,
|
|
87
|
+
"discovery_provider": self.discovery_provider,
|
|
88
|
+
"required_backend": self.required_backend,
|
|
89
|
+
"required_discovery_backend": self.required_discovery_backend,
|
|
90
|
+
"readiness_scope": "static_contract_only",
|
|
91
|
+
"runtime_checks": "dependencies, models, credentials and source access are not probed",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _local_candidate(value: str) -> Path | None:
|
|
96
|
+
"""Resolve spelling only. Never expand a folder or touch an input body."""
|
|
97
|
+
if _WINDOWS_PATH.match(value):
|
|
98
|
+
return Path(value)
|
|
99
|
+
parsed = urlparse(value)
|
|
100
|
+
if parsed.scheme == "file":
|
|
101
|
+
if parsed.netloc not in {"", "localhost"}:
|
|
102
|
+
raise ValueError("network_path_not_supported")
|
|
103
|
+
raw = unquote(parsed.path)
|
|
104
|
+
if raw.startswith(("\\\\", "//")):
|
|
105
|
+
raise ValueError("network_path_not_supported")
|
|
106
|
+
if re.match(r"^/[A-Za-z]:/", raw):
|
|
107
|
+
raw = raw[1:]
|
|
108
|
+
return Path(raw)
|
|
109
|
+
if parsed.scheme:
|
|
110
|
+
return None
|
|
111
|
+
if value.startswith(("\\\\", "//")):
|
|
112
|
+
raise ValueError("network_path_not_supported")
|
|
113
|
+
return Path(value) if value else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _local_source(path: Path) -> str:
|
|
117
|
+
suffix = path.suffix.lower()
|
|
118
|
+
if suffix in _AUDIO_SUFFIXES:
|
|
119
|
+
return "local_audio"
|
|
120
|
+
if suffix in _VIDEO_SUFFIXES:
|
|
121
|
+
return "local_video"
|
|
122
|
+
if suffix in DOCUMENT_EXTENSIONS:
|
|
123
|
+
return "pdf_office"
|
|
124
|
+
if suffix in IMAGE_TEXT_EXTENSIONS:
|
|
125
|
+
return "screenshot"
|
|
126
|
+
return "unknown"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _inspect_input(value: str) -> tuple[str, str, Path | None, str | None]:
|
|
130
|
+
"""Return source/shape/path/error using URL syntax or one local stat only."""
|
|
131
|
+
if re.fullmatch(r"BV[0-9A-Za-z]+", value):
|
|
132
|
+
return "bilibili", "single", None, None
|
|
133
|
+
try:
|
|
134
|
+
path = _local_candidate(value)
|
|
135
|
+
if path is not None:
|
|
136
|
+
source = _local_source(path)
|
|
137
|
+
try:
|
|
138
|
+
mode = path.stat().st_mode
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return source, "file", path, "input_missing" if source != "unknown" else "source_unsupported"
|
|
141
|
+
except OSError:
|
|
142
|
+
return source, "file", path, "input_stat_failed"
|
|
143
|
+
if stat.S_ISDIR(mode):
|
|
144
|
+
return "unknown", "folder", path, "folder_source_not_inspected"
|
|
145
|
+
if not stat.S_ISREG(mode):
|
|
146
|
+
return source, "file", path, "input_not_regular_file"
|
|
147
|
+
return source, "file", path, None
|
|
148
|
+
parsed = urlparse(value)
|
|
149
|
+
if parsed.scheme in {"http", "https"}:
|
|
150
|
+
if not parsed.hostname or any(c.isspace() for c in value):
|
|
151
|
+
return "unknown", "single", None, "source_unsupported"
|
|
152
|
+
# Only HTTP URLs reach the legacy detector, so its local recursive
|
|
153
|
+
# directory classification is unreachable from this projection.
|
|
154
|
+
source = detect_platform(value)
|
|
155
|
+
if source == "pdf":
|
|
156
|
+
source = "pdf_office"
|
|
157
|
+
if source == "xiaohongshu" and "/user/profile/" in parsed.path:
|
|
158
|
+
source = "xiaohongshu_creator"
|
|
159
|
+
return source, _infer_remote_kind(value, source), None, None
|
|
160
|
+
if parsed.scheme in {"weixin", "wx-official", "wechat-official", "wechat", "wx", "wx-group", "wechat-group", "wx-moments", "wechat-moments", "qq", "qq-group", "qq_export", "qq-export"}:
|
|
161
|
+
source = detect_platform(value)
|
|
162
|
+
if source != "web_article":
|
|
163
|
+
return source, "collection", None, None
|
|
164
|
+
except (ValueError, OSError):
|
|
165
|
+
return "unknown", "single", None, "source_unsupported"
|
|
166
|
+
return "unknown", "single", None, "source_unsupported"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _infer_remote_kind(value: str, source: str) -> str:
|
|
170
|
+
parsed = urlparse(value)
|
|
171
|
+
path = parsed.path.lower()
|
|
172
|
+
if source == "youtube" and normalize_youtube_collection_url(value) is not None:
|
|
173
|
+
return "collection"
|
|
174
|
+
if source == "bilibili" and ((parsed.hostname or "").startswith("space.") or "/collection" in path or "/lists/" in path):
|
|
175
|
+
return "collection"
|
|
176
|
+
if source == "xiaoyuzhou" and "/podcast/" in path:
|
|
177
|
+
return "collection"
|
|
178
|
+
if source == "douyin" and ("/user/" in path or "sec_uid=" in parsed.query):
|
|
179
|
+
return "collection"
|
|
180
|
+
if source in {"weixin", "xiaoe"} and any(marker in path for marker in ("/author", "/collection", "/course", "/catalog", "/column")):
|
|
181
|
+
return "collection"
|
|
182
|
+
if source in {"xiaohongshu_creator", "wechat_group", "wechat_moments", "qq_group"}:
|
|
183
|
+
return "collection"
|
|
184
|
+
return "single"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _blocked(
|
|
188
|
+
source_id: str,
|
|
189
|
+
input_kind: str,
|
|
190
|
+
request_all: bool,
|
|
191
|
+
*,
|
|
192
|
+
code: str,
|
|
193
|
+
reason: str,
|
|
194
|
+
record: CapabilityRecord | None = None,
|
|
195
|
+
) -> PlanResult:
|
|
196
|
+
return PlanResult(
|
|
197
|
+
ok=False,
|
|
198
|
+
detected_source=source_id,
|
|
199
|
+
input_kind=input_kind,
|
|
200
|
+
capability_tier=record.tier if record else None,
|
|
201
|
+
implementation_state=record.state if record else None,
|
|
202
|
+
source_exhausted_requested=request_all,
|
|
203
|
+
source_exhausted_approved=False,
|
|
204
|
+
planned_provider=record.planned_provider if record else None,
|
|
205
|
+
runtime_provider=record.runtime_provider if record else None,
|
|
206
|
+
binding_state=record.binding_state if record else None,
|
|
207
|
+
execution_ready=False,
|
|
208
|
+
required_gates=record.required_gates if record else (),
|
|
209
|
+
cost_class=record.cost_class if record else None,
|
|
210
|
+
side_effects=READ_ONLY_SIDE_EFFECTS,
|
|
211
|
+
blocked_reason=reason,
|
|
212
|
+
error_code=code,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def build_plan(
|
|
217
|
+
input_value: str,
|
|
218
|
+
*,
|
|
219
|
+
input_kind: str = "auto",
|
|
220
|
+
request_all: bool = False,
|
|
221
|
+
available_gates: set[str] | None = None,
|
|
222
|
+
) -> PlanResult:
|
|
223
|
+
value = input_value.strip()
|
|
224
|
+
source_id, inferred_kind, local_path, input_error = _inspect_input(value)
|
|
225
|
+
selected_kind = inferred_kind if input_kind == "auto" else input_kind
|
|
226
|
+
if input_error:
|
|
227
|
+
reasons = {
|
|
228
|
+
"input_missing": "local input file does not exist",
|
|
229
|
+
"input_stat_failed": "local input metadata could not be read",
|
|
230
|
+
"input_not_regular_file": "local input is not a regular file",
|
|
231
|
+
"folder_source_not_inspected": "folder contents are not inspected by plan; plan individual files",
|
|
232
|
+
}
|
|
233
|
+
return _blocked(source_id, selected_kind, request_all, code=input_error, reason=reasons.get(input_error, "input source is not approved"))
|
|
234
|
+
try:
|
|
235
|
+
record = get_capability(source_id)
|
|
236
|
+
except ValueError:
|
|
237
|
+
return _blocked(source_id, selected_kind, request_all, code="source_unsupported", reason="input source is not approved")
|
|
238
|
+
|
|
239
|
+
if local_path is not None and selected_kind != inferred_kind:
|
|
240
|
+
return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="input kind does not match local input", record=record)
|
|
241
|
+
if inferred_kind == "collection" and selected_kind == "single":
|
|
242
|
+
return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="collection input cannot be planned as a single item", record=record)
|
|
243
|
+
if selected_kind not in record.input_kinds:
|
|
244
|
+
return _blocked(source_id, selected_kind, request_all, code="input_shape_unsupported", reason="input kind is not approved for this source", record=record)
|
|
245
|
+
if record.state == "paused":
|
|
246
|
+
return _blocked(source_id, selected_kind, request_all, code="capability_paused", reason="source is paused before provider execution", record=record)
|
|
247
|
+
if request_all and selected_kind not in record.source_exhausted_kinds:
|
|
248
|
+
return _blocked(source_id, selected_kind, True, code="all_not_approved", reason="source exhaustion is not approved for this input kind", record=record)
|
|
249
|
+
|
|
250
|
+
gates = available_gates or set()
|
|
251
|
+
required = list(record.required_gates)
|
|
252
|
+
runtime_provider = record.runtime_provider
|
|
253
|
+
if not set(required) <= gates and source_id in {"youtube", "bilibili", "weixin", "web_article"}:
|
|
254
|
+
# Project an allowed fallback from the same declarations as execution.
|
|
255
|
+
# Planning stays static: do not construct or probe a provider.
|
|
256
|
+
from openmd.routing.loader import effective_routes, load_catalog
|
|
257
|
+
|
|
258
|
+
catalog = load_catalog()
|
|
259
|
+
for route in effective_routes(source_id):
|
|
260
|
+
candidate = next((backend for backend in route["chain"]
|
|
261
|
+
if set(catalog.capabilities[backend].get("gates", [])) <= gates), None)
|
|
262
|
+
if candidate:
|
|
263
|
+
runtime_provider = candidate
|
|
264
|
+
required = list(catalog.capabilities[candidate].get("gates", []))
|
|
265
|
+
break
|
|
266
|
+
discovery_provider = None
|
|
267
|
+
required_discovery_backend = None
|
|
268
|
+
operation_block = None
|
|
269
|
+
if selected_kind == "collection":
|
|
270
|
+
binding = COLLECTION_BINDINGS.get(source_id)
|
|
271
|
+
if binding is None:
|
|
272
|
+
operation_block = "collection discovery is not implemented"
|
|
273
|
+
else:
|
|
274
|
+
discovery_provider, discovery_gates = binding
|
|
275
|
+
if source_id == "bilibili" and "network" not in gates and "browser" in gates:
|
|
276
|
+
discovery_provider = BILIBILI_OPENCLI_DISCOVERY_BACKEND
|
|
277
|
+
discovery_gates = ("browser",)
|
|
278
|
+
if source_id == "bilibili" and extract_bilibili_uid(value) is None:
|
|
279
|
+
operation_block = "only Bilibili creator discovery is currently implemented"
|
|
280
|
+
if source_id == "douyin" and not request_all and douyin_user_target(value) is None:
|
|
281
|
+
operation_block = "bounded Douyin discovery requires a creator identifier"
|
|
282
|
+
if source_id == "youtube":
|
|
283
|
+
playlist = youtube_data_api_playlist_id(value) is not None
|
|
284
|
+
if normalize_youtube_collection_url(value) is None:
|
|
285
|
+
operation_block = "input is not a supported YouTube collection"
|
|
286
|
+
if "browser" in gates and ("network" not in gates or "account" not in gates):
|
|
287
|
+
discovery_provider = YOUTUBE_PLAYLIST_DISCOVERY_BACKEND if playlist else YOUTUBE_DISCOVERY_BACKEND
|
|
288
|
+
discovery_gates = ("browser",)
|
|
289
|
+
elif playlist:
|
|
290
|
+
discovery_provider = YOUTUBE_DATA_API_PLAYLIST_DISCOVERY_BACKEND
|
|
291
|
+
elif youtube_data_api_channel_lookup(value) is None:
|
|
292
|
+
operation_block = "this YouTube collection requires the browser discovery gate"
|
|
293
|
+
required_discovery_backend = discovery_provider
|
|
294
|
+
required.extend(discovery_gates)
|
|
295
|
+
if request_all and source_id == "douyin":
|
|
296
|
+
discovery_provider = DOUYIN_OPENMD_CREATOR_VIDEOS_BACKEND
|
|
297
|
+
required_discovery_backend = discovery_provider
|
|
298
|
+
elif selected_kind == "list":
|
|
299
|
+
operation_block = "list contents are not inspected by plan; plan individual inputs"
|
|
300
|
+
elif source_id == "douyin":
|
|
301
|
+
operation_block = "standalone Douyin input resolution is not implemented; use creator collection"
|
|
302
|
+
elif source_id == "youtube" and extract_youtube_video_id(value) is None:
|
|
303
|
+
operation_block = "input does not identify a YouTube video"
|
|
304
|
+
if request_all and not collect_all_allowed(source_id, browser_enabled=True, network_enabled=True, discovery_provider=discovery_provider):
|
|
305
|
+
operation_block = "source exhaustion is not implemented for this operation"
|
|
306
|
+
if local_path is None and source_id in {"pdf_office", "screenshot"}:
|
|
307
|
+
operation_block = "this provider requires a local file"
|
|
308
|
+
missing_gates = tuple(dict.fromkeys(gate for gate in required if gate not in gates))
|
|
309
|
+
execution_ready = record.binding_state == "implemented" and not missing_gates and record.state not in {"blocked", "paused"} and operation_block is None
|
|
310
|
+
if operation_block:
|
|
311
|
+
blocked_reason = operation_block
|
|
312
|
+
elif record.binding_state == "unimplemented":
|
|
313
|
+
blocked_reason = "runtime provider is not implemented"
|
|
314
|
+
elif missing_gates:
|
|
315
|
+
blocked_reason = f"missing required gate(s): {', '.join(missing_gates)}"
|
|
316
|
+
elif record.state == "blocked":
|
|
317
|
+
blocked_reason = "capability is blocked"
|
|
318
|
+
else:
|
|
319
|
+
blocked_reason = None
|
|
320
|
+
# An executable route can differ from the default CLI primary. Surface the
|
|
321
|
+
# exact override so callers never silently execute a placeholder instead.
|
|
322
|
+
required_backend = None
|
|
323
|
+
if source_id in {"local_audio", "local_video", "tutorial_video", "douyin", "youtube", "bilibili", "weixin", "web_article"} or runtime_provider != record.planned_provider:
|
|
324
|
+
required_backend = runtime_provider
|
|
325
|
+
return PlanResult(
|
|
326
|
+
ok=True,
|
|
327
|
+
detected_source=source_id,
|
|
328
|
+
input_kind=selected_kind,
|
|
329
|
+
capability_tier=record.tier,
|
|
330
|
+
implementation_state=record.state,
|
|
331
|
+
source_exhausted_requested=request_all,
|
|
332
|
+
source_exhausted_approved=request_all and selected_kind in record.source_exhausted_kinds,
|
|
333
|
+
planned_provider=record.planned_provider,
|
|
334
|
+
runtime_provider=runtime_provider,
|
|
335
|
+
binding_state=record.binding_state,
|
|
336
|
+
execution_ready=execution_ready,
|
|
337
|
+
required_gates=missing_gates,
|
|
338
|
+
cost_class=record.cost_class,
|
|
339
|
+
side_effects=READ_ONLY_SIDE_EFFECTS,
|
|
340
|
+
blocked_reason=blocked_reason,
|
|
341
|
+
error_code=None,
|
|
342
|
+
discovery_provider=discovery_provider,
|
|
343
|
+
required_backend=required_backend,
|
|
344
|
+
required_discovery_backend=required_discovery_backend,
|
|
345
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Nonsecret preferences for the interactive single-link workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SCHEMA = "openmd.preferences/v1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def data_directory() -> Path:
|
|
15
|
+
configured = os.environ.get("OPENMD_DATA_DIR")
|
|
16
|
+
return Path(configured).expanduser().resolve() if configured else Path.home() / ".openmd"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def preferences_path() -> Path:
|
|
20
|
+
return data_directory() / "preferences.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_preferences() -> dict[str, object]:
|
|
24
|
+
"""Malformed preferences never grant permissions or block extraction."""
|
|
25
|
+
path = preferences_path()
|
|
26
|
+
try:
|
|
27
|
+
if not path.is_file() or path.stat().st_size > 16 * 1024:
|
|
28
|
+
return {}
|
|
29
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return {}
|
|
32
|
+
if not isinstance(value, dict) or value.get("schema") != SCHEMA:
|
|
33
|
+
return {}
|
|
34
|
+
result: dict[str, object] = {}
|
|
35
|
+
directory = value.get("last_save_directory")
|
|
36
|
+
if isinstance(directory, str) and directory.strip():
|
|
37
|
+
result["last_save_directory"] = directory
|
|
38
|
+
if type(value.get("browser_always")) is bool:
|
|
39
|
+
result["browser_always"] = value["browser_always"]
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_preferences(*, last_save_directory: Path | None = None, browser_always: bool | None = None) -> None:
|
|
44
|
+
"""Only these explicit nonsecret values can enter the preference file."""
|
|
45
|
+
value = {"schema": SCHEMA, **load_preferences()}
|
|
46
|
+
if last_save_directory is not None:
|
|
47
|
+
value["last_save_directory"] = str(last_save_directory.expanduser().resolve())
|
|
48
|
+
if browser_always is not None:
|
|
49
|
+
value["browser_always"] = bool(browser_always)
|
|
50
|
+
path = preferences_path()
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
53
|
+
try:
|
|
54
|
+
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
|
55
|
+
json.dump(value, handle, ensure_ascii=False, indent=2)
|
|
56
|
+
handle.write("\n")
|
|
57
|
+
handle.flush()
|
|
58
|
+
os.fsync(handle.fileno())
|
|
59
|
+
temporary.replace(path)
|
|
60
|
+
finally:
|
|
61
|
+
temporary.unlink(missing_ok=True)
|
openmd/core/readable.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Conservative display formatting for caption text, without rewriting words."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_TIME = r"\d{1,}:[0-5]\d(?::[0-5]\d)?"
|
|
10
|
+
# Local ASR emits "[start-end] text"; a caption keeps only its start time for display.
|
|
11
|
+
_CAPTION = re.compile(rf"^\[(?P<time>{_TIME})(?:-{_TIME})?\][ \t]+(?P<text>.+)$")
|
|
12
|
+
_TABLE_ROW = re.compile(
|
|
13
|
+
rf"^\s*\|\s*(?P<time>{_TIME})\s*\|(?P<speaker>[^|]*)\|(?P<text>.*)\|\s*$"
|
|
14
|
+
)
|
|
15
|
+
_TABLE_HEADER = re.compile(r"^\s*\|\s*timestamp\s*\|\s*speaker\s*\|\s*text\s*\|\s*$", re.I)
|
|
16
|
+
_TABLE_SEPARATOR = re.compile(r"^\s*\|\s*:?-+:?\s*\|\s*:?-+:?\s*\|\s*:?-+:?\s*\|\s*$")
|
|
17
|
+
_MARKDOWN_SPECIAL = re.compile(r"([\\`*_{}\[\]<>#!|])")
|
|
18
|
+
_SENTENCE_END = re.compile(r"[.!?。!?][\"'’”))]*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def markdown_label(value: object) -> str:
|
|
22
|
+
"""Escape external text used in a generated heading or label."""
|
|
23
|
+
text = str(value or "").replace("\r", " ").replace("\n", " ")
|
|
24
|
+
return _MARKDOWN_SPECIAL.sub(r"\\\1", text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _Caption:
|
|
29
|
+
timestamp: str
|
|
30
|
+
text: str
|
|
31
|
+
speaker: str = ""
|
|
32
|
+
chapter: bool = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def seconds(self) -> int:
|
|
36
|
+
total = 0
|
|
37
|
+
for part in self.timestamp.split(":"):
|
|
38
|
+
total = total * 60 + int(part)
|
|
39
|
+
return total
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _table_captions(text: str) -> list[_Caption] | None:
|
|
43
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
44
|
+
if len(lines) < 3 or not _TABLE_HEADER.fullmatch(lines[0]) or not _TABLE_SEPARATOR.fullmatch(lines[1]):
|
|
45
|
+
return None
|
|
46
|
+
captions = []
|
|
47
|
+
for line in lines[2:]:
|
|
48
|
+
match = _TABLE_ROW.fullmatch(line)
|
|
49
|
+
if match is None:
|
|
50
|
+
return None
|
|
51
|
+
content = match.group("text").strip()
|
|
52
|
+
if not content:
|
|
53
|
+
return None
|
|
54
|
+
captions.append(_Caption(match.group("time"), content, match.group("speaker").strip(), content.startswith("[Chapter] ")))
|
|
55
|
+
return captions
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _line_captions(text: str) -> list[_Caption] | None:
|
|
59
|
+
captions = []
|
|
60
|
+
for line in text.splitlines():
|
|
61
|
+
if not line.strip():
|
|
62
|
+
continue
|
|
63
|
+
match = _CAPTION.fullmatch(line)
|
|
64
|
+
if match is None:
|
|
65
|
+
return None
|
|
66
|
+
captions.append(_Caption(match.group("time"), match.group("text")))
|
|
67
|
+
return captions or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_transcript(text: str, *, source_type: str | None, platform: str | None = None) -> str:
|
|
71
|
+
"""Group recognized captions only; articles and mixed/unknown formats pass through.
|
|
72
|
+
|
|
73
|
+
No text is summarized, corrected, sorted, deduplicated or repunctuated.
|
|
74
|
+
Only exact transcript table/line formats are recognized. A partly matching
|
|
75
|
+
document is returned intact rather than dropping unfamiliar source lines.
|
|
76
|
+
"""
|
|
77
|
+
if source_type not in {"video", "audio"}:
|
|
78
|
+
return text
|
|
79
|
+
captions = _table_captions(text)
|
|
80
|
+
table = captions is not None
|
|
81
|
+
if captions is None:
|
|
82
|
+
captions = _line_captions(text)
|
|
83
|
+
if captions is None:
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
blocks: list[str] = []
|
|
87
|
+
group: list[_Caption] = []
|
|
88
|
+
group_chars = 0
|
|
89
|
+
|
|
90
|
+
def flush() -> None:
|
|
91
|
+
if not group:
|
|
92
|
+
return
|
|
93
|
+
first = group[0]
|
|
94
|
+
label = f"{markdown_label(first.speaker)} ({first.timestamp})" if first.speaker else first.timestamp
|
|
95
|
+
blocks.append(f"**{label}**\n\n" + " ".join(caption.text for caption in group))
|
|
96
|
+
|
|
97
|
+
for caption in captions:
|
|
98
|
+
if caption.chapter:
|
|
99
|
+
flush()
|
|
100
|
+
group = []
|
|
101
|
+
group_chars = 0
|
|
102
|
+
heading = caption.text[len("[Chapter] "):]
|
|
103
|
+
blocks.append(f"## {markdown_label(heading)} ({caption.timestamp})")
|
|
104
|
+
continue
|
|
105
|
+
if group:
|
|
106
|
+
duration = caption.seconds - group[0].seconds
|
|
107
|
+
gap = caption.seconds - group[-1].seconds
|
|
108
|
+
if table:
|
|
109
|
+
# Use source timestamps to bound long turns even when captions
|
|
110
|
+
# have no sentence-ending punctuation. Never split row text.
|
|
111
|
+
boundary = (
|
|
112
|
+
caption.speaker != group[0].speaker
|
|
113
|
+
or duration >= 60
|
|
114
|
+
or group_chars >= 2400
|
|
115
|
+
or (group_chars >= 1200 and bool(_SENTENCE_END.search(group[-1].text.rstrip())))
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
boundary = duration >= 30 or gap >= 6 or group_chars >= 450
|
|
119
|
+
if gap < 0:
|
|
120
|
+
# Retain source order, even if upstream times move backwards.
|
|
121
|
+
boundary = True
|
|
122
|
+
if boundary:
|
|
123
|
+
flush()
|
|
124
|
+
group = []
|
|
125
|
+
group_chars = 0
|
|
126
|
+
group.append(caption)
|
|
127
|
+
group_chars += len(caption.text)
|
|
128
|
+
flush()
|
|
129
|
+
return "\n\n".join(blocks)
|