textflowkit 0.1.3__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.
- textflowkit/__init__.py +8 -0
- textflowkit/adapters/__init__.py +11 -0
- textflowkit/adapters/http_server.py +465 -0
- textflowkit/adapters/mcp_server.py +554 -0
- textflowkit/cli.py +473 -0
- textflowkit/core/__init__.py +5 -0
- textflowkit/core/batch.py +186 -0
- textflowkit/core/bind.py +66 -0
- textflowkit/core/cancel.py +19 -0
- textflowkit/core/checkpoint.py +389 -0
- textflowkit/core/diarize.py +229 -0
- textflowkit/core/engine.py +127 -0
- textflowkit/core/executor.py +301 -0
- textflowkit/core/jobs.py +241 -0
- textflowkit/core/model.py +98 -0
- textflowkit/core/paths.py +226 -0
- textflowkit/core/pipeline.py +368 -0
- textflowkit/core/retrieval.py +148 -0
- textflowkit/core/runner.py +146 -0
- textflowkit/core/service.py +146 -0
- textflowkit/core/sqlite_store.py +233 -0
- textflowkit/core/submission.py +220 -0
- textflowkit/core/timeutil.py +20 -0
- textflowkit/core/translate.py +244 -0
- textflowkit/render/__init__.py +191 -0
- textflowkit/render/docx.py +71 -0
- textflowkit/render/fonts/NotoSans.ttf +0 -0
- textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
- textflowkit/render/fonts/NotoSansSC.ttf +0 -0
- textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
- textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
- textflowkit/render/fonts/README.md +19 -0
- textflowkit/render/markdown.py +33 -0
- textflowkit/render/pdf.py +136 -0
- textflowkit/render/srt.py +22 -0
- textflowkit/render/txt.py +19 -0
- textflowkit/render/vtt.py +20 -0
- textflowkit/sources/__init__.py +16 -0
- textflowkit/sources/acquire.py +437 -0
- textflowkit/sources/detect.py +200 -0
- textflowkit/sources/scratch.py +32 -0
- textflowkit-0.1.3.dist-info/METADATA +266 -0
- textflowkit-0.1.3.dist-info/RECORD +46 -0
- textflowkit-0.1.3.dist-info/WHEEL +4 -0
- textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
- textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""The shared pipeline.
|
|
2
|
+
|
|
3
|
+
Every platform, every caller, every adapter runs this same path:
|
|
4
|
+
|
|
5
|
+
resolve source -> acquire media -> extract audio -> transcribe -> render
|
|
6
|
+
|
|
7
|
+
Platform differences live entirely in the source layer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from textflowkit.core.checkpoint import (
|
|
22
|
+
CheckpointRecord,
|
|
23
|
+
local_source_identity,
|
|
24
|
+
parse_checkpoint,
|
|
25
|
+
validate_local_resume,
|
|
26
|
+
)
|
|
27
|
+
from textflowkit.core.diarize import DiarizationError, assign_speakers, get_diarizer
|
|
28
|
+
from textflowkit.core.engine import get_engine
|
|
29
|
+
from textflowkit.core.model import Transcript
|
|
30
|
+
from textflowkit.core.paths import (
|
|
31
|
+
UnsafeInputPathError,
|
|
32
|
+
default_input_root,
|
|
33
|
+
resolve_input_path,
|
|
34
|
+
)
|
|
35
|
+
from textflowkit.core.service import enforce_media_limits, enforce_predecode_limits
|
|
36
|
+
from textflowkit.core.translate import (
|
|
37
|
+
TranslationError,
|
|
38
|
+
get_translator,
|
|
39
|
+
translate_segments,
|
|
40
|
+
)
|
|
41
|
+
from textflowkit.render import SUPPORTED_FORMATS, write_all
|
|
42
|
+
from textflowkit.sources.acquire import (
|
|
43
|
+
AcquisitionError,
|
|
44
|
+
extract_audio,
|
|
45
|
+
fetch_media,
|
|
46
|
+
require_tool,
|
|
47
|
+
stage_confined_local_media,
|
|
48
|
+
)
|
|
49
|
+
from textflowkit.sources.detect import resolve_source
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PipelineError(RuntimeError):
|
|
53
|
+
"""Raised when any stage of the pipeline fails."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cleanup_scratch(path: Path) -> None:
|
|
57
|
+
"""Remove a completed attempt, tolerating brief Windows decoder file locks.
|
|
58
|
+
|
|
59
|
+
A failed cleanup must not be silently ignored: the scratch tree may contain
|
|
60
|
+
downloaded media, so report a persistent failure to the caller.
|
|
61
|
+
"""
|
|
62
|
+
for attempt in range(10):
|
|
63
|
+
try:
|
|
64
|
+
shutil.rmtree(path)
|
|
65
|
+
return
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
if attempt == 9:
|
|
70
|
+
raise PipelineError(f"scratch cleanup failed: {exc}") from exc
|
|
71
|
+
time.sleep(0.1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class TranscribeResult:
|
|
76
|
+
transcript: Transcript
|
|
77
|
+
outputs: list[Path]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _existing_path(raw: str | None) -> Path | None:
|
|
81
|
+
if not raw:
|
|
82
|
+
return None
|
|
83
|
+
path = Path(raw)
|
|
84
|
+
return path if path.exists() else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resume_transcript(
|
|
88
|
+
resumed: CheckpointRecord | None,
|
|
89
|
+
*,
|
|
90
|
+
source: str,
|
|
91
|
+
ref,
|
|
92
|
+
) -> Transcript | None:
|
|
93
|
+
if resumed is None or resumed.transcript is None:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
transcript = Transcript.from_dict(resumed.transcript)
|
|
97
|
+
except (KeyError, TypeError, ValueError):
|
|
98
|
+
return None
|
|
99
|
+
transcript.source = source
|
|
100
|
+
transcript.platform = ref.platform
|
|
101
|
+
return transcript
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _checkpoint_paths_usable(resumed: CheckpointRecord | None) -> bool:
|
|
105
|
+
if resumed is None or resumed.transcript is None:
|
|
106
|
+
return False
|
|
107
|
+
return "transcribe" in resumed.finished_stages
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def transcribe(
|
|
111
|
+
source: str,
|
|
112
|
+
*,
|
|
113
|
+
language: str | None = None,
|
|
114
|
+
formats: list[str] | None = None,
|
|
115
|
+
output_dir: str | Path | None = None,
|
|
116
|
+
model: str = "small",
|
|
117
|
+
engine: str = "whisper",
|
|
118
|
+
device: str | None = None,
|
|
119
|
+
cookies_from_browser: str | None = None,
|
|
120
|
+
keep_media: bool = False,
|
|
121
|
+
work_dir: str | Path | None = None,
|
|
122
|
+
check_cancel: Callable[[], None] | None = None,
|
|
123
|
+
input_root: str | Path | None = None,
|
|
124
|
+
diarize: bool = False,
|
|
125
|
+
diarizer_backend: str = "pyannote",
|
|
126
|
+
translate_to: str | None = None,
|
|
127
|
+
translator_backend: str = "ollama",
|
|
128
|
+
resume_checkpoint: dict[str, Any] | None = None,
|
|
129
|
+
on_checkpoint: Callable[[dict[str, Any]], None] | None = None,
|
|
130
|
+
output_id: str | None = None,
|
|
131
|
+
) -> TranscribeResult:
|
|
132
|
+
"""Run the full pipeline for a URL or local file.
|
|
133
|
+
|
|
134
|
+
`check_cancel` is an optional callback invoked at stage boundaries and
|
|
135
|
+
during yt-dlp progress and ffmpeg decode. It is expected to raise to abort
|
|
136
|
+
the run. Model inference and optional postprocessors still only stop at
|
|
137
|
+
their next stage boundary.
|
|
138
|
+
|
|
139
|
+
`resume_checkpoint` carries a validated snapshot from an earlier run.
|
|
140
|
+
Completed transcript work is reused; acquisition and engine work are skipped.
|
|
141
|
+
`on_checkpoint` receives an atomic snapshot after each completed stage.
|
|
142
|
+
"""
|
|
143
|
+
resumed = parse_checkpoint(resume_checkpoint)
|
|
144
|
+
finished_stages = list(resumed.finished_stages) if resumed else []
|
|
145
|
+
media: Path | None = None
|
|
146
|
+
audio: Path | None = None
|
|
147
|
+
transcript: Transcript | None = None
|
|
148
|
+
local_identity: dict[str, Any] | None = resumed.local_identity if resumed else None
|
|
149
|
+
|
|
150
|
+
def _checkpoint(stage: str | None = None) -> CheckpointRecord | None:
|
|
151
|
+
if check_cancel is not None:
|
|
152
|
+
check_cancel()
|
|
153
|
+
if stage is None or on_checkpoint is None:
|
|
154
|
+
return None
|
|
155
|
+
if stage not in finished_stages:
|
|
156
|
+
finished_stages.append(stage)
|
|
157
|
+
snapshot = CheckpointRecord(
|
|
158
|
+
source=source,
|
|
159
|
+
model=model,
|
|
160
|
+
language=language,
|
|
161
|
+
engine=engine,
|
|
162
|
+
device=resumed.device if resumed else device,
|
|
163
|
+
options={
|
|
164
|
+
"formats": list(formats or []),
|
|
165
|
+
"diarize": diarize,
|
|
166
|
+
"diarizer_backend": diarizer_backend,
|
|
167
|
+
"translate_to": translate_to,
|
|
168
|
+
"translator_backend": translator_backend,
|
|
169
|
+
},
|
|
170
|
+
finished_stages=list(finished_stages),
|
|
171
|
+
transcript=transcript.to_dict() if isinstance(transcript, Transcript) else None,
|
|
172
|
+
media_path=_recordable(media),
|
|
173
|
+
audio_path=_recordable(audio),
|
|
174
|
+
local_identity=local_identity,
|
|
175
|
+
)
|
|
176
|
+
on_checkpoint(snapshot.to_dict())
|
|
177
|
+
return snapshot
|
|
178
|
+
|
|
179
|
+
def _recordable(path: Path | None) -> str | None:
|
|
180
|
+
"""A path we can honestly promise to a later run.
|
|
181
|
+
|
|
182
|
+
The scratch directory is deleted at the end of a run, so recording a
|
|
183
|
+
path inside it produces a checkpoint that looks valid but can never be
|
|
184
|
+
used - resume finds the file gone and silently redoes the work. Only
|
|
185
|
+
paths outside scratch are worth storing.
|
|
186
|
+
"""
|
|
187
|
+
if path is None:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
resolved = Path(path).resolve()
|
|
191
|
+
except OSError:
|
|
192
|
+
return None
|
|
193
|
+
if scratch is not None and scratch.resolve() in resolved.parents:
|
|
194
|
+
return None
|
|
195
|
+
return str(path)
|
|
196
|
+
|
|
197
|
+
_checkpoint()
|
|
198
|
+
|
|
199
|
+
formats = formats or ["json", "srt", "txt"]
|
|
200
|
+
for fmt in formats:
|
|
201
|
+
if fmt.lower().lstrip(".") not in SUPPORTED_FORMATS:
|
|
202
|
+
raise PipelineError(
|
|
203
|
+
f"unsupported format '{fmt}'; choose from {', '.join(SUPPORTED_FORMATS)}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# A local path may be confined; a URL is guarded separately by the SSRF
|
|
207
|
+
# check inside resolve_source. `input_root=None` means "use the configured
|
|
208
|
+
# root if one is set", which keeps the CLI unconfined by default.
|
|
209
|
+
root = input_root if input_root is not None else default_input_root()
|
|
210
|
+
resolved_source = source
|
|
211
|
+
if not source.startswith(("http://", "https://")):
|
|
212
|
+
try:
|
|
213
|
+
resolved_source = str(resolve_input_path(source, root=root))
|
|
214
|
+
except (FileNotFoundError, ValueError, UnsafeInputPathError) as exc:
|
|
215
|
+
raise PipelineError(str(exc)) from exc
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
ref = resolve_source(resolved_source)
|
|
219
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
220
|
+
raise PipelineError(str(exc)) from exc
|
|
221
|
+
|
|
222
|
+
_checkpoint("source")
|
|
223
|
+
|
|
224
|
+
if resumed is not None and ref.kind == "file":
|
|
225
|
+
try:
|
|
226
|
+
validate_local_resume(resumed, resolved_source, input_root=root)
|
|
227
|
+
except ValueError as exc:
|
|
228
|
+
raise PipelineError(str(exc)) from exc
|
|
229
|
+
|
|
230
|
+
if work_dir is not None:
|
|
231
|
+
Path(work_dir).mkdir(parents=True, exist_ok=True)
|
|
232
|
+
scratch = Path(tempfile.mkdtemp(prefix="textflowkit-", dir=work_dir))
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
transcript = _resume_transcript(resumed, source=source, ref=ref)
|
|
236
|
+
if resumed is not None:
|
|
237
|
+
media = _existing_path(resumed.media_path)
|
|
238
|
+
audio = _existing_path(resumed.audio_path)
|
|
239
|
+
|
|
240
|
+
# Resuming means "the transcript already exists, do not transcribe again".
|
|
241
|
+
# The media files are a separate question: they live in scratch and are
|
|
242
|
+
# deleted after every run, so requiring them would make resume impossible.
|
|
243
|
+
# If a later stage (diarization) genuinely needs the audio and it is gone,
|
|
244
|
+
# re-acquire it - that is far cheaper than re-running Whisper.
|
|
245
|
+
can_resume = transcript is not None and _checkpoint_paths_usable(resumed)
|
|
246
|
+
if not can_resume:
|
|
247
|
+
transcript = None
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
if not can_resume:
|
|
251
|
+
require_tool("ffmpeg")
|
|
252
|
+
if ref.kind == "file" and root is not None:
|
|
253
|
+
media = stage_confined_local_media(
|
|
254
|
+
ref.location, work_dir=scratch, input_root=root
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
media = fetch_media(
|
|
258
|
+
ref,
|
|
259
|
+
work_dir=scratch,
|
|
260
|
+
cookies_from_browser=cookies_from_browser,
|
|
261
|
+
check_cancel=check_cancel,
|
|
262
|
+
)
|
|
263
|
+
if ref.kind == "file":
|
|
264
|
+
local_identity = local_source_identity(
|
|
265
|
+
resolved_source, input_root=root, content_path=media,
|
|
266
|
+
)
|
|
267
|
+
_checkpoint("fetch")
|
|
268
|
+
enforce_predecode_limits(media)
|
|
269
|
+
audio = extract_audio(media, work_dir=scratch, check_cancel=check_cancel)
|
|
270
|
+
enforce_media_limits(media, audio)
|
|
271
|
+
_checkpoint("extract")
|
|
272
|
+
|
|
273
|
+
eng = get_engine(engine, model=model, device=device)
|
|
274
|
+
try:
|
|
275
|
+
transcript = eng.transcribe(audio, language=language)
|
|
276
|
+
except Exception as exc: # engine failures are user-facing
|
|
277
|
+
raise PipelineError(f"transcription failed: {exc}") from exc
|
|
278
|
+
if ref.kind == "file":
|
|
279
|
+
try:
|
|
280
|
+
current = local_source_identity(resolved_source, input_root=root)
|
|
281
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
282
|
+
raise PipelineError("local source changed during transcription") from exc
|
|
283
|
+
if current != local_identity:
|
|
284
|
+
raise PipelineError("local source changed during transcription")
|
|
285
|
+
_checkpoint("transcribe")
|
|
286
|
+
elif diarize and audio is None:
|
|
287
|
+
# A finished transcript is the expensive checkpoint. Reacquire
|
|
288
|
+
# only the audio required by pyannote; never rerun Whisper.
|
|
289
|
+
require_tool("ffmpeg")
|
|
290
|
+
if media is None:
|
|
291
|
+
if ref.kind == "file" and root is not None:
|
|
292
|
+
media = stage_confined_local_media(
|
|
293
|
+
ref.location, work_dir=scratch, input_root=root
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
media = fetch_media(
|
|
297
|
+
ref, work_dir=scratch,
|
|
298
|
+
cookies_from_browser=cookies_from_browser,
|
|
299
|
+
check_cancel=check_cancel,
|
|
300
|
+
)
|
|
301
|
+
_checkpoint("fetch")
|
|
302
|
+
enforce_predecode_limits(media)
|
|
303
|
+
audio = extract_audio(media, work_dir=scratch, check_cancel=check_cancel)
|
|
304
|
+
enforce_media_limits(media, audio)
|
|
305
|
+
_checkpoint("extract")
|
|
306
|
+
except (AcquisitionError, UnsafeInputPathError) as exc:
|
|
307
|
+
raise PipelineError(str(exc)) from exc
|
|
308
|
+
|
|
309
|
+
if diarize:
|
|
310
|
+
# Refuse loudly rather than returning a transcript with empty speakers.
|
|
311
|
+
# A silent no-op here is exactly the defect that was removed from
|
|
312
|
+
# --speaker-labels, and it must not come back through this door.
|
|
313
|
+
if audio is None:
|
|
314
|
+
raise PipelineError("diarization requested but no audio is available for resume")
|
|
315
|
+
try:
|
|
316
|
+
diarizer = get_diarizer(diarizer_backend)
|
|
317
|
+
turns = diarizer.diarize(audio)
|
|
318
|
+
except DiarizationError as exc:
|
|
319
|
+
raise PipelineError(f"diarization requested but unavailable: {exc}") from exc
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
raise PipelineError(f"diarization failed: {exc}") from exc
|
|
322
|
+
labelled = assign_speakers(transcript.segments, turns)
|
|
323
|
+
transcript.metadata["diarization"] = {
|
|
324
|
+
"backend": getattr(diarizer, "name", diarizer_backend),
|
|
325
|
+
"speakers": sorted({t.speaker for t in turns}),
|
|
326
|
+
"turns": len(turns),
|
|
327
|
+
"segments_labelled": labelled,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if translate_to:
|
|
331
|
+
# Refuse loudly: never present source text as though it were translated.
|
|
332
|
+
try:
|
|
333
|
+
translator = get_translator(translator_backend)
|
|
334
|
+
translated = translate_segments(
|
|
335
|
+
transcript.segments, translate_to, translator=translator
|
|
336
|
+
)
|
|
337
|
+
except TranslationError as exc:
|
|
338
|
+
raise PipelineError(f"translation requested but unavailable: {exc}") from exc
|
|
339
|
+
except ValueError as exc:
|
|
340
|
+
raise PipelineError(f"translation failed: {exc}") from exc
|
|
341
|
+
transcript.metadata["translation"] = {
|
|
342
|
+
"backend": getattr(translator, "name", translator_backend),
|
|
343
|
+
"route": getattr(translator, "route", "unknown"),
|
|
344
|
+
"target": translate_to,
|
|
345
|
+
"segments_translated": translated,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_checkpoint("postprocess")
|
|
349
|
+
|
|
350
|
+
transcript.source = source
|
|
351
|
+
transcript.platform = ref.platform
|
|
352
|
+
|
|
353
|
+
outputs: list[Path] = []
|
|
354
|
+
if output_dir is not None:
|
|
355
|
+
stem = Path(ref.location).stem if ref.kind != "url" else "transcript"
|
|
356
|
+
stem = stem.replace("textflowkit-", "") or "transcript"
|
|
357
|
+
stem = f"{stem}-{output_id or uuid.uuid4().hex[:16]}"
|
|
358
|
+
outputs = write_all(transcript, formats=formats, output_dir=output_dir, stem=stem)
|
|
359
|
+
_checkpoint("render")
|
|
360
|
+
|
|
361
|
+
assert transcript is not None
|
|
362
|
+
result = TranscribeResult(transcript=transcript, outputs=outputs)
|
|
363
|
+
except BaseException:
|
|
364
|
+
_cleanup_scratch(scratch)
|
|
365
|
+
raise
|
|
366
|
+
if not keep_media:
|
|
367
|
+
_cleanup_scratch(scratch)
|
|
368
|
+
return result
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Transcript retrieval: paging, time ranges, and search.
|
|
2
|
+
|
|
3
|
+
A long transcript returned whole is a context problem. A 19-minute video is
|
|
4
|
+
already ~51 KB of JSON (~214 segments); a 3-hour podcast is several hundred KB
|
|
5
|
+
dropped into a model's context in a single tool result, most of it irrelevant to
|
|
6
|
+
the question being asked.
|
|
7
|
+
|
|
8
|
+
This module does the slicing in one place so the MCP and HTTP adapters cannot
|
|
9
|
+
disagree about what `offset` or `start` mean - the same reasoning that put the
|
|
10
|
+
pipeline in core rather than in each adapter.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from textflowkit.core.model import Segment, Transcript
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class Page:
|
|
22
|
+
"""A window onto a transcript's segments."""
|
|
23
|
+
|
|
24
|
+
segments: list[Segment]
|
|
25
|
+
total: int
|
|
26
|
+
offset: int
|
|
27
|
+
returned: int
|
|
28
|
+
has_more: bool
|
|
29
|
+
start: float
|
|
30
|
+
end: float
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"total_segments": self.total,
|
|
35
|
+
"offset": self.offset,
|
|
36
|
+
"returned": self.returned,
|
|
37
|
+
"has_more": self.has_more,
|
|
38
|
+
"start": self.start,
|
|
39
|
+
"end": self.end,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class Match:
|
|
45
|
+
"""A search hit, plus optional surrounding context."""
|
|
46
|
+
|
|
47
|
+
index: int
|
|
48
|
+
segment: Segment
|
|
49
|
+
context_before: list[Segment] = field(default_factory=list)
|
|
50
|
+
context_after: list[Segment] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def page_segments(
|
|
54
|
+
transcript: Transcript,
|
|
55
|
+
*,
|
|
56
|
+
offset: int = 0,
|
|
57
|
+
limit: int | None = None,
|
|
58
|
+
start: float | None = None,
|
|
59
|
+
end: float | None = None,
|
|
60
|
+
) -> Page:
|
|
61
|
+
"""Slice a transcript by time range and/or page offset.
|
|
62
|
+
|
|
63
|
+
Filtering order is time first, then offset/limit within the filtered set, so
|
|
64
|
+
`offset` counts from the start of the requested window rather than from the
|
|
65
|
+
beginning of the transcript.
|
|
66
|
+
|
|
67
|
+
Raises `ValueError` for a negative offset or limit.
|
|
68
|
+
"""
|
|
69
|
+
if offset < 0:
|
|
70
|
+
raise ValueError("offset must be >= 0")
|
|
71
|
+
if limit is not None and limit < 0:
|
|
72
|
+
raise ValueError("limit must be >= 0")
|
|
73
|
+
|
|
74
|
+
segments = [s for s in transcript.segments if not s.hidden]
|
|
75
|
+
|
|
76
|
+
if start is not None:
|
|
77
|
+
segments = [s for s in segments if s.end >= start]
|
|
78
|
+
if end is not None:
|
|
79
|
+
segments = [s for s in segments if s.start <= end]
|
|
80
|
+
|
|
81
|
+
total = len(segments)
|
|
82
|
+
window = segments[offset:] if offset else segments
|
|
83
|
+
if limit is not None:
|
|
84
|
+
window = window[:limit]
|
|
85
|
+
|
|
86
|
+
first = window[0].start if window else 0.0
|
|
87
|
+
last = window[-1].end if window else 0.0
|
|
88
|
+
consumed = offset + len(window)
|
|
89
|
+
|
|
90
|
+
return Page(
|
|
91
|
+
segments=window,
|
|
92
|
+
total=total,
|
|
93
|
+
offset=offset,
|
|
94
|
+
returned=len(window),
|
|
95
|
+
has_more=consumed < total,
|
|
96
|
+
start=first,
|
|
97
|
+
end=last,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def search_segments(
|
|
102
|
+
transcript: Transcript,
|
|
103
|
+
query: str,
|
|
104
|
+
*,
|
|
105
|
+
limit: int = 20,
|
|
106
|
+
context: int = 0,
|
|
107
|
+
case_sensitive: bool = False,
|
|
108
|
+
) -> list[Match]:
|
|
109
|
+
"""Find segments whose text contains `query`.
|
|
110
|
+
|
|
111
|
+
`context` includes that many neighbouring segments either side, which is
|
|
112
|
+
usually what makes a hit readable. Matching is substring, not fuzzy - the
|
|
113
|
+
caller can ask again with a different phrase.
|
|
114
|
+
|
|
115
|
+
Raises `ValueError` for an empty query or negative limit/context.
|
|
116
|
+
"""
|
|
117
|
+
if not query:
|
|
118
|
+
raise ValueError("query must not be empty")
|
|
119
|
+
if limit < 0:
|
|
120
|
+
raise ValueError("limit must be >= 0")
|
|
121
|
+
if context < 0:
|
|
122
|
+
raise ValueError("context must be >= 0")
|
|
123
|
+
if limit == 0:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
needle = query if case_sensitive else query.lower()
|
|
127
|
+
visible = [s for s in transcript.segments if not s.hidden]
|
|
128
|
+
|
|
129
|
+
matches: list[Match] = []
|
|
130
|
+
for idx, segment in enumerate(visible):
|
|
131
|
+
hay = segment.text if case_sensitive else segment.text.lower()
|
|
132
|
+
translated = segment.translated_text
|
|
133
|
+
if translated:
|
|
134
|
+
hay_translated = translated if case_sensitive else translated.lower()
|
|
135
|
+
else:
|
|
136
|
+
hay_translated = ""
|
|
137
|
+
if needle in hay or (hay_translated and needle in hay_translated):
|
|
138
|
+
matches.append(
|
|
139
|
+
Match(
|
|
140
|
+
index=idx,
|
|
141
|
+
segment=segment,
|
|
142
|
+
context_before=visible[max(0, idx - context): idx] if context else [],
|
|
143
|
+
context_after=visible[idx + 1: idx + 1 + context] if context else [],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
if len(matches) >= limit:
|
|
147
|
+
break
|
|
148
|
+
return matches
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Job execution.
|
|
2
|
+
|
|
3
|
+
`run_job` executes one job synchronously and owns its state transitions.
|
|
4
|
+
`submit` is the entry point callers use; it delegates to the process-wide
|
|
5
|
+
`JobExecutor`, so jobs get bounded concurrency and can be cancelled.
|
|
6
|
+
|
|
7
|
+
Cancellation contract:
|
|
8
|
+
|
|
9
|
+
- `JobCancelled` is an orderly stop, not a failure. The job ends CANCELLED.
|
|
10
|
+
- A job that finishes while a cancellation is in flight must not overwrite the
|
|
11
|
+
cancelled state with DONE, so the final transition re-reads the job first.
|
|
12
|
+
- Explicit resume of an interrupted job is the one path allowed past the
|
|
13
|
+
terminal-state guard. Callers use `prepare_resume` first, which resets the job
|
|
14
|
+
to PENDING while preserving its checkpoint.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from textflowkit.core.checkpoint import write_checkpoint
|
|
24
|
+
from textflowkit.core.executor import JobCancelled, get_default_executor
|
|
25
|
+
from textflowkit.core.jobs import Job, JobState, JobStore
|
|
26
|
+
from textflowkit.core.model import Transcript
|
|
27
|
+
from textflowkit.core.pipeline import PipelineError, transcribe
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_job(
|
|
31
|
+
job: Job,
|
|
32
|
+
store: JobStore,
|
|
33
|
+
*,
|
|
34
|
+
source: str,
|
|
35
|
+
language: str | None = None,
|
|
36
|
+
formats: list[str] | None = None,
|
|
37
|
+
output_dir: str | Path | None = None,
|
|
38
|
+
model: str = "small",
|
|
39
|
+
engine: str = "whisper",
|
|
40
|
+
device: str | None = None,
|
|
41
|
+
cookies_from_browser: str | None = None,
|
|
42
|
+
keep_media: bool = False,
|
|
43
|
+
work_dir: str | Path | None = None,
|
|
44
|
+
check_cancel: Callable[[], None] | None = None,
|
|
45
|
+
input_root: str | Path | None = None,
|
|
46
|
+
diarize: bool = False,
|
|
47
|
+
diarizer_backend: str = "pyannote",
|
|
48
|
+
translate_to: str | None = None,
|
|
49
|
+
translator_backend: str = "ollama",
|
|
50
|
+
resume_checkpoint: dict[str, Any] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Execute a job, recording its terminal state. Callers decide the thread."""
|
|
53
|
+
# Do not start work that has already been cancelled or otherwise finished.
|
|
54
|
+
# `submit` only hands us fresh jobs; explicit resume prepares the row first.
|
|
55
|
+
current = store.get(job.id)
|
|
56
|
+
if current is not None and current.is_terminal:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
store.update(job.id, state=JobState.RUNNING, progress="starting")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = transcribe(
|
|
63
|
+
source,
|
|
64
|
+
language=language,
|
|
65
|
+
formats=formats,
|
|
66
|
+
output_dir=output_dir,
|
|
67
|
+
model=model,
|
|
68
|
+
engine=engine,
|
|
69
|
+
device=device,
|
|
70
|
+
cookies_from_browser=cookies_from_browser,
|
|
71
|
+
keep_media=keep_media,
|
|
72
|
+
work_dir=work_dir,
|
|
73
|
+
check_cancel=check_cancel,
|
|
74
|
+
input_root=input_root,
|
|
75
|
+
diarize=diarize,
|
|
76
|
+
diarizer_backend=diarizer_backend,
|
|
77
|
+
translate_to=translate_to,
|
|
78
|
+
translator_backend=translator_backend,
|
|
79
|
+
resume_checkpoint=resume_checkpoint,
|
|
80
|
+
on_checkpoint=lambda record: write_checkpoint(store, job.id, record),
|
|
81
|
+
output_id=job.id,
|
|
82
|
+
)
|
|
83
|
+
except JobCancelled:
|
|
84
|
+
store.update(job.id, state=JobState.CANCELLED, progress="cancelled")
|
|
85
|
+
return
|
|
86
|
+
except PipelineError as exc:
|
|
87
|
+
store.update(job.id, state=JobState.ERROR, error=str(exc), progress="failed")
|
|
88
|
+
return
|
|
89
|
+
# Last-resort guard: a job must never be left stuck in RUNNING because of an
|
|
90
|
+
# unexpected exception type. The error is recorded on the job, not swallowed.
|
|
91
|
+
# Narrower catches above handle the expected failure modes.
|
|
92
|
+
except Exception as exc: # noqa: BLE001
|
|
93
|
+
store.update(
|
|
94
|
+
job.id,
|
|
95
|
+
state=JobState.ERROR,
|
|
96
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
97
|
+
progress="failed",
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# A cancellation that arrived while the last stage ran must win over DONE.
|
|
102
|
+
latest = store.get(job.id)
|
|
103
|
+
if latest is not None and latest.state is JobState.CANCELLED:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
store.update(
|
|
107
|
+
job.id,
|
|
108
|
+
state=JobState.DONE,
|
|
109
|
+
progress="complete",
|
|
110
|
+
transcript=result.transcript.to_dict(),
|
|
111
|
+
outputs=[str(p) for p in result.outputs],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def submit(
|
|
116
|
+
store: JobStore,
|
|
117
|
+
*,
|
|
118
|
+
source: str,
|
|
119
|
+
background: bool = True,
|
|
120
|
+
**kwargs,
|
|
121
|
+
) -> Job:
|
|
122
|
+
"""Create a job and schedule it.
|
|
123
|
+
|
|
124
|
+
`background=False` runs inline (used by tests and by callers that want a
|
|
125
|
+
blocking call). Otherwise the job goes to the process-wide executor, which
|
|
126
|
+
bounds concurrency and can cancel it.
|
|
127
|
+
"""
|
|
128
|
+
if not background:
|
|
129
|
+
job = store.create(source)
|
|
130
|
+
run_job(job, store, source=source, **kwargs)
|
|
131
|
+
return job
|
|
132
|
+
|
|
133
|
+
executor = get_default_executor()
|
|
134
|
+
if executor.store is not store:
|
|
135
|
+
# A caller passed a specific store; run it directly rather than silently
|
|
136
|
+
# routing the job to a different store than the one they hold.
|
|
137
|
+
job = store.create(source)
|
|
138
|
+
run_job(job, store, source=source, **kwargs)
|
|
139
|
+
return job
|
|
140
|
+
return executor.submit(source=source, **kwargs)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def transcript_for(job: Job) -> Transcript | None:
|
|
144
|
+
if job.transcript is None:
|
|
145
|
+
return None
|
|
146
|
+
return Transcript.from_dict(job.transcript)
|