kd-engine 1.0.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.
- kd_engine-1.0.0.dist-info/METADATA +19 -0
- kd_engine-1.0.0.dist-info/RECORD +118 -0
- kd_engine-1.0.0.dist-info/WHEEL +5 -0
- kd_engine-1.0.0.dist-info/entry_points.txt +2 -0
- kd_engine-1.0.0.dist-info/licenses/LICENSE-MIT +21 -0
- kd_engine-1.0.0.dist-info/top_level.txt +1 -0
- ytk/__init__.py +2 -0
- ytk/__main__.py +21 -0
- ytk/batch/__init__.py +39 -0
- ytk/batch/runner.py +1149 -0
- ytk/cache/__init__.py +34 -0
- ytk/cache/v2.py +562 -0
- ytk/cleaning/density.py +40 -0
- ytk/cleaning/fillers.py +18 -0
- ytk/cleaning/sentence_boundary.py +30 -0
- ytk/cleaning/service.py +62 -0
- ytk/cli.py +1281 -0
- ytk/commands/__init__.py +3 -0
- ytk/commands/merge.py +600 -0
- ytk/config.py +757 -0
- ytk/dedup/service.py +166 -0
- ytk/doctor.py +891 -0
- ytk/dotenv.py +334 -0
- ytk/errors.py +38 -0
- ytk/extraction/service.py +451 -0
- ytk/extraction/strategies/base.py +16 -0
- ytk/extraction/strategies/code_block.py +285 -0
- ytk/extraction/strategies/heuristic.py +791 -0
- ytk/extraction/strategies/local_model.py +56 -0
- ytk/extraction/text_context.py +177 -0
- ytk/extraction/visual_context.py +159 -0
- ytk/ingestion/adapters/__init__.py +1 -0
- ytk/ingestion/adapters/_common.py +865 -0
- ytk/ingestion/adapters/base.py +160 -0
- ytk/ingestion/adapters/bilibili.py +271 -0
- ytk/ingestion/adapters/douyin.py +445 -0
- ytk/ingestion/adapters/tiktok.py +102 -0
- ytk/ingestion/adapters/xiaohongshu.py +758 -0
- ytk/ingestion/adapters/youtube.py +74 -0
- ytk/ingestion/browser_acquired.py +220 -0
- ytk/ingestion/http_client.py +43 -0
- ytk/ingestion/local_import.py +202 -0
- ytk/ingestion/media.py +468 -0
- ytk/ingestion/providers/asr.py +902 -0
- ytk/ingestion/providers/base.py +13 -0
- ytk/ingestion/providers/dtk_client.py +816 -0
- ytk/ingestion/providers/minimax.py +350 -0
- ytk/ingestion/providers/transcript_api.py +336 -0
- ytk/ingestion/providers/xhs_client.py +646 -0
- ytk/ingestion/providers/ytdlp.py +400 -0
- ytk/ingestion/registry.py +189 -0
- ytk/ingestion/resolver.py +417 -0
- ytk/ingestion/search.py +56 -0
- ytk/ingestion/service.py +167 -0
- ytk/ingestion/subtitles.py +602 -0
- ytk/ingestion/transcript_cache.py +212 -0
- ytk/ingestion/url_parser.py +53 -0
- ytk/ingestion/video_frames.py +292 -0
- ytk/ingestion/ytdlp_acquire.py +754 -0
- ytk/logging.py +383 -0
- ytk/models/common.py +20 -0
- ytk/models/content.py +451 -0
- ytk/models/knowledge.py +43 -0
- ytk/models/migrations.py +262 -0
- ytk/models/skill.py +13 -0
- ytk/models/source.py +265 -0
- ytk/models/topic.py +18 -0
- ytk/models/transcript.py +30 -0
- ytk/ocr/__init__.py +65 -0
- ytk/ocr/engine.py +245 -0
- ytk/ocr/layout.py +347 -0
- ytk/ocr/pipeline.py +440 -0
- ytk/ocr/rapidocr_engine.py +144 -0
- ytk/pipeline/acquisition_checkpoint.py +240 -0
- ytk/pipeline/artifacts.py +18 -0
- ytk/pipeline/orchestrator.py +598 -0
- ytk/pipeline/processing_identity.py +18 -0
- ytk/pipeline/retry.py +728 -0
- ytk/pipeline/runners.py +578 -0
- ytk/pipeline/state.py +1107 -0
- ytk/pipeline/v2.py +1863 -0
- ytk/py.typed +1 -0
- ytk/quality/__init__.py +9 -0
- ytk/quality/completeness.py +260 -0
- ytk/renderers/base.py +9 -0
- ytk/renderers/citation_alignment.py +617 -0
- ytk/renderers/evidence_formatter.py +362 -0
- ytk/renderers/json_renderer.py +13 -0
- ytk/renderers/jsonl_renderer.py +18 -0
- ytk/renderers/knowledge_doc_renderer.py +1984 -0
- ytk/renderers/markdown_renderer.py +540 -0
- ytk/renderers/obsidian_renderer.py +138 -0
- ytk/renderers/skill_package.py +21 -0
- ytk/renderers/source_links.py +309 -0
- ytk/renderers/summary_renderer.py +163 -0
- ytk/renderers/v2.py +171 -0
- ytk/renderers/v2_jsonl.py +165 -0
- ytk/renderers/v2_markdown.py +477 -0
- ytk/renderers/v2_obsidian.py +113 -0
- ytk/renderers/v2_summary.py +220 -0
- ytk/segmentation/change_points.py +50 -0
- ytk/segmentation/embeddings.py +45 -0
- ytk/segmentation/service.py +318 -0
- ytk/skill_builder/__init__.py +1 -0
- ytk/skill_builder/builder.py +419 -0
- ytk/skill_builder/templates/SKILL.md.j2 +85 -0
- ytk/skill_builder/templates/evals/evals.json.j2 +25 -0
- ytk/skill_builder/templates/examples/example-input.md.j2 +5 -0
- ytk/skill_builder/templates/examples/example-output.md.j2 +8 -0
- ytk/skill_builder/templates/references/commands.md.j2 +34 -0
- ytk/skill_builder/templates/references/concepts.md.j2 +22 -0
- ytk/skill_builder/templates/references/configs.md.j2 +31 -0
- ytk/skill_builder/templates/references/evidence-policy.md.j2 +27 -0
- ytk/skill_builder/templates/references/source-summary.md.j2 +24 -0
- ytk/skill_builder/templates/scripts/build_skill.py.j2 +14 -0
- ytk/skill_builder/templates/scripts/validate_output.py.j2 +136 -0
- ytk/skill_builder/templates.py +29 -0
- ytk/text_normalization.py +44 -0
ytk/batch/runner.py
ADDED
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
"""v2 batch execution (T40.1/T40.2).
|
|
2
|
+
|
|
3
|
+
Identity, directories and resume are driven by the canonical source id, never
|
|
4
|
+
by input order:
|
|
5
|
+
|
|
6
|
+
* output dir = ``<out>/<platform>/<stable-source-id>`` (readable title may only
|
|
7
|
+
ever be a suffix, never part of the key);
|
|
8
|
+
* legacy ``video-NNN`` dirs are *discovered* (strict identity verification,
|
|
9
|
+
never bulk-renamed) so an interrupted pre-v2 batch can still be resumed;
|
|
10
|
+
* duplicates are removed after canonicalization (BV/av/short-long chains that
|
|
11
|
+
resolve to the same source are processed once) and the original alias is
|
|
12
|
+
recorded in ``.batch-aliases.json``;
|
|
13
|
+
* resume decisions are per format: verified manifest outputs (``output_valid``),
|
|
14
|
+
justified ``not_applicable``/``NO_KNOWLEDGE`` judgments, and legacy marker
|
|
15
|
+
coverage. A corrupt artifact can never be skipped; ``--refresh`` re-runs the
|
|
16
|
+
requested formats but never hides an unresolved remote submission;
|
|
17
|
+
* one blocked platform/source never aborts the remaining sources; the report
|
|
18
|
+
counts per platform / format / status and the exit code is 1 iff any source
|
|
19
|
+
failed or was blocked.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import inspect
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from collections.abc import Callable, Mapping, MutableMapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from ytk.config import AppSettings
|
|
37
|
+
from ytk.errors import UserFacingError
|
|
38
|
+
from ytk.ingestion.resolver import resolve_source
|
|
39
|
+
from ytk.logging import redact_text, redact_url
|
|
40
|
+
from ytk.models.source import SUPPORTED_PLATFORMS, ContentRef
|
|
41
|
+
from ytk.pipeline.state import (
|
|
42
|
+
MANIFEST_FILENAME,
|
|
43
|
+
OUTPUT_VALIDATOR_VERSION,
|
|
44
|
+
OVERALL_BLOCKED,
|
|
45
|
+
OVERALL_COMPLETE,
|
|
46
|
+
OVERALL_FAILED,
|
|
47
|
+
OVERALL_NO_KNOWLEDGE,
|
|
48
|
+
OVERALL_PARTIAL,
|
|
49
|
+
REASON_OUTPUTS_NOT_APPLICABLE,
|
|
50
|
+
ArtifactStore,
|
|
51
|
+
ManifestV2,
|
|
52
|
+
derive_overall_status,
|
|
53
|
+
manifest_recorded_outcome,
|
|
54
|
+
output_entry_claims_no_knowledge,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
LOGGER = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
BATCH_MARKER_FILENAME = ".batch-complete.json"
|
|
60
|
+
BATCH_ALIASES_FILENAME = ".batch-aliases.json"
|
|
61
|
+
BATCH_REPORT_FILENAME = ".batch-report.json"
|
|
62
|
+
BATCH_MARKER_SCHEMA = 2
|
|
63
|
+
|
|
64
|
+
#: Validator version of the v2 pipeline outputs (kept in sync with pipeline/v2.py).
|
|
65
|
+
V2_VALIDATOR_VERSION = OUTPUT_VALIDATOR_VERSION
|
|
66
|
+
|
|
67
|
+
#: Resume diagnostics recorded when a derived completion cannot be trusted.
|
|
68
|
+
RESUME_REASON_STALE_MARKER = "STALE_MARKER"
|
|
69
|
+
RESUME_REASON_MANIFEST_MISSING = "MANIFEST_MISSING"
|
|
70
|
+
|
|
71
|
+
_STATUS_COMPLETE = "complete"
|
|
72
|
+
_STATUS_PARTIAL = "partial"
|
|
73
|
+
_STATUS_NO_KNOWLEDGE = "no_knowledge"
|
|
74
|
+
_STATUS_NOT_APPLICABLE = "not_applicable"
|
|
75
|
+
_STATUS_SKIPPED = "skipped"
|
|
76
|
+
_STATUS_FAILED = "failed"
|
|
77
|
+
_STATUS_BLOCKED = "blocked"
|
|
78
|
+
|
|
79
|
+
_FAILED_STATUSES = frozenset({_STATUS_FAILED, _STATUS_BLOCKED})
|
|
80
|
+
|
|
81
|
+
_OUTCOME_TO_STATUS: dict[str, str] = {
|
|
82
|
+
OVERALL_COMPLETE: _STATUS_COMPLETE,
|
|
83
|
+
OVERALL_PARTIAL: _STATUS_PARTIAL,
|
|
84
|
+
OVERALL_NO_KNOWLEDGE: _STATUS_NO_KNOWLEDGE,
|
|
85
|
+
OVERALL_BLOCKED: _STATUS_BLOCKED,
|
|
86
|
+
OVERALL_FAILED: _STATUS_FAILED,
|
|
87
|
+
REASON_OUTPUTS_NOT_APPLICABLE: _STATUS_FAILED,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#: Categories that mean "cannot proceed here" rather than "our bug".
|
|
91
|
+
_BLOCKED_CATEGORIES = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"auth_required",
|
|
94
|
+
"permission_denied",
|
|
95
|
+
"content_removed",
|
|
96
|
+
"rate_limited",
|
|
97
|
+
"model_missing",
|
|
98
|
+
"unknown_task",
|
|
99
|
+
"service_unavailable",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
# Result records
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
@dataclass
|
|
108
|
+
class SourceResult:
|
|
109
|
+
"""One input value's batch outcome (redacted before it reaches the report)."""
|
|
110
|
+
|
|
111
|
+
index: int
|
|
112
|
+
input_value: str
|
|
113
|
+
source_id: str | None = None
|
|
114
|
+
identity_key: str | None = None
|
|
115
|
+
acquired_source_id: str | None = None
|
|
116
|
+
platform: str | None = None
|
|
117
|
+
output_dir: str | None = None
|
|
118
|
+
status: str = _STATUS_FAILED
|
|
119
|
+
outcome: str | None = None
|
|
120
|
+
formats: dict[str, str] = field(default_factory=dict)
|
|
121
|
+
requested_formats: list[str] = field(default_factory=list)
|
|
122
|
+
error_code: str | None = None
|
|
123
|
+
error: str | None = None
|
|
124
|
+
resume_reason: str | None = None
|
|
125
|
+
resumed: bool = False
|
|
126
|
+
duration_ms: int = 0
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def failed(self) -> bool:
|
|
130
|
+
return self.status in _FAILED_STATUSES
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def skipped(self) -> bool:
|
|
134
|
+
return self.status == _STATUS_SKIPPED
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
return {
|
|
138
|
+
"index": self.index,
|
|
139
|
+
"input": self.input_value,
|
|
140
|
+
"source_id": self.source_id,
|
|
141
|
+
"identity_key": self.identity_key,
|
|
142
|
+
"acquired_source_id": self.acquired_source_id,
|
|
143
|
+
"platform": self.platform,
|
|
144
|
+
"output_dir": self.output_dir,
|
|
145
|
+
"status": self.status,
|
|
146
|
+
"outcome": self.outcome,
|
|
147
|
+
"formats": dict(self.formats),
|
|
148
|
+
"requested_formats": list(self.requested_formats),
|
|
149
|
+
"error_code": self.error_code,
|
|
150
|
+
"error": self.error,
|
|
151
|
+
"resume_reason": self.resume_reason,
|
|
152
|
+
"resumed": self.resumed,
|
|
153
|
+
"duration_ms": self.duration_ms,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class BatchReport:
|
|
159
|
+
"""Per-source results plus the counters the CLI/CI print."""
|
|
160
|
+
|
|
161
|
+
out_root: Path
|
|
162
|
+
results: list[SourceResult] = field(default_factory=list)
|
|
163
|
+
started_at: float = field(default_factory=time.time)
|
|
164
|
+
finished_at: float = 0.0
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def total(self) -> int:
|
|
168
|
+
return len(self.results)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def processed(self) -> int:
|
|
172
|
+
"""Sources that actually executed (resume skips are not processed)."""
|
|
173
|
+
return sum(
|
|
174
|
+
1
|
|
175
|
+
for result in self.results
|
|
176
|
+
if not result.failed and not result.skipped and not result.resumed
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def skipped(self) -> int:
|
|
181
|
+
"""Duplicates plus resume skips (historical summary semantics)."""
|
|
182
|
+
return sum(1 for result in self.results if result.skipped or result.resumed)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def failed(self) -> int:
|
|
186
|
+
return sum(1 for result in self.results if result.failed)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def exit_code(self) -> int:
|
|
190
|
+
return 1 if self.failed else 0
|
|
191
|
+
|
|
192
|
+
def status_counts(self) -> dict[str, int]:
|
|
193
|
+
return dict(Counter(result.status for result in self.results))
|
|
194
|
+
|
|
195
|
+
def platform_counts(self) -> dict[str, dict[str, int]]:
|
|
196
|
+
counts: dict[str, Counter[str]] = {}
|
|
197
|
+
for result in self.results:
|
|
198
|
+
platform = result.platform or "unknown"
|
|
199
|
+
counts.setdefault(platform, Counter())[result.status] += 1
|
|
200
|
+
return {platform: dict(counter) for platform, counter in sorted(counts.items())}
|
|
201
|
+
|
|
202
|
+
def format_counts(self) -> dict[str, dict[str, int]]:
|
|
203
|
+
counts: dict[str, Counter[str]] = {}
|
|
204
|
+
for result in self.results:
|
|
205
|
+
for fmt in result.requested_formats:
|
|
206
|
+
status = result.formats.get(fmt, "not_run")
|
|
207
|
+
counts.setdefault(fmt, Counter())[status] += 1
|
|
208
|
+
return {fmt: dict(counter) for fmt, counter in sorted(counts.items())}
|
|
209
|
+
|
|
210
|
+
def legacy_summary_line(self) -> str:
|
|
211
|
+
"""The historical one-line summary (kept for scripts that parse it)."""
|
|
212
|
+
return (
|
|
213
|
+
f"Batch Complete: {self.processed} processed, {self.skipped} skipped, "
|
|
214
|
+
f"{self.failed} failed (of {self.total} total)"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def summary_lines(self) -> list[str]:
|
|
218
|
+
lines = [self.legacy_summary_line()]
|
|
219
|
+
status_counts = self.status_counts()
|
|
220
|
+
lines.append(
|
|
221
|
+
"Status counts: "
|
|
222
|
+
+ ", ".join(f"{status}={count}" for status, count in sorted(status_counts.items()))
|
|
223
|
+
)
|
|
224
|
+
for platform, counts in self.platform_counts().items():
|
|
225
|
+
detail = ", ".join(f"{status}={count}" for status, count in sorted(counts.items()))
|
|
226
|
+
lines.append(f" platform {platform}: {detail}")
|
|
227
|
+
for fmt, counts in self.format_counts().items():
|
|
228
|
+
detail = ", ".join(f"{status}={count}" for status, count in sorted(counts.items()))
|
|
229
|
+
lines.append(f" format {fmt}: {detail}")
|
|
230
|
+
for result in self.results:
|
|
231
|
+
label = result.source_id or result.input_value
|
|
232
|
+
suffix = f" -> {result.output_dir}" if result.output_dir else ""
|
|
233
|
+
if result.failed:
|
|
234
|
+
lines.append(
|
|
235
|
+
f" [{result.index}/{self.total}] {result.status.upper()}: "
|
|
236
|
+
f"{result.input_value} ({label}){suffix}"
|
|
237
|
+
f"{' — ' + result.error if result.error else ''}"
|
|
238
|
+
)
|
|
239
|
+
elif result.resumed or result.skipped:
|
|
240
|
+
lines.append(f" [{result.index}/{self.total}] {result.status}: {label}{suffix}")
|
|
241
|
+
if result.resume_reason:
|
|
242
|
+
lines.append(
|
|
243
|
+
f" [{result.index}/{self.total}] resume invalidated "
|
|
244
|
+
f"({result.resume_reason}): {label}{suffix}"
|
|
245
|
+
)
|
|
246
|
+
return lines
|
|
247
|
+
|
|
248
|
+
def to_dict(self) -> dict[str, Any]:
|
|
249
|
+
return {
|
|
250
|
+
"schema": 1,
|
|
251
|
+
"out_root": str(self.out_root),
|
|
252
|
+
"total": self.total,
|
|
253
|
+
"processed": self.processed,
|
|
254
|
+
"skipped": self.skipped,
|
|
255
|
+
"failed": self.failed,
|
|
256
|
+
"exit_code": self.exit_code,
|
|
257
|
+
"status_counts": self.status_counts(),
|
|
258
|
+
"platform_counts": self.platform_counts(),
|
|
259
|
+
"format_counts": self.format_counts(),
|
|
260
|
+
"started_at": self.started_at,
|
|
261
|
+
"finished_at": self.finished_at,
|
|
262
|
+
"results": [result.to_dict() for result in self.results],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# --------------------------------------------------------------------------- #
|
|
267
|
+
# Identity / directory helpers
|
|
268
|
+
# --------------------------------------------------------------------------- #
|
|
269
|
+
def resolve_batch_identity(
|
|
270
|
+
value: str, *, redirect_resolver: Callable[[str], str] | None = None
|
|
271
|
+
) -> ContentRef:
|
|
272
|
+
"""Canonical identity for one batch input (raises on unresolvable links)."""
|
|
273
|
+
return resolve_source(value, redirect_resolver=redirect_resolver)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _normalise_display_part(value: Any) -> str | None:
|
|
277
|
+
"""Canonical, filesystem-safe display part (Bilibili ``p``), or ``None``."""
|
|
278
|
+
if value in (None, ""):
|
|
279
|
+
return None
|
|
280
|
+
text = str(value).strip()
|
|
281
|
+
if not text:
|
|
282
|
+
return None
|
|
283
|
+
if text.isdigit():
|
|
284
|
+
number = int(text)
|
|
285
|
+
return str(number) if number >= 0 else None
|
|
286
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-._")
|
|
287
|
+
return safe or None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def display_part(ref: ContentRef) -> str | None:
|
|
291
|
+
"""The part selector carried by the ref (Bilibili ``?p=N``), if any."""
|
|
292
|
+
display = ref.display or {}
|
|
293
|
+
return _normalise_display_part(display.get("p"))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def batch_identity_key(ref: ContentRef) -> str:
|
|
297
|
+
"""Dedupe/identity key for a batch input.
|
|
298
|
+
|
|
299
|
+
Distinct Bilibili parts (``?p=1`` vs ``?p=2``) resolve to the same
|
|
300
|
+
``source_id`` *before acquisition* (the ``cid`` variant only appears after
|
|
301
|
+
the API call), so the display part is part of the batch identity. Without
|
|
302
|
+
this, the second part would be dropped as a duplicate (R-08/A03).
|
|
303
|
+
"""
|
|
304
|
+
part = display_part(ref)
|
|
305
|
+
if part is not None:
|
|
306
|
+
return f"{ref.source_id}#p={part}"
|
|
307
|
+
return ref.source_id
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def stable_source_dirname(ref: ContentRef) -> str:
|
|
311
|
+
"""Filesystem-safe, stable directory name for one source (title-free)."""
|
|
312
|
+
raw = ref.content_id
|
|
313
|
+
part = display_part(ref)
|
|
314
|
+
if part is not None:
|
|
315
|
+
raw = f"{raw}-p{part}"
|
|
316
|
+
if ref.variant_id:
|
|
317
|
+
raw = f"{raw}--{ref.variant_id}"
|
|
318
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip("-._") or "source"
|
|
319
|
+
if ".." in safe: # never expose a traversal-looking component
|
|
320
|
+
safe = safe.replace("..", "-").strip("-._") or "source"
|
|
321
|
+
if safe != raw or len(safe) > 100:
|
|
322
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
|
|
323
|
+
safe = f"{safe[:100]}-{digest}"
|
|
324
|
+
return safe
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def stable_output_dir(out_root: Path | str, ref: ContentRef) -> Path:
|
|
328
|
+
"""``<out>/<platform>/<stable-id>`` — the only key for a new batch."""
|
|
329
|
+
return Path(out_root) / ref.platform / stable_source_dirname(ref)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def default_formats_for(platform: str) -> set[str]:
|
|
333
|
+
"""Per-platform default formats (YouTube keeps its legacy triad)."""
|
|
334
|
+
from ytk.ingestion.registry import PlatformRegistry
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
registration = PlatformRegistry.default().get(platform)
|
|
338
|
+
except UserFacingError:
|
|
339
|
+
return {"json", "md"}
|
|
340
|
+
return set(registration.default_formats)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def requested_formats_for(
|
|
344
|
+
ref: ContentRef, *, formats: set[str] | None, formats_explicit: bool
|
|
345
|
+
) -> set[str]:
|
|
346
|
+
if formats_explicit and formats:
|
|
347
|
+
return set(formats)
|
|
348
|
+
return default_formats_for(ref.platform)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------- #
|
|
352
|
+
# Marker helpers (the marker is a *derived* cache; the manifest is authority)
|
|
353
|
+
# --------------------------------------------------------------------------- #
|
|
354
|
+
def read_batch_marker(directory: Path | str) -> dict[str, Any] | None:
|
|
355
|
+
path = Path(directory) / BATCH_MARKER_FILENAME
|
|
356
|
+
try:
|
|
357
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
358
|
+
except FileNotFoundError:
|
|
359
|
+
return None
|
|
360
|
+
except (OSError, json.JSONDecodeError):
|
|
361
|
+
return None
|
|
362
|
+
if not isinstance(payload, dict):
|
|
363
|
+
return None
|
|
364
|
+
if payload.get("schema") not in (1, BATCH_MARKER_SCHEMA):
|
|
365
|
+
return None
|
|
366
|
+
return payload
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def marker_source_id(
|
|
370
|
+
marker: Mapping[str, Any] | None,
|
|
371
|
+
*,
|
|
372
|
+
redirect_resolver: Callable[[str], str] | None = None,
|
|
373
|
+
) -> str | None:
|
|
374
|
+
"""Strict identity from a marker: a parseable source_id or resolvable URL."""
|
|
375
|
+
if not marker:
|
|
376
|
+
return None
|
|
377
|
+
source_id = marker.get("source_id")
|
|
378
|
+
if isinstance(source_id, str) and ":" in source_id:
|
|
379
|
+
platform = source_id.split(":", 1)[0]
|
|
380
|
+
if platform in SUPPORTED_PLATFORMS:
|
|
381
|
+
return source_id
|
|
382
|
+
url = marker.get("url")
|
|
383
|
+
if isinstance(url, str) and url:
|
|
384
|
+
try:
|
|
385
|
+
return resolve_source(url, redirect_resolver=redirect_resolver).source_id
|
|
386
|
+
except (UserFacingError, ValueError):
|
|
387
|
+
return None
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def legacy_output_dirs(
|
|
392
|
+
out_root: Path | str,
|
|
393
|
+
*,
|
|
394
|
+
redirect_resolver: Callable[[str], str] | None = None,
|
|
395
|
+
) -> dict[str, Path]:
|
|
396
|
+
"""Map ``source_id -> legacy video-NNN dir`` after strict identity checks.
|
|
397
|
+
|
|
398
|
+
Discovery only — legacy directories are never renamed or overwritten.
|
|
399
|
+
"""
|
|
400
|
+
mapping: dict[str, Path] = {}
|
|
401
|
+
root = Path(out_root)
|
|
402
|
+
if not root.is_dir():
|
|
403
|
+
return mapping
|
|
404
|
+
for directory in sorted(root.glob("video-*")):
|
|
405
|
+
if not directory.is_dir():
|
|
406
|
+
continue
|
|
407
|
+
marker = read_batch_marker(directory)
|
|
408
|
+
source_id = marker_source_id(marker, redirect_resolver=redirect_resolver)
|
|
409
|
+
if source_id is not None:
|
|
410
|
+
mapping.setdefault(source_id, directory)
|
|
411
|
+
return mapping
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _legacy_marker_covered(
|
|
415
|
+
marker: Mapping[str, Any] | None,
|
|
416
|
+
requested: set[str],
|
|
417
|
+
directory: Path,
|
|
418
|
+
*,
|
|
419
|
+
doc_verifier: Callable[[Path], bool] | None,
|
|
420
|
+
) -> set[str]:
|
|
421
|
+
"""Formats an old marker covers — with the historical doc-verification rule."""
|
|
422
|
+
if not marker:
|
|
423
|
+
return set()
|
|
424
|
+
covered = {str(fmt) for fmt in (marker.get("formats") or []) if isinstance(fmt, str)}
|
|
425
|
+
covered &= requested
|
|
426
|
+
if "doc" in covered and doc_verifier is not None and not doc_verifier(directory):
|
|
427
|
+
covered.discard("doc")
|
|
428
|
+
return covered
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def write_batch_marker(
|
|
432
|
+
directory: Path | str,
|
|
433
|
+
*,
|
|
434
|
+
ref: ContentRef,
|
|
435
|
+
requested: Sequence[str],
|
|
436
|
+
format_status: Mapping[str, str],
|
|
437
|
+
outcome: str,
|
|
438
|
+
manifest: ManifestV2 | None = None,
|
|
439
|
+
) -> Path:
|
|
440
|
+
"""Write the derived resume marker (never the primary completion authority)."""
|
|
441
|
+
payload: dict[str, Any] = {
|
|
442
|
+
"schema": BATCH_MARKER_SCHEMA,
|
|
443
|
+
"source_id": ref.source_id,
|
|
444
|
+
"platform": ref.platform,
|
|
445
|
+
"content_type": ref.content_type,
|
|
446
|
+
"url": redact_url(ref.canonical_url),
|
|
447
|
+
"formats": sorted(requested),
|
|
448
|
+
"format_status": dict(format_status),
|
|
449
|
+
"outcome": outcome,
|
|
450
|
+
"validator_version": V2_VALIDATOR_VERSION,
|
|
451
|
+
"verified": manifest is not None,
|
|
452
|
+
"ts": time.time(),
|
|
453
|
+
}
|
|
454
|
+
if manifest is not None:
|
|
455
|
+
payload["request_fingerprint"] = manifest.request_fingerprint
|
|
456
|
+
payload["source_revision"] = manifest.source_revision
|
|
457
|
+
payload["refresh_epoch"] = manifest.refresh_epoch
|
|
458
|
+
payload["manifest"] = MANIFEST_FILENAME
|
|
459
|
+
store = ArtifactStore(directory)
|
|
460
|
+
return store.write_json(BATCH_MARKER_FILENAME, payload)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _manifest_for(directory: Path) -> ManifestV2 | None:
|
|
464
|
+
try:
|
|
465
|
+
return ArtifactStore(directory).read_manifest()
|
|
466
|
+
except Exception: # noqa: BLE001 - a bad manifest is simply "no manifest"
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _status_from_manifest(manifest: ManifestV2, requested: Sequence[str]) -> tuple[str, str]:
|
|
471
|
+
"""``(batch status, outcome)`` derived from a manifest (authority)."""
|
|
472
|
+
overall, _reasons = derive_overall_status(manifest, requested=requested)
|
|
473
|
+
status = _OUTCOME_TO_STATUS.get(overall, _STATUS_FAILED)
|
|
474
|
+
if overall == OVERALL_FAILED and _all_not_applicable(manifest, requested):
|
|
475
|
+
return status, REASON_OUTPUTS_NOT_APPLICABLE
|
|
476
|
+
return status, overall
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _manifest_terminal_judgment(
|
|
480
|
+
manifest: ManifestV2, requested: Sequence[str]
|
|
481
|
+
) -> tuple[str, str] | None:
|
|
482
|
+
"""Reuse a deterministic all-not-applicable verdict recorded by the pipeline.
|
|
483
|
+
|
|
484
|
+
The pipeline writes ``format_status=not_applicable`` for every requested
|
|
485
|
+
format plus a top-level ``outcome`` (extra key) when the content is
|
|
486
|
+
NO_KNOWLEDGE or every requested format is inapplicable. Both verdicts are
|
|
487
|
+
content-level and stable for the same source revision + validator version,
|
|
488
|
+
so resuming must not re-dispatch them (A30). Missing/older metadata fails
|
|
489
|
+
closed (``None`` → the format is re-run).
|
|
490
|
+
"""
|
|
491
|
+
entries = [manifest.outputs.get(fmt) for fmt in requested]
|
|
492
|
+
if not entries or any(entry is None for entry in entries):
|
|
493
|
+
return None
|
|
494
|
+
assert all(entry is not None for entry in entries)
|
|
495
|
+
if not all(entry.status == "not_applicable" for entry in entries if entry is not None):
|
|
496
|
+
return None
|
|
497
|
+
if not all(
|
|
498
|
+
entry.validator_version == V2_VALIDATOR_VERSION
|
|
499
|
+
for entry in entries
|
|
500
|
+
if entry is not None
|
|
501
|
+
):
|
|
502
|
+
return None
|
|
503
|
+
if not _judgment_evidence_valid(manifest, entries):
|
|
504
|
+
return None
|
|
505
|
+
recorded_outcome = manifest_recorded_outcome(manifest)
|
|
506
|
+
claims_no_knowledge = recorded_outcome == OVERALL_NO_KNOWLEDGE or all(
|
|
507
|
+
entry is not None and output_entry_claims_no_knowledge(entry)
|
|
508
|
+
for entry in entries
|
|
509
|
+
)
|
|
510
|
+
if claims_no_knowledge:
|
|
511
|
+
return (_STATUS_NO_KNOWLEDGE, OVERALL_NO_KNOWLEDGE)
|
|
512
|
+
if recorded_outcome == OVERALL_FAILED:
|
|
513
|
+
return (_STATUS_FAILED, REASON_OUTPUTS_NOT_APPLICABLE)
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _judgment_evidence_valid(
|
|
518
|
+
manifest: ManifestV2, entries: Sequence[Any]
|
|
519
|
+
) -> bool:
|
|
520
|
+
for entry in entries:
|
|
521
|
+
if entry is None:
|
|
522
|
+
return False
|
|
523
|
+
evidence = getattr(entry, "evidence_fingerprint", None)
|
|
524
|
+
if evidence:
|
|
525
|
+
if not manifest.source_revision or evidence != manifest.source_revision:
|
|
526
|
+
return False
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _all_not_applicable(manifest: ManifestV2, requested: Sequence[str]) -> bool:
|
|
531
|
+
entries = [manifest.outputs.get(fmt) for fmt in requested]
|
|
532
|
+
return bool(entries) and all(
|
|
533
|
+
entry is not None and entry.status == "not_applicable" for entry in entries
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _manifest_covered_formats(
|
|
538
|
+
store: ArtifactStore,
|
|
539
|
+
manifest: ManifestV2,
|
|
540
|
+
requested: Sequence[str],
|
|
541
|
+
) -> set[str]:
|
|
542
|
+
"""Formats already terminal under the current manifest (fail-closed)."""
|
|
543
|
+
covered: set[str] = set()
|
|
544
|
+
for fmt in requested:
|
|
545
|
+
skip, _reason = store.should_skip_format(
|
|
546
|
+
fmt,
|
|
547
|
+
request_fingerprint=manifest.request_fingerprint,
|
|
548
|
+
evidence_fingerprint=manifest.source_revision,
|
|
549
|
+
checker_version=(
|
|
550
|
+
manifest.outputs[fmt].checker_version
|
|
551
|
+
if fmt in manifest.outputs
|
|
552
|
+
else None
|
|
553
|
+
),
|
|
554
|
+
validator_version=V2_VALIDATOR_VERSION,
|
|
555
|
+
source_revision=manifest.source_revision,
|
|
556
|
+
)
|
|
557
|
+
if skip:
|
|
558
|
+
covered.add(fmt)
|
|
559
|
+
return covered
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# --------------------------------------------------------------------------- #
|
|
563
|
+
# Pipeline invocation
|
|
564
|
+
# --------------------------------------------------------------------------- #
|
|
565
|
+
def _call_pipeline(
|
|
566
|
+
pipeline: Any,
|
|
567
|
+
*,
|
|
568
|
+
value: str,
|
|
569
|
+
output_dir: Path,
|
|
570
|
+
formats: set[str] | None,
|
|
571
|
+
fixture_transcript: Path | None,
|
|
572
|
+
fixture_content: Path | None,
|
|
573
|
+
doc_language: str | None,
|
|
574
|
+
with_reviewer_scaffolding: bool,
|
|
575
|
+
refresh: bool,
|
|
576
|
+
ref: ContentRef | None,
|
|
577
|
+
preserve_existing_formats: bool = False,
|
|
578
|
+
) -> Any:
|
|
579
|
+
"""Call either the v1 or the v2 pipeline using only supported kwargs."""
|
|
580
|
+
parameters: Any
|
|
581
|
+
try:
|
|
582
|
+
parameters = inspect.signature(pipeline.run).parameters
|
|
583
|
+
except (TypeError, ValueError): # pragma: no cover - exotic callables
|
|
584
|
+
parameters = {}
|
|
585
|
+
accepts_extra = any(
|
|
586
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
587
|
+
for parameter in parameters.values()
|
|
588
|
+
)
|
|
589
|
+
kwargs: dict[str, Any] = {"url": value, "output_dir": output_dir}
|
|
590
|
+
candidates: dict[str, Any] = {
|
|
591
|
+
"formats": formats,
|
|
592
|
+
"fixture_transcript": fixture_transcript,
|
|
593
|
+
"fixture_content": fixture_content,
|
|
594
|
+
"doc_language": doc_language,
|
|
595
|
+
"with_reviewer_scaffolding": with_reviewer_scaffolding,
|
|
596
|
+
"refresh": refresh,
|
|
597
|
+
"formats_explicit": formats is not None,
|
|
598
|
+
"platform": ref.platform if ref is not None else None,
|
|
599
|
+
"content_type": ref.content_type if ref is not None else None,
|
|
600
|
+
"preserve_existing_formats": preserve_existing_formats,
|
|
601
|
+
}
|
|
602
|
+
for name, candidate in candidates.items():
|
|
603
|
+
if candidate is not None and (name in parameters or accepts_extra):
|
|
604
|
+
kwargs[name] = candidate
|
|
605
|
+
return pipeline.run(**kwargs)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _interpret_v2_result(
|
|
609
|
+
result: Any, requested: Sequence[str], directory: Path
|
|
610
|
+
) -> tuple[str, str, dict[str, str], list[str]]:
|
|
611
|
+
"""Return ``(status, outcome, format_status, reasons)`` for a v2 run."""
|
|
612
|
+
|
|
613
|
+
def _field(name: str) -> Any:
|
|
614
|
+
if isinstance(result, Mapping):
|
|
615
|
+
return result.get(name)
|
|
616
|
+
return getattr(result, name, None)
|
|
617
|
+
|
|
618
|
+
outcome_raw = _field("outcome")
|
|
619
|
+
outcome = str(outcome_raw).upper() if outcome_raw is not None else ""
|
|
620
|
+
format_status = dict(_field("format_status") or {})
|
|
621
|
+
reasons = [str(reason) for reason in (_field("reasons") or [])]
|
|
622
|
+
|
|
623
|
+
manifest = _manifest_for(directory)
|
|
624
|
+
if manifest is not None:
|
|
625
|
+
# Manifest outputs are the authority when present; marker is derived.
|
|
626
|
+
authoritative = {
|
|
627
|
+
fmt: manifest.outputs[fmt].status
|
|
628
|
+
for fmt in requested
|
|
629
|
+
if fmt in manifest.outputs
|
|
630
|
+
}
|
|
631
|
+
if authoritative:
|
|
632
|
+
format_status = {**format_status, **authoritative}
|
|
633
|
+
if not outcome:
|
|
634
|
+
outcome, manifest_reasons = _status_from_manifest(manifest, requested)
|
|
635
|
+
reasons.extend(manifest_reasons)
|
|
636
|
+
if not outcome:
|
|
637
|
+
outcome = OVERALL_COMPLETE
|
|
638
|
+
if outcome == OVERALL_FAILED and all(
|
|
639
|
+
format_status.get(fmt) == "not_applicable" for fmt in requested
|
|
640
|
+
):
|
|
641
|
+
outcome = REASON_OUTPUTS_NOT_APPLICABLE
|
|
642
|
+
status = _OUTCOME_TO_STATUS.get(outcome, _STATUS_FAILED)
|
|
643
|
+
return status, outcome, format_status, reasons
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# --------------------------------------------------------------------------- #
|
|
647
|
+
# Runner
|
|
648
|
+
# --------------------------------------------------------------------------- #
|
|
649
|
+
def run_batch(
|
|
650
|
+
values: Sequence[str],
|
|
651
|
+
*,
|
|
652
|
+
out_root: Path | str,
|
|
653
|
+
settings: AppSettings | None = None,
|
|
654
|
+
formats: set[str] | None = None,
|
|
655
|
+
formats_explicit: bool = False,
|
|
656
|
+
fixture_transcript: Path | None = None,
|
|
657
|
+
fixture_content: Path | None = None,
|
|
658
|
+
doc_language: str | None = None,
|
|
659
|
+
with_reviewer_scaffolding: bool = False,
|
|
660
|
+
resume: bool = False,
|
|
661
|
+
refresh: bool = False,
|
|
662
|
+
legacy_pipeline_factory: Callable[..., Any] | None = None,
|
|
663
|
+
v2_pipeline_factory: Callable[..., Any] | None = None,
|
|
664
|
+
redirect_resolver: Callable[[str], str] | None = None,
|
|
665
|
+
doc_verifier: Callable[[Path], bool] | None = None,
|
|
666
|
+
echo: Callable[[str], None] = print,
|
|
667
|
+
) -> BatchReport:
|
|
668
|
+
"""Process every input once, per-source isolated, and return the report."""
|
|
669
|
+
settings = settings or AppSettings.load()
|
|
670
|
+
out_root = Path(out_root)
|
|
671
|
+
out_root.mkdir(parents=True, exist_ok=True)
|
|
672
|
+
report = BatchReport(out_root=out_root)
|
|
673
|
+
|
|
674
|
+
legacy_dirs = legacy_output_dirs(out_root, redirect_resolver=redirect_resolver) if (
|
|
675
|
+
resume or refresh
|
|
676
|
+
) else {}
|
|
677
|
+
pipelines: dict[str, Any] = {}
|
|
678
|
+
|
|
679
|
+
def _pipeline(kind: str) -> Any:
|
|
680
|
+
if kind not in pipelines:
|
|
681
|
+
factory = legacy_pipeline_factory if kind == "v1" else v2_pipeline_factory
|
|
682
|
+
if factory is None:
|
|
683
|
+
if kind == "v1":
|
|
684
|
+
from ytk.pipeline.orchestrator import build_default_pipeline
|
|
685
|
+
|
|
686
|
+
factory = build_default_pipeline
|
|
687
|
+
else:
|
|
688
|
+
from ytk.pipeline.v2 import build_v2_pipeline
|
|
689
|
+
|
|
690
|
+
factory = build_v2_pipeline
|
|
691
|
+
pipelines[kind] = factory(settings=settings)
|
|
692
|
+
return pipelines[kind]
|
|
693
|
+
|
|
694
|
+
seen_sources: dict[str, SourceResult] = {}
|
|
695
|
+
for index, raw_value in enumerate(values, start=1):
|
|
696
|
+
value = raw_value.strip()
|
|
697
|
+
if not value:
|
|
698
|
+
continue
|
|
699
|
+
result = SourceResult(index=index, input_value=redact_text(value))
|
|
700
|
+
started = time.monotonic()
|
|
701
|
+
try:
|
|
702
|
+
_process_one(
|
|
703
|
+
result,
|
|
704
|
+
value,
|
|
705
|
+
out_root=out_root,
|
|
706
|
+
settings=settings,
|
|
707
|
+
formats=formats,
|
|
708
|
+
formats_explicit=formats_explicit,
|
|
709
|
+
fixture_transcript=fixture_transcript,
|
|
710
|
+
fixture_content=fixture_content,
|
|
711
|
+
doc_language=doc_language,
|
|
712
|
+
with_reviewer_scaffolding=with_reviewer_scaffolding,
|
|
713
|
+
resume=resume,
|
|
714
|
+
refresh=refresh,
|
|
715
|
+
legacy_dirs=legacy_dirs,
|
|
716
|
+
pipeline_getter=_pipeline,
|
|
717
|
+
redirect_resolver=redirect_resolver,
|
|
718
|
+
doc_verifier=doc_verifier,
|
|
719
|
+
seen_sources=seen_sources,
|
|
720
|
+
echo=echo,
|
|
721
|
+
)
|
|
722
|
+
except Exception as exc: # noqa: BLE001 - one source must never abort the batch
|
|
723
|
+
_record_failure(result, exc)
|
|
724
|
+
finally:
|
|
725
|
+
result.duration_ms = int((time.monotonic() - started) * 1000)
|
|
726
|
+
report.results.append(result)
|
|
727
|
+
for dedupe_key in (
|
|
728
|
+
result.identity_key,
|
|
729
|
+
result.source_id,
|
|
730
|
+
result.acquired_source_id,
|
|
731
|
+
):
|
|
732
|
+
if dedupe_key:
|
|
733
|
+
seen_sources.setdefault(dedupe_key, result)
|
|
734
|
+
|
|
735
|
+
report.finished_at = time.time()
|
|
736
|
+
_write_alias_map(out_root, report)
|
|
737
|
+
_write_batch_report(out_root, report)
|
|
738
|
+
return report
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _process_one(
|
|
742
|
+
result: SourceResult,
|
|
743
|
+
value: str,
|
|
744
|
+
*,
|
|
745
|
+
out_root: Path,
|
|
746
|
+
settings: AppSettings,
|
|
747
|
+
formats: set[str] | None,
|
|
748
|
+
formats_explicit: bool,
|
|
749
|
+
fixture_transcript: Path | None,
|
|
750
|
+
fixture_content: Path | None,
|
|
751
|
+
doc_language: str | None,
|
|
752
|
+
with_reviewer_scaffolding: bool,
|
|
753
|
+
resume: bool,
|
|
754
|
+
refresh: bool,
|
|
755
|
+
legacy_dirs: Mapping[str, Path],
|
|
756
|
+
pipeline_getter: Callable[[str], Any],
|
|
757
|
+
redirect_resolver: Callable[[str], str] | None,
|
|
758
|
+
doc_verifier: Callable[[Path], bool] | None,
|
|
759
|
+
seen_sources: MutableMapping[str, SourceResult],
|
|
760
|
+
echo: Callable[[str], None],
|
|
761
|
+
) -> None:
|
|
762
|
+
try:
|
|
763
|
+
ref = resolve_batch_identity(value, redirect_resolver=redirect_resolver)
|
|
764
|
+
except (UserFacingError, ValueError) as exc:
|
|
765
|
+
result.status = _STATUS_FAILED
|
|
766
|
+
result.error_code = "IDENTITY_RESOLUTION_FAILED"
|
|
767
|
+
result.error = redact_text(str(exc))
|
|
768
|
+
return
|
|
769
|
+
result.source_id = ref.source_id
|
|
770
|
+
result.identity_key = batch_identity_key(ref)
|
|
771
|
+
result.platform = ref.platform
|
|
772
|
+
|
|
773
|
+
if not settings.is_platform_enabled(ref.platform):
|
|
774
|
+
result.status = _STATUS_BLOCKED
|
|
775
|
+
result.error_code = "PLATFORM_DISABLED"
|
|
776
|
+
result.error = f"platform {ref.platform!r} is disabled in configuration"
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
if result.identity_key in seen_sources:
|
|
780
|
+
duplicate = seen_sources[result.identity_key]
|
|
781
|
+
result.status = _STATUS_SKIPPED
|
|
782
|
+
result.error_code = "DUPLICATE_SOURCE"
|
|
783
|
+
result.error = (
|
|
784
|
+
f"duplicate of {duplicate.source_id or result.identity_key} (canonicalized)"
|
|
785
|
+
)
|
|
786
|
+
result.output_dir = duplicate.output_dir
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
directory = legacy_dirs.get(ref.source_id)
|
|
790
|
+
if directory is None:
|
|
791
|
+
directory = stable_output_dir(out_root, ref)
|
|
792
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
793
|
+
result.output_dir = str(directory)
|
|
794
|
+
|
|
795
|
+
requested = requested_formats_for(ref, formats=formats, formats_explicit=formats_explicit)
|
|
796
|
+
result.requested_formats = sorted(requested)
|
|
797
|
+
|
|
798
|
+
to_run, skip_status, skip_outcome, resume_reason = _plan_formats(
|
|
799
|
+
directory,
|
|
800
|
+
ref,
|
|
801
|
+
requested=requested,
|
|
802
|
+
resume=resume,
|
|
803
|
+
refresh=refresh,
|
|
804
|
+
doc_verifier=doc_verifier,
|
|
805
|
+
settings=settings,
|
|
806
|
+
doc_language=doc_language,
|
|
807
|
+
)
|
|
808
|
+
result.resume_reason = resume_reason
|
|
809
|
+
if resume_reason is not None and to_run:
|
|
810
|
+
echo(
|
|
811
|
+
f"[{result.index}] resume invalidated ({resume_reason}): {ref.source_id}"
|
|
812
|
+
f" — re-running {','.join(sorted(to_run))}"
|
|
813
|
+
)
|
|
814
|
+
if skip_status is not None and not to_run:
|
|
815
|
+
result.status = skip_status
|
|
816
|
+
result.outcome = skip_outcome
|
|
817
|
+
result.resumed = True
|
|
818
|
+
result.formats = _terminal_statuses(directory, sorted(requested)) or {
|
|
819
|
+
fmt: "complete" for fmt in requested
|
|
820
|
+
}
|
|
821
|
+
if skip_status in _FAILED_STATUSES:
|
|
822
|
+
result.error_code = skip_outcome or (
|
|
823
|
+
"OUTPUT_BLOCKED" if skip_status == _STATUS_BLOCKED else "OUTPUT_FAILED"
|
|
824
|
+
)
|
|
825
|
+
result.error = skip_outcome
|
|
826
|
+
echo(f"[{result.index}] resume skip ({skip_status}): {ref.source_id}")
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
kind = _pipeline_kind(ref, fixture_content=fixture_content)
|
|
830
|
+
pipeline = pipeline_getter(kind)
|
|
831
|
+
try:
|
|
832
|
+
run_result = _call_pipeline(
|
|
833
|
+
pipeline,
|
|
834
|
+
value=value,
|
|
835
|
+
output_dir=directory,
|
|
836
|
+
formats=to_run or requested,
|
|
837
|
+
fixture_transcript=fixture_transcript,
|
|
838
|
+
fixture_content=fixture_content,
|
|
839
|
+
doc_language=doc_language,
|
|
840
|
+
with_reviewer_scaffolding=with_reviewer_scaffolding,
|
|
841
|
+
refresh=refresh,
|
|
842
|
+
ref=ref,
|
|
843
|
+
preserve_existing_formats=resume and not refresh,
|
|
844
|
+
)
|
|
845
|
+
except (UserFacingError, ValueError) as exc:
|
|
846
|
+
_record_failure(result, exc)
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
if kind == "v1":
|
|
850
|
+
if doc_verifier is not None and "doc" in requested and not doc_verifier(directory):
|
|
851
|
+
raise RuntimeError(
|
|
852
|
+
"knowledge document is FALLBACK or unverified; batch completion not recorded"
|
|
853
|
+
)
|
|
854
|
+
outcome = OVERALL_COMPLETE
|
|
855
|
+
format_status = {fmt: "complete" for fmt in requested}
|
|
856
|
+
manifest = None
|
|
857
|
+
else:
|
|
858
|
+
status, outcome, format_status, reasons = _interpret_v2_result(
|
|
859
|
+
run_result, sorted(requested), directory
|
|
860
|
+
)
|
|
861
|
+
manifest = _manifest_for(directory)
|
|
862
|
+
result.formats = format_status
|
|
863
|
+
result.outcome = outcome
|
|
864
|
+
result.status = status
|
|
865
|
+
_reconcile_acquired_identity(result, ref, manifest, seen_sources)
|
|
866
|
+
if status in _FAILED_STATUSES:
|
|
867
|
+
result.error_code = outcome if outcome == REASON_OUTPUTS_NOT_APPLICABLE else (
|
|
868
|
+
"OUTPUT_FAILED" if status == _STATUS_FAILED else "OUTPUT_BLOCKED"
|
|
869
|
+
)
|
|
870
|
+
result.error = "; ".join(reasons[:3]) or None
|
|
871
|
+
return
|
|
872
|
+
write_batch_marker(
|
|
873
|
+
directory,
|
|
874
|
+
ref=ref,
|
|
875
|
+
requested=sorted(requested),
|
|
876
|
+
format_status=format_status,
|
|
877
|
+
outcome=outcome,
|
|
878
|
+
manifest=manifest,
|
|
879
|
+
)
|
|
880
|
+
return
|
|
881
|
+
|
|
882
|
+
# v1 legacy success path
|
|
883
|
+
result.status = _STATUS_COMPLETE
|
|
884
|
+
result.outcome = outcome
|
|
885
|
+
result.formats = format_status
|
|
886
|
+
write_batch_marker(
|
|
887
|
+
directory,
|
|
888
|
+
ref=ref,
|
|
889
|
+
requested=sorted(requested),
|
|
890
|
+
format_status=format_status,
|
|
891
|
+
outcome=outcome,
|
|
892
|
+
manifest=None,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _terminal_statuses(directory: Path, requested: Sequence[str]) -> dict[str, str]:
|
|
897
|
+
manifest = _manifest_for(directory)
|
|
898
|
+
if manifest is None:
|
|
899
|
+
return {}
|
|
900
|
+
return {
|
|
901
|
+
fmt: manifest.outputs[fmt].status
|
|
902
|
+
for fmt in requested
|
|
903
|
+
if fmt in manifest.outputs
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _marker_is_manifest_backed(marker: Mapping[str, Any] | None) -> bool:
|
|
908
|
+
"""True when the marker claims to be derived from a written manifest."""
|
|
909
|
+
if not marker:
|
|
910
|
+
return False
|
|
911
|
+
return marker.get("verified") is True or marker.get("manifest") == MANIFEST_FILENAME
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _completion_problem(
|
|
915
|
+
store: ArtifactStore, manifest: ManifestV2, requested: Sequence[str]
|
|
916
|
+
) -> str | None:
|
|
917
|
+
"""A reason code when a recorded ``complete`` output no longer verifies.
|
|
918
|
+
|
|
919
|
+
Manifest entries are the completion authority; a digest/validator mismatch
|
|
920
|
+
means the artifact was corrupted or replaced after the run (A27) and the
|
|
921
|
+
requested formats must be re-run instead of trusted (R-03).
|
|
922
|
+
"""
|
|
923
|
+
for fmt in requested:
|
|
924
|
+
entry = manifest.outputs.get(fmt)
|
|
925
|
+
if entry is not None and entry.status == "complete":
|
|
926
|
+
if not store.output_valid(fmt, validator_version=V2_VALIDATOR_VERSION):
|
|
927
|
+
return RESUME_REASON_STALE_MARKER
|
|
928
|
+
return None
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _plan_formats(
|
|
932
|
+
directory: Path,
|
|
933
|
+
ref: ContentRef,
|
|
934
|
+
*,
|
|
935
|
+
requested: set[str],
|
|
936
|
+
resume: bool,
|
|
937
|
+
refresh: bool,
|
|
938
|
+
doc_verifier: Callable[[Path], bool] | None,
|
|
939
|
+
settings: AppSettings | None = None,
|
|
940
|
+
doc_language: str | None = None,
|
|
941
|
+
) -> tuple[set[str], str | None, str | None, str | None]:
|
|
942
|
+
"""Decide what to run and whether the whole source can be skipped.
|
|
943
|
+
|
|
944
|
+
Returns ``(formats_to_run, skip_status, skip_outcome, resume_reason)``.
|
|
945
|
+
|
|
946
|
+
The manifest is the only completion authority for v2 sources: a derived
|
|
947
|
+
``.batch-complete.json`` marker without a readable, digest-verified manifest
|
|
948
|
+
is *not* proof of completion (R-03/A18/A27). Legacy v1 ``video-NNN``
|
|
949
|
+
directories keep their historical marker strategy (plan §9.1).
|
|
950
|
+
"""
|
|
951
|
+
store = ArtifactStore(directory)
|
|
952
|
+
manifest = store.read_manifest()
|
|
953
|
+
marker = read_batch_marker(directory)
|
|
954
|
+
|
|
955
|
+
if manifest is not None:
|
|
956
|
+
from ytk.pipeline.state import unresolved_remote_tasks
|
|
957
|
+
|
|
958
|
+
unresolved = unresolved_remote_tasks(manifest)
|
|
959
|
+
resumable = {
|
|
960
|
+
provider: record for provider, record in unresolved.items()
|
|
961
|
+
if provider == "dtk" and record.get("task_id") and ref.platform == "douyin"
|
|
962
|
+
}
|
|
963
|
+
unresolved = {
|
|
964
|
+
provider: record for provider, record in unresolved.items()
|
|
965
|
+
if provider not in resumable
|
|
966
|
+
}
|
|
967
|
+
if unresolved:
|
|
968
|
+
providers = ", ".join(sorted(unresolved))
|
|
969
|
+
return set(), _STATUS_BLOCKED, f"REMOTE_SUBMISSION_UNKNOWN for {providers}", None
|
|
970
|
+
if resumable:
|
|
971
|
+
return set(requested), None, None, None
|
|
972
|
+
if not resume and not refresh:
|
|
973
|
+
return set(requested), None, None, None
|
|
974
|
+
if refresh:
|
|
975
|
+
# Re-run everything requested; never treat old outputs as final.
|
|
976
|
+
return set(requested), None, None, None
|
|
977
|
+
content_cache = getattr(manifest, "content_cache", None)
|
|
978
|
+
if settings is not None and isinstance(content_cache, dict):
|
|
979
|
+
from ytk.pipeline.v2 import MultiPlatformPipeline
|
|
980
|
+
|
|
981
|
+
if content_cache.get("processing_fingerprint") != MultiPlatformPipeline(settings=settings)._processing_fingerprint():
|
|
982
|
+
return set(requested), None, None, "PROCESSING_SETTINGS_CHANGED"
|
|
983
|
+
problem = _completion_problem(store, manifest, sorted(requested))
|
|
984
|
+
if problem is not None:
|
|
985
|
+
# A recorded complete artifact no longer matches its digest/validator:
|
|
986
|
+
# re-run the requested formats, never resume-skip a tampered output.
|
|
987
|
+
return set(requested), None, None, problem
|
|
988
|
+
covered = _manifest_covered_formats(store, manifest, sorted(requested))
|
|
989
|
+
if settings is not None:
|
|
990
|
+
from ytk.pipeline.v2 import render_request_fingerprint
|
|
991
|
+
|
|
992
|
+
recorded = getattr(manifest, "render_fingerprints", {})
|
|
993
|
+
covered = {
|
|
994
|
+
fmt for fmt in covered
|
|
995
|
+
if fmt == "json" or manifest.outputs[fmt].status != "complete"
|
|
996
|
+
or recorded.get(fmt) == render_request_fingerprint(fmt, settings, doc_language, ref.platform)
|
|
997
|
+
}
|
|
998
|
+
missing = set(requested) - covered
|
|
999
|
+
if missing:
|
|
1000
|
+
# A pipeline-recorded terminal judgment (NO_KNOWLEDGE / all formats
|
|
1001
|
+
# not applicable) is reused while its validator version is current:
|
|
1002
|
+
# re-dispatching a deterministic judgment would be "unconditional
|
|
1003
|
+
# retry" (A30) and can be expensive (paid doc generation).
|
|
1004
|
+
terminal = _manifest_terminal_judgment(manifest, sorted(requested))
|
|
1005
|
+
if terminal is not None:
|
|
1006
|
+
return set(), terminal[0], terminal[1], None
|
|
1007
|
+
return missing, None, None, None
|
|
1008
|
+
status, outcome = _status_from_manifest(manifest, sorted(requested))
|
|
1009
|
+
return set(), status, outcome, None
|
|
1010
|
+
|
|
1011
|
+
if not resume and not refresh:
|
|
1012
|
+
return set(requested), None, None, None
|
|
1013
|
+
marker_matches = marker is not None and marker_source_id(marker) == ref.source_id
|
|
1014
|
+
if _requires_manifest(ref, marker):
|
|
1015
|
+
if marker is None:
|
|
1016
|
+
# No manifest and no derived marker: a fresh (or already wiped) run.
|
|
1017
|
+
return set(requested), None, None, None
|
|
1018
|
+
# The marker says "complete" but the manifest that is its only authority
|
|
1019
|
+
# is missing/corrupt → the derived completion cannot be trusted.
|
|
1020
|
+
return set(requested), None, None, RESUME_REASON_MANIFEST_MISSING
|
|
1021
|
+
if marker is not None and marker_matches:
|
|
1022
|
+
if refresh:
|
|
1023
|
+
return set(requested), None, None, None
|
|
1024
|
+
covered = _legacy_marker_covered(
|
|
1025
|
+
marker, requested, directory, doc_verifier=doc_verifier
|
|
1026
|
+
)
|
|
1027
|
+
missing = set(requested) - covered
|
|
1028
|
+
if missing:
|
|
1029
|
+
return missing, None, None, None
|
|
1030
|
+
outcome = str(marker.get("outcome") or OVERALL_COMPLETE).upper()
|
|
1031
|
+
status = _OUTCOME_TO_STATUS.get(outcome, _STATUS_COMPLETE)
|
|
1032
|
+
return set(), status, outcome, None
|
|
1033
|
+
return set(requested), None, None, None
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _requires_manifest(ref: ContentRef, marker: Mapping[str, Any] | None) -> bool:
|
|
1037
|
+
"""v2-managed sources must never be completed by a derived marker alone."""
|
|
1038
|
+
return ref.platform != "youtube" or _marker_is_manifest_backed(marker)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _pipeline_kind(ref: ContentRef, *, fixture_content: Path | None) -> str:
|
|
1042
|
+
if fixture_content is not None:
|
|
1043
|
+
return "v2"
|
|
1044
|
+
return "v1" if ref.platform == "youtube" else "v2"
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _record_failure(result: SourceResult, exc: BaseException) -> None:
|
|
1048
|
+
from ytk.pipeline.retry import classify_error
|
|
1049
|
+
|
|
1050
|
+
info = classify_error(exc)
|
|
1051
|
+
result.status = _STATUS_BLOCKED if info.category in _BLOCKED_CATEGORIES else _STATUS_FAILED
|
|
1052
|
+
result.error_code = info.reason_code
|
|
1053
|
+
result.outcome = info.category.upper()
|
|
1054
|
+
result.error = redact_text(info.message)[:400]
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _acquired_source_id(manifest: ManifestV2, ref: ContentRef) -> str | None:
|
|
1058
|
+
"""The authoritative source id the pipeline acquired for this input (if any)."""
|
|
1059
|
+
prefix = f"{ref.platform}:{ref.content_id}"
|
|
1060
|
+
candidates = [
|
|
1061
|
+
source_id
|
|
1062
|
+
for source_id in manifest.source_ids
|
|
1063
|
+
if source_id == prefix or source_id.startswith(prefix + ":")
|
|
1064
|
+
]
|
|
1065
|
+
if not candidates:
|
|
1066
|
+
return None
|
|
1067
|
+
variants = [source_id for source_id in candidates if source_id.startswith(prefix + ":")]
|
|
1068
|
+
return variants[0] if variants else candidates[0]
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _reconcile_acquired_identity(
|
|
1072
|
+
result: SourceResult,
|
|
1073
|
+
ref: ContentRef,
|
|
1074
|
+
manifest: ManifestV2 | None,
|
|
1075
|
+
seen_sources: MutableMapping[str, SourceResult],
|
|
1076
|
+
) -> None:
|
|
1077
|
+
"""Bind a post-acquisition variant (Bilibili ``cid``) to the same batch source.
|
|
1078
|
+
|
|
1079
|
+
The stable directory was computed before acquisition (part-isolated, R-08);
|
|
1080
|
+
the pipeline may report a more specific ``source_id`` afterwards. That is
|
|
1081
|
+
the *same* source — register it so a later duplicate input is not processed
|
|
1082
|
+
twice, but never move or rename the part-isolated directory.
|
|
1083
|
+
"""
|
|
1084
|
+
if manifest is None:
|
|
1085
|
+
return
|
|
1086
|
+
acquired = _acquired_source_id(manifest, ref)
|
|
1087
|
+
if acquired is None:
|
|
1088
|
+
return
|
|
1089
|
+
result.acquired_source_id = acquired
|
|
1090
|
+
seen_sources.setdefault(acquired, result)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _write_alias_map(out_root: Path, report: BatchReport) -> Path | None:
|
|
1094
|
+
aliases = [
|
|
1095
|
+
{
|
|
1096
|
+
"input": result.input_value,
|
|
1097
|
+
"source_id": result.source_id,
|
|
1098
|
+
"identity_key": result.identity_key,
|
|
1099
|
+
"acquired_source_id": result.acquired_source_id,
|
|
1100
|
+
"platform": result.platform,
|
|
1101
|
+
"output_dir": (
|
|
1102
|
+
str(Path(result.output_dir).relative_to(out_root))
|
|
1103
|
+
if result.output_dir
|
|
1104
|
+
else None
|
|
1105
|
+
),
|
|
1106
|
+
}
|
|
1107
|
+
for result in report.results
|
|
1108
|
+
if result.source_id
|
|
1109
|
+
]
|
|
1110
|
+
try:
|
|
1111
|
+
return ArtifactStore(out_root).write_json(
|
|
1112
|
+
BATCH_ALIASES_FILENAME, {"schema": 1, "aliases": aliases}
|
|
1113
|
+
)
|
|
1114
|
+
except Exception as exc: # noqa: BLE001 - alias bookkeeping must not fail a batch
|
|
1115
|
+
LOGGER.warning("could not write batch alias map: %s", exc)
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _write_batch_report(out_root: Path, report: BatchReport) -> Path | None:
|
|
1120
|
+
try:
|
|
1121
|
+
return ArtifactStore(out_root).write_json(BATCH_REPORT_FILENAME, report.to_dict())
|
|
1122
|
+
except Exception as exc: # noqa: BLE001
|
|
1123
|
+
LOGGER.warning("could not write batch report: %s", exc)
|
|
1124
|
+
return None
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
__all__ = [
|
|
1128
|
+
"BATCH_ALIASES_FILENAME",
|
|
1129
|
+
"BATCH_MARKER_FILENAME",
|
|
1130
|
+
"BATCH_MARKER_SCHEMA",
|
|
1131
|
+
"BATCH_REPORT_FILENAME",
|
|
1132
|
+
"BatchReport",
|
|
1133
|
+
"RESUME_REASON_MANIFEST_MISSING",
|
|
1134
|
+
"RESUME_REASON_STALE_MARKER",
|
|
1135
|
+
"SourceResult",
|
|
1136
|
+
"V2_VALIDATOR_VERSION",
|
|
1137
|
+
"batch_identity_key",
|
|
1138
|
+
"default_formats_for",
|
|
1139
|
+
"display_part",
|
|
1140
|
+
"legacy_output_dirs",
|
|
1141
|
+
"marker_source_id",
|
|
1142
|
+
"read_batch_marker",
|
|
1143
|
+
"requested_formats_for",
|
|
1144
|
+
"resolve_batch_identity",
|
|
1145
|
+
"run_batch",
|
|
1146
|
+
"stable_output_dir",
|
|
1147
|
+
"stable_source_dirname",
|
|
1148
|
+
"write_batch_marker",
|
|
1149
|
+
]
|