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/pipeline/runners.py
ADDED
|
@@ -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
|
+
]
|