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.
Files changed (118) hide show
  1. kd_engine-1.0.0.dist-info/METADATA +19 -0
  2. kd_engine-1.0.0.dist-info/RECORD +118 -0
  3. kd_engine-1.0.0.dist-info/WHEEL +5 -0
  4. kd_engine-1.0.0.dist-info/entry_points.txt +2 -0
  5. kd_engine-1.0.0.dist-info/licenses/LICENSE-MIT +21 -0
  6. kd_engine-1.0.0.dist-info/top_level.txt +1 -0
  7. ytk/__init__.py +2 -0
  8. ytk/__main__.py +21 -0
  9. ytk/batch/__init__.py +39 -0
  10. ytk/batch/runner.py +1149 -0
  11. ytk/cache/__init__.py +34 -0
  12. ytk/cache/v2.py +562 -0
  13. ytk/cleaning/density.py +40 -0
  14. ytk/cleaning/fillers.py +18 -0
  15. ytk/cleaning/sentence_boundary.py +30 -0
  16. ytk/cleaning/service.py +62 -0
  17. ytk/cli.py +1281 -0
  18. ytk/commands/__init__.py +3 -0
  19. ytk/commands/merge.py +600 -0
  20. ytk/config.py +757 -0
  21. ytk/dedup/service.py +166 -0
  22. ytk/doctor.py +891 -0
  23. ytk/dotenv.py +334 -0
  24. ytk/errors.py +38 -0
  25. ytk/extraction/service.py +451 -0
  26. ytk/extraction/strategies/base.py +16 -0
  27. ytk/extraction/strategies/code_block.py +285 -0
  28. ytk/extraction/strategies/heuristic.py +791 -0
  29. ytk/extraction/strategies/local_model.py +56 -0
  30. ytk/extraction/text_context.py +177 -0
  31. ytk/extraction/visual_context.py +159 -0
  32. ytk/ingestion/adapters/__init__.py +1 -0
  33. ytk/ingestion/adapters/_common.py +865 -0
  34. ytk/ingestion/adapters/base.py +160 -0
  35. ytk/ingestion/adapters/bilibili.py +271 -0
  36. ytk/ingestion/adapters/douyin.py +445 -0
  37. ytk/ingestion/adapters/tiktok.py +102 -0
  38. ytk/ingestion/adapters/xiaohongshu.py +758 -0
  39. ytk/ingestion/adapters/youtube.py +74 -0
  40. ytk/ingestion/browser_acquired.py +220 -0
  41. ytk/ingestion/http_client.py +43 -0
  42. ytk/ingestion/local_import.py +202 -0
  43. ytk/ingestion/media.py +468 -0
  44. ytk/ingestion/providers/asr.py +902 -0
  45. ytk/ingestion/providers/base.py +13 -0
  46. ytk/ingestion/providers/dtk_client.py +816 -0
  47. ytk/ingestion/providers/minimax.py +350 -0
  48. ytk/ingestion/providers/transcript_api.py +336 -0
  49. ytk/ingestion/providers/xhs_client.py +646 -0
  50. ytk/ingestion/providers/ytdlp.py +400 -0
  51. ytk/ingestion/registry.py +189 -0
  52. ytk/ingestion/resolver.py +417 -0
  53. ytk/ingestion/search.py +56 -0
  54. ytk/ingestion/service.py +167 -0
  55. ytk/ingestion/subtitles.py +602 -0
  56. ytk/ingestion/transcript_cache.py +212 -0
  57. ytk/ingestion/url_parser.py +53 -0
  58. ytk/ingestion/video_frames.py +292 -0
  59. ytk/ingestion/ytdlp_acquire.py +754 -0
  60. ytk/logging.py +383 -0
  61. ytk/models/common.py +20 -0
  62. ytk/models/content.py +451 -0
  63. ytk/models/knowledge.py +43 -0
  64. ytk/models/migrations.py +262 -0
  65. ytk/models/skill.py +13 -0
  66. ytk/models/source.py +265 -0
  67. ytk/models/topic.py +18 -0
  68. ytk/models/transcript.py +30 -0
  69. ytk/ocr/__init__.py +65 -0
  70. ytk/ocr/engine.py +245 -0
  71. ytk/ocr/layout.py +347 -0
  72. ytk/ocr/pipeline.py +440 -0
  73. ytk/ocr/rapidocr_engine.py +144 -0
  74. ytk/pipeline/acquisition_checkpoint.py +240 -0
  75. ytk/pipeline/artifacts.py +18 -0
  76. ytk/pipeline/orchestrator.py +598 -0
  77. ytk/pipeline/processing_identity.py +18 -0
  78. ytk/pipeline/retry.py +728 -0
  79. ytk/pipeline/runners.py +578 -0
  80. ytk/pipeline/state.py +1107 -0
  81. ytk/pipeline/v2.py +1863 -0
  82. ytk/py.typed +1 -0
  83. ytk/quality/__init__.py +9 -0
  84. ytk/quality/completeness.py +260 -0
  85. ytk/renderers/base.py +9 -0
  86. ytk/renderers/citation_alignment.py +617 -0
  87. ytk/renderers/evidence_formatter.py +362 -0
  88. ytk/renderers/json_renderer.py +13 -0
  89. ytk/renderers/jsonl_renderer.py +18 -0
  90. ytk/renderers/knowledge_doc_renderer.py +1984 -0
  91. ytk/renderers/markdown_renderer.py +540 -0
  92. ytk/renderers/obsidian_renderer.py +138 -0
  93. ytk/renderers/skill_package.py +21 -0
  94. ytk/renderers/source_links.py +309 -0
  95. ytk/renderers/summary_renderer.py +163 -0
  96. ytk/renderers/v2.py +171 -0
  97. ytk/renderers/v2_jsonl.py +165 -0
  98. ytk/renderers/v2_markdown.py +477 -0
  99. ytk/renderers/v2_obsidian.py +113 -0
  100. ytk/renderers/v2_summary.py +220 -0
  101. ytk/segmentation/change_points.py +50 -0
  102. ytk/segmentation/embeddings.py +45 -0
  103. ytk/segmentation/service.py +318 -0
  104. ytk/skill_builder/__init__.py +1 -0
  105. ytk/skill_builder/builder.py +419 -0
  106. ytk/skill_builder/templates/SKILL.md.j2 +85 -0
  107. ytk/skill_builder/templates/evals/evals.json.j2 +25 -0
  108. ytk/skill_builder/templates/examples/example-input.md.j2 +5 -0
  109. ytk/skill_builder/templates/examples/example-output.md.j2 +8 -0
  110. ytk/skill_builder/templates/references/commands.md.j2 +34 -0
  111. ytk/skill_builder/templates/references/concepts.md.j2 +22 -0
  112. ytk/skill_builder/templates/references/configs.md.j2 +31 -0
  113. ytk/skill_builder/templates/references/evidence-policy.md.j2 +27 -0
  114. ytk/skill_builder/templates/references/source-summary.md.j2 +24 -0
  115. ytk/skill_builder/templates/scripts/build_skill.py.j2 +14 -0
  116. ytk/skill_builder/templates/scripts/validate_output.py.j2 +136 -0
  117. ytk/skill_builder/templates.py +29 -0
  118. 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
+ ]