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
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Validator for Openmd V1 ``item.openmd/`` packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .package_contract import (
|
|
12
|
+
PACKAGE_PROFILES,
|
|
13
|
+
PACKAGE_SCHEMA_VERSION,
|
|
14
|
+
PACKAGE_SOURCE_LEVEL_VALUES,
|
|
15
|
+
PACKAGE_STATUS_VALUES,
|
|
16
|
+
PERMISSION_SCOPE_VALUES,
|
|
17
|
+
PROFILE_SECTIONS,
|
|
18
|
+
REQUIRED_METADATA_FIELDS,
|
|
19
|
+
REQUIRED_SOURCE_FIELDS,
|
|
20
|
+
)
|
|
21
|
+
from .models import COMPONENT_STATUS_VALUES, FAILURE_CLASS_VALUES
|
|
22
|
+
from .secrets import SENSITIVE_TEXT_PATTERNS, is_sensitive_key, is_sensitive_query_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SECRET_TEXT_RE = re.compile(
|
|
26
|
+
r"(?i)\b(access[_-]?token|refresh[_-]?token|authorization|set-cookie|bearer|"
|
|
27
|
+
r"xsec[_-]?token|session[_-]?key|api[_-]?key)\b"
|
|
28
|
+
)
|
|
29
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.S)
|
|
30
|
+
_TEXT_SCAN_SUFFIXES = {".md", ".json", ".txt", ".yaml", ".yml", ".html", ".htm", ".har"}
|
|
31
|
+
_RAW_ARTIFACT_SUFFIXES = {
|
|
32
|
+
".aac",
|
|
33
|
+
".avi",
|
|
34
|
+
".db",
|
|
35
|
+
".dump",
|
|
36
|
+
".flac",
|
|
37
|
+
".har",
|
|
38
|
+
".htm",
|
|
39
|
+
".html",
|
|
40
|
+
".m4a",
|
|
41
|
+
".mkv",
|
|
42
|
+
".mov",
|
|
43
|
+
".mp3",
|
|
44
|
+
".mp4",
|
|
45
|
+
".sqlite",
|
|
46
|
+
".sqlite3",
|
|
47
|
+
".wav",
|
|
48
|
+
".webm",
|
|
49
|
+
".zip",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PackageValidationResult:
|
|
55
|
+
path: Path
|
|
56
|
+
errors: list[str] = field(default_factory=list)
|
|
57
|
+
warnings: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def ok(self) -> bool:
|
|
61
|
+
return not self.errors
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PackageValidationError(ValueError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_package(path: str | Path) -> PackageValidationResult:
|
|
69
|
+
package_path = Path(path)
|
|
70
|
+
errors: list[str] = []
|
|
71
|
+
warnings: list[str] = []
|
|
72
|
+
|
|
73
|
+
if package_path.suffix != ".openmd":
|
|
74
|
+
errors.append("package path must end with .openmd")
|
|
75
|
+
if not package_path.exists() or not package_path.is_dir():
|
|
76
|
+
errors.append("package path must exist and be a directory")
|
|
77
|
+
return PackageValidationResult(package_path, errors, warnings)
|
|
78
|
+
|
|
79
|
+
required_files = ("index.md", "metadata.json", "sources.json")
|
|
80
|
+
for filename in required_files:
|
|
81
|
+
if not (package_path / filename).is_file():
|
|
82
|
+
errors.append(f"missing required file: {filename}")
|
|
83
|
+
if not (package_path / "assets").is_dir():
|
|
84
|
+
errors.append("missing required assets/ directory")
|
|
85
|
+
if errors:
|
|
86
|
+
return PackageValidationResult(package_path, errors, warnings)
|
|
87
|
+
|
|
88
|
+
metadata = _read_json_object(package_path / "metadata.json", errors)
|
|
89
|
+
sources_doc = _read_json_object(package_path / "sources.json", errors)
|
|
90
|
+
index_text = _read_text(package_path / "index.md", errors)
|
|
91
|
+
|
|
92
|
+
if metadata is not None:
|
|
93
|
+
_validate_metadata(metadata, errors)
|
|
94
|
+
if sources_doc is not None:
|
|
95
|
+
_validate_sources(sources_doc, errors)
|
|
96
|
+
source_ids = _source_ids(sources_doc)
|
|
97
|
+
if metadata is not None and sources_doc is not None:
|
|
98
|
+
_validate_primary_source(sources_doc, errors)
|
|
99
|
+
if metadata is not None and index_text is not None:
|
|
100
|
+
_validate_index(index_text, metadata, errors)
|
|
101
|
+
|
|
102
|
+
_validate_assets(package_path, errors)
|
|
103
|
+
_validate_optional_sidecars(package_path, metadata, source_ids, errors, warnings)
|
|
104
|
+
_scan_for_secrets(package_path, errors)
|
|
105
|
+
|
|
106
|
+
return PackageValidationResult(package_path, errors, warnings)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_package_or_raise(path: str | Path) -> PackageValidationResult:
|
|
110
|
+
result = validate_package(path)
|
|
111
|
+
if not result.ok:
|
|
112
|
+
raise PackageValidationError("; ".join(result.errors))
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
|
|
117
|
+
try:
|
|
118
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
119
|
+
except json.JSONDecodeError as exc:
|
|
120
|
+
errors.append(f"{path.name} is not valid JSON: {exc.msg}")
|
|
121
|
+
return None
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
errors.append(f"{path.name} could not be read: {exc}")
|
|
124
|
+
return None
|
|
125
|
+
if not isinstance(value, dict):
|
|
126
|
+
errors.append(f"{path.name} must be a JSON object")
|
|
127
|
+
return None
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_text(path: Path, errors: list[str]) -> str | None:
|
|
132
|
+
try:
|
|
133
|
+
return path.read_text(encoding="utf-8")
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
errors.append(f"{path.name} could not be read: {exc}")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_metadata(metadata: dict[str, Any], errors: list[str]) -> None:
|
|
140
|
+
_require_fields(metadata, REQUIRED_METADATA_FIELDS, "metadata.json", errors)
|
|
141
|
+
if metadata.get("package_schema_version") != PACKAGE_SCHEMA_VERSION:
|
|
142
|
+
errors.append("metadata.json package_schema_version must be 1.0-draft")
|
|
143
|
+
if metadata.get("profile") not in PACKAGE_PROFILES:
|
|
144
|
+
errors.append("metadata.json profile is not allowed")
|
|
145
|
+
if metadata.get("status") not in PACKAGE_STATUS_VALUES:
|
|
146
|
+
errors.append("metadata.json status is not allowed")
|
|
147
|
+
if metadata.get("source_level") not in PACKAGE_SOURCE_LEVEL_VALUES:
|
|
148
|
+
errors.append("metadata.json source_level is not allowed")
|
|
149
|
+
if not isinstance(metadata.get("modalities"), list):
|
|
150
|
+
errors.append("metadata.json modalities must be an array")
|
|
151
|
+
if not isinstance(metadata.get("warnings"), list):
|
|
152
|
+
errors.append("metadata.json warnings must be an array")
|
|
153
|
+
if not isinstance(metadata.get("backend"), dict):
|
|
154
|
+
errors.append("metadata.json backend must be an object")
|
|
155
|
+
_validate_extraction_components(metadata, errors)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_extraction_components(metadata: dict[str, Any], errors: list[str]) -> None:
|
|
159
|
+
version = metadata.get("extraction_contract_version")
|
|
160
|
+
components = metadata.get("components")
|
|
161
|
+
if version is None and components is None:
|
|
162
|
+
return
|
|
163
|
+
if not isinstance(version, str) or not version.strip():
|
|
164
|
+
errors.append("metadata.json extraction_contract_version must be a non-empty string")
|
|
165
|
+
if not isinstance(components, list) or not components:
|
|
166
|
+
errors.append("metadata.json components must be a non-empty array")
|
|
167
|
+
return
|
|
168
|
+
seen_ids: set[str] = set()
|
|
169
|
+
for index, component in enumerate(components):
|
|
170
|
+
prefix = f"metadata.json components[{index}]"
|
|
171
|
+
if not isinstance(component, dict):
|
|
172
|
+
errors.append(f"{prefix} must be an object")
|
|
173
|
+
continue
|
|
174
|
+
_require_fields(
|
|
175
|
+
component,
|
|
176
|
+
("id", "kind", "status", "source_level", "backend", "text_ref", "warnings"),
|
|
177
|
+
prefix,
|
|
178
|
+
errors,
|
|
179
|
+
)
|
|
180
|
+
component_id = component.get("id")
|
|
181
|
+
if not isinstance(component_id, str) or not component_id.strip():
|
|
182
|
+
errors.append(f"{prefix}.id must be a non-empty string")
|
|
183
|
+
elif component_id in seen_ids:
|
|
184
|
+
errors.append(f"{prefix}.id must be unique")
|
|
185
|
+
else:
|
|
186
|
+
seen_ids.add(component_id)
|
|
187
|
+
if not isinstance(component.get("kind"), str) or not str(component.get("kind")).strip():
|
|
188
|
+
errors.append(f"{prefix}.kind must be a non-empty string")
|
|
189
|
+
if component.get("status") not in COMPONENT_STATUS_VALUES:
|
|
190
|
+
errors.append(f"{prefix}.status is not allowed")
|
|
191
|
+
if component.get("source_level") not in PACKAGE_SOURCE_LEVEL_VALUES:
|
|
192
|
+
errors.append(f"{prefix}.source_level is not allowed")
|
|
193
|
+
if not isinstance(component.get("backend"), str) or not str(component.get("backend")).strip():
|
|
194
|
+
errors.append(f"{prefix}.backend must be a non-empty string")
|
|
195
|
+
if not isinstance(component.get("text_ref"), str) or not str(component.get("text_ref")).strip():
|
|
196
|
+
errors.append(f"{prefix}.text_ref must be a non-empty string")
|
|
197
|
+
if not isinstance(component.get("warnings"), list):
|
|
198
|
+
errors.append(f"{prefix}.warnings must be an array")
|
|
199
|
+
failure_class = component.get("failure_class")
|
|
200
|
+
if failure_class is not None and failure_class not in FAILURE_CLASS_VALUES:
|
|
201
|
+
errors.append(f"{prefix}.failure_class is not allowed")
|
|
202
|
+
if component.get("status") == "unavailable":
|
|
203
|
+
for field_name in ("terminal_reason", "error_code", "failure_class"):
|
|
204
|
+
value = component.get(field_name)
|
|
205
|
+
if not isinstance(value, str) or not value.strip():
|
|
206
|
+
errors.append(
|
|
207
|
+
f"{prefix}.{field_name} is required for unavailable components"
|
|
208
|
+
)
|
|
209
|
+
if component.get("failure_class") != "source_unavailable":
|
|
210
|
+
errors.append(
|
|
211
|
+
f"{prefix}.failure_class must be source_unavailable for unavailable components"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _validate_sources(sources_doc: dict[str, Any], errors: list[str]) -> None:
|
|
216
|
+
if sources_doc.get("package_schema_version") != PACKAGE_SCHEMA_VERSION:
|
|
217
|
+
errors.append("sources.json package_schema_version must be 1.0-draft")
|
|
218
|
+
sources = sources_doc.get("sources")
|
|
219
|
+
if not isinstance(sources, list) or not sources:
|
|
220
|
+
errors.append("sources.json sources must be a non-empty array")
|
|
221
|
+
return
|
|
222
|
+
for index, source in enumerate(sources):
|
|
223
|
+
if not isinstance(source, dict):
|
|
224
|
+
errors.append(f"sources.json sources[{index}] must be an object")
|
|
225
|
+
continue
|
|
226
|
+
_require_fields(source, REQUIRED_SOURCE_FIELDS, f"sources[{index}]", errors)
|
|
227
|
+
if source.get("permission_scope") not in PERMISSION_SCOPE_VALUES:
|
|
228
|
+
errors.append(f"sources.json sources[{index}].permission_scope is not allowed")
|
|
229
|
+
if not isinstance(source.get("adapter"), dict):
|
|
230
|
+
errors.append(f"sources.json sources[{index}].adapter must be an object")
|
|
231
|
+
if not isinstance(source.get("source_map"), list):
|
|
232
|
+
errors.append(f"sources.json sources[{index}].source_map must be an array")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_primary_source(sources_doc: dict[str, Any], errors: list[str]) -> None:
|
|
236
|
+
primary = sources_doc.get("primary_source_id")
|
|
237
|
+
source_ids = _source_ids(sources_doc)
|
|
238
|
+
if not primary:
|
|
239
|
+
errors.append("sources.json primary_source_id is required")
|
|
240
|
+
elif primary not in source_ids:
|
|
241
|
+
errors.append("sources.json primary_source_id must refer to sources[].source_id")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _source_ids(sources_doc: dict[str, Any] | None) -> set[str]:
|
|
245
|
+
if not isinstance(sources_doc, dict):
|
|
246
|
+
return set()
|
|
247
|
+
sources = sources_doc.get("sources")
|
|
248
|
+
if not isinstance(sources, list):
|
|
249
|
+
return set()
|
|
250
|
+
return {
|
|
251
|
+
str(source.get("source_id"))
|
|
252
|
+
for source in sources
|
|
253
|
+
if isinstance(source, dict) and source.get("source_id")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _validate_index(index_text: str, metadata: dict[str, Any], errors: list[str]) -> None:
|
|
258
|
+
frontmatter = _FRONTMATTER_RE.match(index_text)
|
|
259
|
+
if not frontmatter:
|
|
260
|
+
errors.append("index.md must start with YAML frontmatter")
|
|
261
|
+
else:
|
|
262
|
+
_validate_frontmatter(frontmatter.group(1), metadata, errors)
|
|
263
|
+
title = str(metadata.get("title") or "").strip()
|
|
264
|
+
if title and f"# {title}" not in index_text:
|
|
265
|
+
errors.append("index.md must contain # <Title>")
|
|
266
|
+
if "## Source" not in index_text:
|
|
267
|
+
errors.append("index.md must contain ## Source")
|
|
268
|
+
profile = str(metadata.get("profile") or "")
|
|
269
|
+
required_section = PROFILE_SECTIONS.get(profile)
|
|
270
|
+
if required_section and f"## {required_section}" not in index_text:
|
|
271
|
+
errors.append(f"index.md must contain ## {required_section}")
|
|
272
|
+
if metadata.get("status") in {"verified", "partial"}:
|
|
273
|
+
body = _FRONTMATTER_RE.sub("", index_text).strip()
|
|
274
|
+
if not body or "_No extracted content._" in body:
|
|
275
|
+
errors.append("index.md body must be non-empty for verified/partial packages")
|
|
276
|
+
if metadata.get("status") == "failed" and "failed" not in index_text.lower():
|
|
277
|
+
errors.append("failed packages must include a visible failure reason")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_frontmatter(frontmatter_text: str, metadata: dict[str, Any], errors: list[str]) -> None:
|
|
281
|
+
values: dict[str, str] = {}
|
|
282
|
+
for line in frontmatter_text.splitlines():
|
|
283
|
+
if not line.strip():
|
|
284
|
+
continue
|
|
285
|
+
if ":" not in line:
|
|
286
|
+
errors.append(f"index.md frontmatter line is not key/value: {line}")
|
|
287
|
+
continue
|
|
288
|
+
key, raw_value = line.split(":", 1)
|
|
289
|
+
key = key.strip()
|
|
290
|
+
if key in values:
|
|
291
|
+
errors.append(f"index.md frontmatter duplicate key: {key}")
|
|
292
|
+
continue
|
|
293
|
+
values[key] = _frontmatter_scalar_value(raw_value.strip())
|
|
294
|
+
|
|
295
|
+
expected = {
|
|
296
|
+
"title": str(metadata.get("title") or ""),
|
|
297
|
+
"profile": str(metadata.get("profile") or ""),
|
|
298
|
+
"source_platform": str(metadata.get("source_platform") or ""),
|
|
299
|
+
"status": str(metadata.get("status") or ""),
|
|
300
|
+
"source_level": str(metadata.get("source_level") or ""),
|
|
301
|
+
"created_at": str(metadata.get("created_at") or ""),
|
|
302
|
+
}
|
|
303
|
+
for key, expected_value in expected.items():
|
|
304
|
+
actual = values.get(key)
|
|
305
|
+
if actual is None:
|
|
306
|
+
errors.append(f"index.md frontmatter missing key: {key}")
|
|
307
|
+
elif actual != expected_value:
|
|
308
|
+
errors.append(f"index.md frontmatter {key} does not match metadata.json")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _frontmatter_scalar_value(value: str) -> str:
|
|
312
|
+
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
|
313
|
+
try:
|
|
314
|
+
parsed = json.loads(value)
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
return value.strip('"')
|
|
317
|
+
return str(parsed)
|
|
318
|
+
return value
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _validate_assets(package_path: Path, errors: list[str]) -> None:
|
|
322
|
+
assets_path = package_path / "assets"
|
|
323
|
+
if not assets_path.exists():
|
|
324
|
+
return
|
|
325
|
+
for path in assets_path.rglob("*"):
|
|
326
|
+
if not path.is_file() or path.name == ".gitkeep":
|
|
327
|
+
continue
|
|
328
|
+
relative_path = path.relative_to(package_path)
|
|
329
|
+
suffix = path.suffix.lower()
|
|
330
|
+
if suffix in _RAW_ARTIFACT_SUFFIXES:
|
|
331
|
+
errors.append(f"raw/debug artifact is not allowed by default: {relative_path}")
|
|
332
|
+
if any(is_sensitive_key(part) or is_sensitive_query_key(part) for part in path.parts):
|
|
333
|
+
errors.append(f"credential-like asset path is not allowed: {relative_path}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _validate_optional_sidecars(
|
|
337
|
+
package_path: Path,
|
|
338
|
+
metadata: dict[str, Any] | None,
|
|
339
|
+
source_ids: set[str],
|
|
340
|
+
errors: list[str],
|
|
341
|
+
warnings: list[str],
|
|
342
|
+
) -> None:
|
|
343
|
+
profile = str(metadata.get("profile") or "") if isinstance(metadata, dict) else ""
|
|
344
|
+
allowed_sidecars = _allowed_sidecars_for_profile(profile)
|
|
345
|
+
for path in package_path.iterdir():
|
|
346
|
+
if path.name in {"index.md", "metadata.json", "sources.json", "assets"}:
|
|
347
|
+
continue
|
|
348
|
+
if not path.is_file():
|
|
349
|
+
continue
|
|
350
|
+
if path.name not in allowed_sidecars:
|
|
351
|
+
errors.append(f"{path.name} is not allowed for profile: {profile or 'unknown'}")
|
|
352
|
+
continue
|
|
353
|
+
if path.suffix == ".json":
|
|
354
|
+
value = _read_json_object(path, errors)
|
|
355
|
+
if value is not None:
|
|
356
|
+
_validate_json_sidecar(path.name, value, source_ids, errors)
|
|
357
|
+
elif path.suffix == ".md" and path.name != "index.md":
|
|
358
|
+
text = _read_text(path, errors)
|
|
359
|
+
if text is not None and not text.strip():
|
|
360
|
+
warnings.append(f"{path.name} is empty")
|
|
361
|
+
else:
|
|
362
|
+
errors.append(f"unsupported sidecar file: {path.name}")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _allowed_sidecars_for_profile(profile: str) -> set[str]:
|
|
366
|
+
return {
|
|
367
|
+
"article": set(),
|
|
368
|
+
"media": {"transcript.md", "timeline.json"},
|
|
369
|
+
"conversation": {"messages.json"},
|
|
370
|
+
"document": {"chunks.json"},
|
|
371
|
+
"process": {"transcript.md", "timeline.json", "steps.json"},
|
|
372
|
+
}.get(profile, set())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _validate_json_sidecar(
|
|
376
|
+
name: str,
|
|
377
|
+
value: dict[str, Any],
|
|
378
|
+
source_ids: set[str],
|
|
379
|
+
errors: list[str],
|
|
380
|
+
) -> None:
|
|
381
|
+
if name == "timeline.json":
|
|
382
|
+
_validate_sidecar_items(
|
|
383
|
+
name,
|
|
384
|
+
value.get("items"),
|
|
385
|
+
("id", "start", "end", "text", "source_map"),
|
|
386
|
+
source_ids,
|
|
387
|
+
errors,
|
|
388
|
+
)
|
|
389
|
+
elif name == "messages.json":
|
|
390
|
+
_validate_sidecar_items(
|
|
391
|
+
name,
|
|
392
|
+
value.get("messages"),
|
|
393
|
+
("id", "timestamp", "speaker", "text", "attachments", "links", "source_map"),
|
|
394
|
+
source_ids,
|
|
395
|
+
errors,
|
|
396
|
+
)
|
|
397
|
+
elif name == "steps.json":
|
|
398
|
+
_validate_sidecar_items(
|
|
399
|
+
name,
|
|
400
|
+
value.get("steps"),
|
|
401
|
+
("id", "title", "text", "evidence_refs", "source_map"),
|
|
402
|
+
source_ids,
|
|
403
|
+
errors,
|
|
404
|
+
)
|
|
405
|
+
elif name == "chunks.json":
|
|
406
|
+
_validate_sidecar_items(
|
|
407
|
+
name,
|
|
408
|
+
value.get("chunks"),
|
|
409
|
+
("id", "text", "source_map"),
|
|
410
|
+
source_ids,
|
|
411
|
+
errors,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _validate_sidecar_items(
|
|
416
|
+
name: str,
|
|
417
|
+
items: Any,
|
|
418
|
+
required_fields: tuple[str, ...],
|
|
419
|
+
source_ids: set[str],
|
|
420
|
+
errors: list[str],
|
|
421
|
+
) -> None:
|
|
422
|
+
if not isinstance(items, list) or not items:
|
|
423
|
+
errors.append(f"{name} must contain a non-empty array")
|
|
424
|
+
return
|
|
425
|
+
for index, item in enumerate(items):
|
|
426
|
+
label = f"{name}[{index}]"
|
|
427
|
+
if not isinstance(item, dict):
|
|
428
|
+
errors.append(f"{label} must be an object")
|
|
429
|
+
continue
|
|
430
|
+
_require_fields(item, required_fields, label, errors)
|
|
431
|
+
source_map = item.get("source_map")
|
|
432
|
+
if not isinstance(source_map, list):
|
|
433
|
+
errors.append(f"{label}.source_map must be an array")
|
|
434
|
+
continue
|
|
435
|
+
_validate_sidecar_source_map(name, index, source_map, source_ids, errors)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _validate_sidecar_source_map(
|
|
439
|
+
name: str,
|
|
440
|
+
index: int,
|
|
441
|
+
source_map: list[Any],
|
|
442
|
+
source_ids: set[str],
|
|
443
|
+
errors: list[str],
|
|
444
|
+
) -> None:
|
|
445
|
+
for map_index, entry in enumerate(source_map):
|
|
446
|
+
label = f"{name}[{index}].source_map[{map_index}]"
|
|
447
|
+
if not isinstance(entry, dict):
|
|
448
|
+
errors.append(f"{label} must be an object")
|
|
449
|
+
continue
|
|
450
|
+
source_ref = entry.get("source_ref")
|
|
451
|
+
if not isinstance(source_ref, dict):
|
|
452
|
+
errors.append(f"{label}.source_ref must be an object")
|
|
453
|
+
continue
|
|
454
|
+
source_id = source_ref.get("source_id")
|
|
455
|
+
if source_id and str(source_id) not in source_ids:
|
|
456
|
+
errors.append(f"{label}.source_ref.source_id must refer to sources[].source_id")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _scan_for_secrets(package_path: Path, errors: list[str]) -> None:
|
|
460
|
+
for path in package_path.rglob("*"):
|
|
461
|
+
if not path.is_file():
|
|
462
|
+
continue
|
|
463
|
+
if path.name == ".gitkeep":
|
|
464
|
+
continue
|
|
465
|
+
if path.suffix.lower() not in _TEXT_SCAN_SUFFIXES:
|
|
466
|
+
continue
|
|
467
|
+
try:
|
|
468
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
469
|
+
except OSError:
|
|
470
|
+
continue
|
|
471
|
+
if _SECRET_TEXT_RE.search(text) or any(pattern.search(text) for pattern in SENSITIVE_TEXT_PATTERNS):
|
|
472
|
+
errors.append(f"possible secret-bearing text in {path.relative_to(package_path)}")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _require_fields(
|
|
476
|
+
value: dict[str, Any],
|
|
477
|
+
required_fields: tuple[str, ...],
|
|
478
|
+
label: str,
|
|
479
|
+
errors: list[str],
|
|
480
|
+
) -> None:
|
|
481
|
+
for field_name in required_fields:
|
|
482
|
+
if field_name not in value:
|
|
483
|
+
errors.append(f"{label} missing required field: {field_name}")
|
|
484
|
+
continue
|
|
485
|
+
field_value = value[field_name]
|
|
486
|
+
if field_value is None:
|
|
487
|
+
errors.append(f"{label} field must not be null: {field_name}")
|
|
488
|
+
elif isinstance(field_value, str) and not field_value.strip():
|
|
489
|
+
errors.append(f"{label} field must not be empty: {field_name}")
|