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
@@ -0,0 +1,578 @@
1
+ """Pipeline enrichment runners: ASR and OCR glue for the v2 pipeline.
2
+
3
+ The pipeline itself stays decoupled from model backends; :func:`build_v2_pipeline`
4
+ injects these callables. Both runners follow the environment rules verified for
5
+ this machine (see ``docs/implementation/five-platform/models.md``):
6
+
7
+ * **no silent download at inference** — the ASR model is resolved from an
8
+ explicit local directory and loaded with ``local_files_only=True``; the OCR
9
+ engine is resolved from explicit ONNX model paths (``YTK_OCR_MODEL_DIR``).
10
+ Missing models raise :class:`~ytk.errors.ModelUnavailableError` with the
11
+ preparation command instead of fetching anything (A16).
12
+ * **already-downloaded audio only** — ASR consumes an existing asset from the
13
+ task directory and never re-downloads (plan T22.1).
14
+ * **chunk-aware output** — faster-whisper's segment timestamps are absolute, so
15
+ blocks keep real time ranges; long-audio runs are bounded by a deadline.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import math
23
+ import os
24
+ import shutil
25
+ import tempfile
26
+ import time
27
+ from dataclasses import replace
28
+ from importlib.metadata import PackageNotFoundError, version
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ytk.config import AppSettings
33
+ from ytk.errors import ContentIntegrityError, ModelUnavailableError, UserFacingError
34
+ from ytk.ingestion.providers.asr import (
35
+ AsrChunk, AsrResult, AsrSegment, asr_decoding_profile,
36
+ missing_asr_model_files as _missing_asr_files,
37
+ )
38
+ from ytk.models.content import (
39
+ ContentBlock,
40
+ ContentBundle,
41
+ TimeRange,
42
+ TimeRangeLocator,
43
+ make_block_id,
44
+ )
45
+ from ytk.pipeline.state import ArtifactStore
46
+
47
+ LOGGER = logging.getLogger(__name__)
48
+
49
+ #: Candidate local ASR model locations, in preference order. A multilingual
50
+ #: model is required for Chinese; ``base.en`` must never be the default (A07).
51
+ ASR_MODEL_CANDIDATES: tuple[str, ...] = (
52
+ "~/.cache/ytk-models/faster-whisper/faster-whisper-large-v3",
53
+ "~/.cache/ytk-models/faster-whisper/faster-whisper-large-v3-turbo",
54
+ "~/.cache/ytk-models/faster-whisper/faster-whisper-small",
55
+ "~/.cache/ytk-models/faster-whisper/faster-whisper-base",
56
+ "~/.cache/ytk-models/faster-whisper",
57
+ )
58
+
59
+ #: Model names that are English-only and therefore unusable for Chinese content.
60
+ _ENGLISH_ONLY_MODELS = frozenset({"base.en", "small.en", "medium.en", "tiny.en"})
61
+
62
+ DEFAULT_OCR_MODEL_DIR = "~/.cache/ytk-models/rapidocr/models"
63
+ ASR_CHUNK_SECONDS = 180.0
64
+ ASR_OVERLAP_SECONDS = 1.0
65
+ ASR_RESUME_SCHEMA_VERSION = 2
66
+ ASR_DURATION_TOLERANCE_SECONDS = 0.5
67
+
68
+
69
+ def _sha256_file(path: Path) -> str:
70
+ import hashlib
71
+
72
+ digest = hashlib.sha256()
73
+ with path.open("rb") as handle:
74
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
75
+ digest.update(chunk)
76
+ return digest.hexdigest()
77
+
78
+
79
+ def _asr_model_fingerprint(model_dir: str) -> dict[str, str]:
80
+ """Bind recognition to the bytes of the provisioned model and vocabulary."""
81
+ root = Path(model_dir)
82
+ if root.is_file():
83
+ return {root.name: _sha256_file(root)}
84
+ names = {"model.bin", "config.json", "tokenizer.json", "vocabulary.json", "vocabulary.txt", "preprocessor_config.json"}
85
+ return {
86
+ path.name: _sha256_file(path)
87
+ for path in sorted(root.iterdir())
88
+ if path.is_file() and path.name in names
89
+ }
90
+
91
+
92
+ def _expand(path: str | None) -> str | None:
93
+ if not path:
94
+ return None
95
+ return os.path.expanduser(path)
96
+
97
+
98
+ def resolve_asr_backend(settings: AppSettings) -> str:
99
+ if settings.asr_backend != "auto":
100
+ return settings.asr_backend
101
+ if settings.asr_model:
102
+ return "whisper_cpp" if Path(settings.asr_model).expanduser().is_file() else "faster_whisper"
103
+ if settings.whisper_cpp_binary and settings.whisper_cpp_model:
104
+ return "whisper_cpp"
105
+ return "faster_whisper"
106
+
107
+
108
+ def resolve_asr_model(
109
+ settings: AppSettings | None = None, *, language: str | None = None,
110
+ ) -> str:
111
+ """Resolve the selected local model; an explicit missing model never falls back."""
112
+ settings = settings or AppSettings()
113
+ backend = resolve_asr_backend(settings)
114
+ explicit = _expand(settings.asr_model)
115
+ if backend == "whisper_cpp":
116
+ selected = explicit or _expand(settings.whisper_cpp_model)
117
+ if not selected or not Path(selected).is_file() or Path(selected).stat().st_size == 0:
118
+ raise ModelUnavailableError("whisper.cpp requires a provisioned local model file; set YTK_ASR_MODEL or YTK_WHISPER_CPP_MODEL")
119
+ return selected
120
+ if explicit:
121
+ if not Path(explicit).is_dir():
122
+ raise ModelUnavailableError("the selected ASR model is not a local directory; provision YTK_ASR_MODEL before inference")
123
+ missing = _missing_asr_files(Path(explicit))
124
+ if missing:
125
+ raise ModelUnavailableError(f"local ASR model is incomplete; provision these files before inference: {', '.join(missing)}")
126
+ return explicit
127
+ configured = _expand(getattr(settings, "faster_whisper_model", None))
128
+ candidates: list[str] = []
129
+ if configured:
130
+ candidates.append(configured)
131
+ candidates.extend(os.path.expanduser(path) for path in ASR_MODEL_CANDIDATES)
132
+ for candidate in candidates:
133
+ if candidate and Path(candidate).is_dir():
134
+ name = Path(candidate).name.lower()
135
+ if name in _ENGLISH_ONLY_MODELS:
136
+ continue
137
+ if _missing_asr_files(Path(candidate)):
138
+ continue
139
+ return candidate
140
+ raise ModelUnavailableError(
141
+ "no local multilingual ASR model is provisioned. Prepare one explicitly, e.g.: "
142
+ "install the declared optional extra with `python -m pip install 'kd-engine[asr]==1.0.0'` in the connector runtime, then "
143
+ "explicitly download Systran/faster-whisper-small into a local model directory "
144
+ "and set YTK_ASR_BACKEND=faster_whisper and YTK_ASR_MODEL to that absolute directory (see connector README). "
145
+ "The pipeline never downloads models at inference time."
146
+ )
147
+
148
+
149
+ def make_asr_runner(settings: AppSettings | None = None) -> Any:
150
+ """Build ``asr_runner(content, output_dir) -> list[ContentBlock]``."""
151
+ settings = settings or AppSettings()
152
+
153
+ def run(content: ContentBundle, output_dir: Path) -> Any:
154
+ return transcribe_content(content, output_dir, settings=settings)
155
+
156
+ return run
157
+
158
+
159
+ def _checkpoint_plan(payload: dict[str, Any]) -> tuple[tuple[int, float, float], ...] | None:
160
+ rows = payload.get("chunk_plan")
161
+ if rows is None and payload.get("resume_schema_version") == 1:
162
+ # Schema 1 wrote the entire attempted plan after transcribe returned.
163
+ # Retain that proven layout only when a failed chunk is recorded; it is
164
+ # still compared with the freshly probed plan before any work is reused.
165
+ chunks = payload.get("chunk_segments")
166
+ if not isinstance(chunks, list) or not all(isinstance(row, dict) for row in chunks):
167
+ return None
168
+ if not any(row.get("status") == "failed" for row in chunks):
169
+ return None
170
+ rows = [[row.get("index"), row.get("start"), row.get("end")] for row in chunks]
171
+ if not isinstance(rows, list) or not rows:
172
+ return None
173
+ plan = []
174
+ try:
175
+ for index, row in enumerate(rows):
176
+ if not isinstance(row, list) or len(row) != 3 or type(row[0]) is not int or row[0] != index:
177
+ return None
178
+ start, end = float(row[1]), float(row[2])
179
+ if not math.isfinite(start) or not math.isfinite(end):
180
+ return None
181
+ if abs(start - round(index * (ASR_CHUNK_SECONDS - ASR_OVERLAP_SECONDS), 3)) > 0.001:
182
+ return None
183
+ if end < start or (end == start and not (len(rows) == 1 and end == 0)) or end > start + ASR_CHUNK_SECONDS + 0.001:
184
+ return None
185
+ if index < len(rows) - 1 and abs(end - (start + ASR_CHUNK_SECONDS)) > 0.001:
186
+ return None
187
+ plan.append((index, start, end))
188
+ except (TypeError, ValueError):
189
+ return None
190
+ return tuple(plan)
191
+
192
+
193
+ def _reusable_asr_chunks(payload: dict[str, Any], source_id: str) -> dict[int, tuple[AsrSegment, ...]]:
194
+ """Read proven chunk membership; never infer it from final transcript times."""
195
+ if payload.get("resume_schema_version") not in {1, ASR_RESUME_SCHEMA_VERSION} or payload.get("source_id") != source_id:
196
+ return {}
197
+ if payload.get("metadata", {}).get("complete") is not False:
198
+ return {}
199
+ rows = payload.get("chunk_segments")
200
+ metadata_rows = payload["metadata"].get("chunks")
201
+ plan = _checkpoint_plan(payload)
202
+ if plan is None or not isinstance(rows, list) or not rows or not isinstance(metadata_rows, list) or len(metadata_rows) != len(rows):
203
+ return {}
204
+ reusable: dict[int, tuple[AsrSegment, ...]] = {}
205
+ previous_index = -1
206
+ try:
207
+ for position, row in enumerate(rows):
208
+ if not isinstance(row, dict) or type(row.get("index")) is not int:
209
+ return {}
210
+ index = row["index"]
211
+ if index <= previous_index or index >= len(plan):
212
+ return {}
213
+ previous_index = index
214
+ start, end = float(row["start"]), float(row["end"])
215
+ if (index, start, end) != plan[index]:
216
+ return {}
217
+ status = row["status"]
218
+ if status not in {"ok", "skipped", "failed"} or not isinstance(row.get("segments"), list):
219
+ return {}
220
+ recorded = metadata_rows[position]
221
+ if not isinstance(recorded, dict) or any(recorded.get(key) != row[key] for key in ("index", "start", "end")):
222
+ return {}
223
+ if recorded.get("status") != ("ok" if status == "skipped" else status):
224
+ return {}
225
+ segments: list[AsrSegment] = []
226
+ for segment in row["segments"]:
227
+ text = segment["text"]
228
+ segment_start, segment_end = float(segment["start"]), float(segment["end"])
229
+ if not isinstance(text, str) or not text.strip():
230
+ return {}
231
+ if not math.isfinite(segment_start) or not math.isfinite(segment_end):
232
+ return {}
233
+ if segment_start < start - 0.001 or segment_end < segment_start or segment_end > end + 0.001:
234
+ return {}
235
+ segments.append(AsrSegment(text=text, start=segment_start, end=segment_end))
236
+ if status == "failed":
237
+ if segments:
238
+ return {}
239
+ else:
240
+ reusable[index] = tuple(segments)
241
+ except (KeyError, TypeError, ValueError):
242
+ return {}
243
+ return reusable
244
+
245
+
246
+ def _asr_result_payload(source_id: str, result: Any, backend: str, language: str | None,
247
+ source_duration: float | None = None) -> tuple[list[ContentBlock], dict[str, object], dict[str, Any]]:
248
+ blocks: list[ContentBlock] = []
249
+ for order, segment in enumerate(result.segments):
250
+ text = (segment.text or "").strip()
251
+ start, end = float(segment.start), float(segment.end)
252
+ if end < start or not text:
253
+ continue
254
+ locator = TimeRangeLocator(time=TimeRange(start=round(start, 3), end=round(end, 3)))
255
+ blocks.append(ContentBlock(
256
+ block_id=make_block_id(source_id, locator, text), source_id=source_id,
257
+ kind="transcript", raw_text=text, language=language, order=order, locator=locator,
258
+ ))
259
+ chunks = getattr(result, "chunks", ()) or ()
260
+ rows = [{"index": c.index, "status": "ok" if c.status == "skipped" else c.status,
261
+ "start": c.start, "end": c.end, "reused": c.status == "skipped"} for c in chunks]
262
+ metadata = {
263
+ "backend": backend, "complete": bool(getattr(result, "complete", True)),
264
+ "last_chunk_ok": bool(getattr(result, "last_chunk_ok", not (rows and rows[-1]["status"] == "failed"))),
265
+ "segments": len(blocks), "chunks": rows, "language": language,
266
+ "duration_seconds": float(getattr(result, "duration", 0.0) or 0.0),
267
+ "model": getattr(result, "model", None),
268
+ "audio_sha256": getattr(result, "audio_sha256", None),
269
+ "decoding_options": dict(getattr(result, "decoding_options", {}) or {}),
270
+ "segment_diagnostics": [dict(row) for chunk in chunks for row in getattr(chunk, "segment_diagnostics", ())],
271
+ "diagnostics_interpretation": "decoder_scores_are_not_accuracy_or_fact_verification",
272
+ }
273
+ if source_duration is not None:
274
+ metadata["source_audio_duration_seconds"] = source_duration
275
+ metadata["audio_duration_matches_source"] = _asr_duration_matches(source_duration, getattr(result, "duration", None))
276
+ payload = {
277
+ "resume_schema_version": ASR_RESUME_SCHEMA_VERSION, "source_id": source_id,
278
+ "blocks": [block.model_dump(mode="json") for block in blocks], "metadata": metadata,
279
+ "chunk_plan": list(getattr(result, "chunk_plan", ()) or ()),
280
+ "chunk_segments": [{"index": c.index, "start": c.start, "end": c.end, "status": c.status,
281
+ "segments": [{"text": s.text, "start": s.start, "end": s.end} for s in c.segments]}
282
+ for c in chunks],
283
+ }
284
+ return blocks, metadata, payload
285
+
286
+
287
+ def _asr_duration_matches(expected: float, actual: object) -> bool:
288
+ return (isinstance(actual, (int, float)) and not isinstance(actual, bool)
289
+ and math.isfinite(actual) and actual > 0
290
+ and abs(expected - actual) <= ASR_DURATION_TOLERANCE_SECONDS)
291
+
292
+
293
+ def _verified_asr_audio(media: Any, source_path: Path, audio_path: Path) -> float:
294
+ """Publish only a validated complete conversion; an existing WAV is untrusted."""
295
+ source_info = media.probe(source_path)
296
+ duration = getattr(source_info, "audio_duration_seconds", None)
297
+ if duration is None:
298
+ duration = source_info.duration_seconds
299
+ if not _asr_duration_matches(duration or 0, duration) or not source_info.has_audio:
300
+ raise ContentIntegrityError("Cannot verify the source audio duration before transcription")
301
+
302
+ def valid_audio(path: Path) -> bool:
303
+ if not path.is_file() or path.stat().st_size == 0:
304
+ return False
305
+ try:
306
+ info = media.probe(path)
307
+ return (info.has_audio and info.sample_rate == 16000 and info.channels == 1
308
+ and _asr_duration_matches(duration, info.duration_seconds))
309
+ except Exception:
310
+ return False
311
+
312
+ if valid_audio(audio_path):
313
+ return float(duration)
314
+ audio_path.parent.mkdir(parents=True, exist_ok=True)
315
+ with tempfile.NamedTemporaryFile(prefix=".asr-conversion-", suffix=".wav", dir=audio_path.parent, delete=False) as handle:
316
+ preparing = Path(handle.name)
317
+ try:
318
+ media.to_asr_audio(source_path, preparing)
319
+ if not valid_audio(preparing):
320
+ raise ContentIntegrityError("Converted ASR audio duration does not match the source; transcription was stopped")
321
+ os.replace(preparing, audio_path)
322
+ finally:
323
+ preparing.unlink(missing_ok=True)
324
+ return float(duration)
325
+
326
+
327
+ def transcribe_content(
328
+ content: ContentBundle,
329
+ output_dir: Path,
330
+ *,
331
+ settings: AppSettings | None = None,
332
+ model: Any | None = None,
333
+ language: str | None = None,
334
+ ) -> tuple[list[ContentBlock], dict[str, object]]:
335
+ """Transcribe one already-downloaded audio/video asset into timed blocks.
336
+
337
+ Returns ``(blocks, asr_metadata)``; the v2 pipeline already consumes both
338
+ shapes via ``_split_runner_output`` (the tuple return is the newer contract
339
+ introduced with the ASR metadata plumbing)."""
340
+ settings = settings or AppSettings()
341
+ language = language if language is not None else settings.asr_language
342
+ language = None if language == "auto" else language
343
+ decoding_options = asr_decoding_profile(settings, language)
344
+ source_id = content.source.source_id
345
+ asset = _pick_audio_asset(content)
346
+ if asset is None:
347
+ raise UserFacingError(f"no audio/video asset to transcribe for {source_id}")
348
+ source_path = Path(output_dir) / asset.relative_path
349
+ if not source_path.is_file():
350
+ raise UserFacingError(f"audio asset missing on disk: {source_path}")
351
+ backend = resolve_asr_backend(settings)
352
+ if backend == "whisper_cpp":
353
+ if settings.asr_beam_size != 5 or settings.asr_initial_prompt or settings.asr_hotwords:
354
+ raise UserFacingError("ASR beam size and vocabulary hints require the faster_whisper backend")
355
+ decoding_options = {"language": language or "auto", "word_timestamps": False}
356
+ model_dir = resolve_asr_model(settings, language=language)
357
+
358
+ from ytk.ingestion.media import MediaService
359
+
360
+ media = MediaService(settings=settings)
361
+ audio_path = source_path
362
+ source_duration: float | None = None
363
+ if asset.media_type == "video" or backend == "whisper_cpp":
364
+ audio_path = Path(output_dir) / "artifacts" / f"{source_id.replace(':', '-')}-{_sha256_file(source_path)[:20]}-asr.wav"
365
+ source_duration = _verified_asr_audio(media, source_path, audio_path)
366
+
367
+ runtime: str | None = None
368
+ if backend == "whisper_cpp":
369
+ binary = settings.whisper_cpp_binary
370
+ runtime = shutil.which(binary) if binary else None
371
+ if runtime is None:
372
+ raise ModelUnavailableError("whisper.cpp executable is unavailable; configure YTK_WHISPER_CPP_BINARY before inference")
373
+ runtime_fingerprint = _sha256_file(Path(runtime))
374
+ else:
375
+ try:
376
+ runtime_fingerprint = version("faster-whisper")
377
+ except PackageNotFoundError:
378
+ runtime_fingerprint = "not-installed"
379
+
380
+ # Layered cache (A19/A33): recognition is keyed by audio digest + model dir +
381
+ # language; a model or language change invalidates, a signed-URL change does not.
382
+ cache_parts: dict[str, object] | None = None
383
+ cache: Any | None = None
384
+ reuse_chunks: dict[int, tuple[AsrSegment, ...]] = {}
385
+ resume_plan: tuple[tuple[int, float, float], ...] | None = None
386
+ restored_language: str | None = None
387
+ restored_diagnostics: dict[int, tuple[dict[str, Any], ...]] = {}
388
+ try:
389
+ from ytk.cache.v2 import LayeredCache
390
+
391
+ cache = LayeredCache(root=settings.cache_dir, enabled=True)
392
+ cache_parts = {
393
+ "source_id": source_id,
394
+ "audio_sha256": _sha256_file(audio_path),
395
+ "model": model_dir,
396
+ "backend": backend,
397
+ "runtime": runtime_fingerprint,
398
+ "model_files": _asr_model_fingerprint(model_dir),
399
+ "processor_version": "ytk-local-asr-v3",
400
+ "language": language or "auto",
401
+ "decoding_options": decoding_options,
402
+ "chunk_seconds": ASR_CHUNK_SECONDS,
403
+ "overlap_seconds": ASR_OVERLAP_SECONDS,
404
+ }
405
+ cached = cache.get("asr", cache_parts)
406
+ if cached:
407
+ payload = json.loads(cached.decode("utf-8"))
408
+ cached_blocks = [ContentBlock(**row) for row in payload["blocks"]]
409
+ if (
410
+ all(block.source_id == source_id for block in cached_blocks)
411
+ and payload["metadata"].get("complete") is True
412
+ and (source_duration is None or _asr_duration_matches(source_duration, payload["metadata"].get("duration_seconds")))
413
+ ):
414
+ LOGGER.info("ASR cache hit for %s (%d blocks)", source_id, len(cached_blocks))
415
+ return cached_blocks, payload["metadata"]
416
+ if all(block.source_id == source_id for block in cached_blocks):
417
+ reuse_chunks = _reusable_asr_chunks(payload, source_id)
418
+ resume_plan = _checkpoint_plan(payload) if reuse_chunks else None
419
+ prior_language = payload["metadata"].get("language")
420
+ if reuse_chunks and isinstance(prior_language, str):
421
+ restored_language = prior_language
422
+ for index in reuse_chunks:
423
+ restored_diagnostics[index] = tuple(
424
+ row for row in payload["metadata"].get("segment_diagnostics", [])
425
+ if isinstance(row, dict) and row.get("chunk_index") == index
426
+ )
427
+ except Exception as cache_exc: # cache must never break recognition
428
+ LOGGER.debug("ASR cache unavailable: %s", cache_exc)
429
+ cache = None
430
+ cache_parts = None
431
+
432
+ from ytk.ingestion.providers.asr import LocalAsrService, WhisperCppAsrProvider
433
+
434
+ transcriber = model if callable(model) else None
435
+ if backend == "whisper_cpp" and transcriber is None:
436
+ started = time.monotonic()
437
+
438
+ def transcribe_cpp(path: Path, selected_language: str | None) -> dict[str, object]:
439
+ remaining = settings.task_deadline_s - (time.monotonic() - started)
440
+ if remaining <= 0:
441
+ raise TimeoutError("whisper.cpp ASR deadline exceeded")
442
+ provider = WhisperCppAsrProvider(
443
+ metadata_fetcher=lambda _: {}, whisper_cpp_binary=runtime,
444
+ model_path=model_dir, language=selected_language or "auto",
445
+ output_dir=path.parent,
446
+ )
447
+ payload = provider._run_whisper_cpp(path, timeout=remaining)
448
+ segments = provider._parse_segments(payload)
449
+ return {
450
+ "language": selected_language,
451
+ "segments": [
452
+ {"text": segment.text, "start": segment.start, "end": segment.end}
453
+ for segment in segments
454
+ ],
455
+ }
456
+
457
+ transcriber = transcribe_cpp
458
+
459
+ def persist_progress(result: AsrResult) -> None:
460
+ if cache is None or cache_parts is None:
461
+ return
462
+ try:
463
+ # Keep later successful chunks while retrying an earlier failure;
464
+ # another interruption must not discard their original membership.
465
+ if resume_plan == result.chunk_plan:
466
+ saved = {index: AsrChunk(index=index, start=resume_plan[index][1], end=resume_plan[index][2],
467
+ status="skipped", attempts=0, segments=segments)
468
+ for index, segments in reuse_chunks.items()}
469
+ saved.update({chunk.index: chunk for chunk in result.chunks})
470
+ result = replace(result, chunks=tuple(saved[index] for index in sorted(saved)))
471
+ result = with_diagnostics(result)
472
+ detected = result.language or restored_language or language
473
+ _, _, payload = _asr_result_payload(source_id, result, backend, detected, source_duration)
474
+ cache.put("asr", cache_parts, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
475
+ except Exception as cache_exc: # cache must never break recognition
476
+ LOGGER.debug("ASR checkpoint store failed: %s", cache_exc)
477
+
478
+ def with_diagnostics(result: AsrResult) -> AsrResult:
479
+ return replace(result, decoding_options=decoding_options, chunks=tuple(
480
+ replace(chunk, segment_diagnostics=restored_diagnostics.get(chunk.index, ()))
481
+ if chunk.status == "skipped" and not chunk.segment_diagnostics else chunk
482
+ for chunk in result.chunks
483
+ ))
484
+
485
+ service = LocalAsrService(
486
+ model_path=model_dir if backend == "faster_whisper" else None,
487
+ settings=settings,
488
+ media=media,
489
+ transcriber=transcriber,
490
+ language=language,
491
+ model_candidates=(),
492
+ chunk_seconds=ASR_CHUNK_SECONDS,
493
+ overlap_seconds=ASR_OVERLAP_SECONDS,
494
+ on_progress=persist_progress,
495
+ resume_plan=resume_plan,
496
+ )
497
+ result = (
498
+ service.transcribe(audio_path, reuse_chunks=reuse_chunks)
499
+ if reuse_chunks else service.transcribe(audio_path)
500
+ )
501
+ result = with_diagnostics(result)
502
+ if source_duration is not None and not _asr_duration_matches(source_duration, getattr(result, "duration", None)):
503
+ raise ContentIntegrityError("Recognized audio duration does not match the source; a complete transcript cannot be confirmed")
504
+ detected_language = getattr(result, "language", None) or restored_language or language
505
+ blocks, asr_metadata, payload = _asr_result_payload(source_id, result, backend, detected_language, source_duration)
506
+ if cache is not None and cache_parts is not None:
507
+ try:
508
+ cache.put(
509
+ "asr",
510
+ cache_parts,
511
+ json.dumps(payload, ensure_ascii=False).encode("utf-8"),
512
+ )
513
+ except Exception as cache_exc: # cache must never break recognition
514
+ LOGGER.debug("ASR cache store failed: %s", cache_exc)
515
+ return blocks, asr_metadata
516
+
517
+
518
+ def _pick_audio_asset(content: ContentBundle) -> Any | None:
519
+ audio = [asset for asset in content.assets if asset.media_type == "audio"]
520
+ if audio:
521
+ return sorted(audio, key=lambda asset: asset.order)[0]
522
+ video = [asset for asset in content.assets if asset.media_type == "video"]
523
+ if video:
524
+ return sorted(video, key=lambda asset: asset.order)[0]
525
+ return None
526
+
527
+
528
+ def make_ocr_runner(settings: AppSettings | None = None) -> Any:
529
+ """Build ``ocr_runner(content, output_dir) -> list[ContentBlock]``.
530
+
531
+ Raises :class:`ModelUnavailableError` when the engine/models are absent so
532
+ the pipeline marks the run PARTIAL/blocked with an actionable reason rather
533
+ than silently producing an OCR-less image-note doc (A16/A09).
534
+ """
535
+ settings = settings or AppSettings()
536
+
537
+ def run(content: ContentBundle, output_dir: Path) -> Any:
538
+ image_assets = [asset for asset in content.assets if asset.media_type == "image"]
539
+ if not image_assets:
540
+ return []
541
+ model_dir = _expand(getattr(settings, "ocr_model_dir", None)) or os.path.expanduser(
542
+ DEFAULT_OCR_MODEL_DIR
543
+ )
544
+ from ytk.cache.v2 import LayeredCache
545
+ from ytk.ocr.engine import get_engine
546
+ from ytk.ocr.pipeline import run_ocr_for_content
547
+
548
+ diagnostics: list[str] = []
549
+ blocks = run_ocr_for_content(
550
+ content, Path(output_dir), engine=get_engine(model_dir=model_dir), diagnostics=diagnostics,
551
+ cache=LayeredCache(root=settings.cache_dir, enabled=True),
552
+ )
553
+ for diagnostic in diagnostics:
554
+ LOGGER.warning("OCR: %s", diagnostic)
555
+ return blocks, {"complete": not diagnostics, "diagnostics": diagnostics}
556
+
557
+ return run
558
+
559
+
560
+ def make_artifact_store_factory() -> Any:
561
+ """Factory used by the pipeline to get a locked store per task directory."""
562
+
563
+ def factory(root: Path) -> ArtifactStore:
564
+ return ArtifactStore(Path(root))
565
+
566
+ return factory
567
+
568
+
569
+ __all__ = [
570
+ "ASR_MODEL_CANDIDATES",
571
+ "DEFAULT_OCR_MODEL_DIR",
572
+ "make_artifact_store_factory",
573
+ "make_asr_runner",
574
+ "make_ocr_runner",
575
+ "resolve_asr_model",
576
+ "resolve_asr_backend",
577
+ "transcribe_content",
578
+ ]