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
textflowkit/core/bind.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Bind-safety guard for the HTTP surfaces.
|
|
2
|
+
|
|
3
|
+
Developer-mode HTTP and Streamable-HTTP MCP are unauthenticated. The JSON HTTP
|
|
4
|
+
adapter has a separate opt-in production Bearer-token profile. The dangerous
|
|
5
|
+
configuration is an unauthenticated, file-writing, network-fetching API bound
|
|
6
|
+
beyond loopback.
|
|
7
|
+
|
|
8
|
+
This guard does not add auth. It refuses that specific configuration unless the
|
|
9
|
+
operator says so explicitly, so the failure mode is a clear startup error rather
|
|
10
|
+
than a silently exposed service.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
ENV_ALLOW_REMOTE = "TEXTFLOWKIT_ALLOW_REMOTE"
|
|
19
|
+
|
|
20
|
+
_LOOPBACK_NAMES = frozenset({"localhost", "localhost.localdomain", "::1"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnsafeBindError(ValueError):
|
|
24
|
+
"""Raised when a bind address would expose a service without explicit opt-in."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_loopback_host(host: str) -> bool:
|
|
28
|
+
"""True if `host` is loopback (or a loopback name).
|
|
29
|
+
|
|
30
|
+
A bare hostname that is not `localhost` is treated as non-loopback: it may
|
|
31
|
+
resolve anywhere, and we cannot prove otherwise cheaply.
|
|
32
|
+
"""
|
|
33
|
+
if not host:
|
|
34
|
+
return False
|
|
35
|
+
candidate = host.strip().strip("[]").lower()
|
|
36
|
+
if candidate in _LOOPBACK_NAMES:
|
|
37
|
+
return True
|
|
38
|
+
try:
|
|
39
|
+
return ipaddress.ip_address(candidate).is_loopback
|
|
40
|
+
except ValueError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remote_allowed(explicit: bool | None = None) -> bool:
|
|
45
|
+
"""Whether exposing the service beyond loopback was explicitly permitted."""
|
|
46
|
+
if explicit is not None:
|
|
47
|
+
return explicit
|
|
48
|
+
raw = os.environ.get(ENV_ALLOW_REMOTE, "")
|
|
49
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_bind_safety(host: str, *, allow_remote: bool | None = None) -> None:
|
|
53
|
+
"""Refuse a non-loopback bind unless it was explicitly requested.
|
|
54
|
+
|
|
55
|
+
Raises `UnsafeBindError` with an actionable message.
|
|
56
|
+
"""
|
|
57
|
+
if is_loopback_host(host):
|
|
58
|
+
return
|
|
59
|
+
if remote_allowed(allow_remote):
|
|
60
|
+
return
|
|
61
|
+
raise UnsafeBindError(
|
|
62
|
+
f"refusing to bind to '{host}': developer HTTP/MCP is unauthenticated, "
|
|
63
|
+
"so binding beyond loopback would expose it on the network. "
|
|
64
|
+
"Bind to 127.0.0.1, or pass --allow-remote (or set "
|
|
65
|
+
f"{ENV_ALLOW_REMOTE}=1) if you have a gateway in front of it."
|
|
66
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The cancellation signal.
|
|
2
|
+
|
|
3
|
+
Lives in its own leaf module so lower layers (the source layer, which must abort
|
|
4
|
+
a download mid-flight) can re-raise it without importing the executor. Sources
|
|
5
|
+
cannot import `core.executor` - `core.pipeline` imports sources, so that would
|
|
6
|
+
be a cycle.
|
|
7
|
+
|
|
8
|
+
Anything that catches broad exceptions while doing cancellable work MUST re-raise
|
|
9
|
+
this first, or a cancellation silently becomes a failure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CancelledError(Exception):
|
|
16
|
+
"""Raised when cancellation has been requested.
|
|
17
|
+
|
|
18
|
+
Callers must not treat this as a failure - it is an orderly stop.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Resumable transcription checkpoints.
|
|
2
|
+
|
|
3
|
+
A checkpoint is stored on the existing `Job` record, so there is no second
|
|
4
|
+
persistence mechanism to keep consistent. The store owns durability; this module
|
|
5
|
+
owns the shape of the record, safe reading, and matching a request against a
|
|
6
|
+
candidate.
|
|
7
|
+
|
|
8
|
+
Two rules matter for correctness:
|
|
9
|
+
|
|
10
|
+
- A malformed checkpoint is treated as absent. A corrupt record must never make
|
|
11
|
+
resume crash; it means "start clean".
|
|
12
|
+
- A checkpoint is only reusable when source, model, and language match. Anything
|
|
13
|
+
less can silently mix transcripts from different runs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from textflowkit.core.jobs import Job, JobState, JobStore
|
|
26
|
+
from textflowkit.core.model import Transcript
|
|
27
|
+
from textflowkit.core.paths import opened_file_path, resolve_input_path
|
|
28
|
+
from textflowkit.render import ensure_outputs
|
|
29
|
+
|
|
30
|
+
CHECKPOINT_VERSION = 2
|
|
31
|
+
RESUMABLE_STATES = frozenset(
|
|
32
|
+
{JobState.PENDING, JobState.RUNNING, JobState.ERROR, JobState.CANCELLED, JobState.DONE}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CheckpointError(ValueError):
|
|
37
|
+
"""Raised when explicitly creating a malformed checkpoint."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class CheckpointRecord:
|
|
42
|
+
"""A durable snapshot of completed pipeline work for one source."""
|
|
43
|
+
|
|
44
|
+
source: str
|
|
45
|
+
model: str
|
|
46
|
+
language: str | None = None
|
|
47
|
+
engine: str = "whisper"
|
|
48
|
+
device: str | None = None
|
|
49
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
finished_stages: list[str] = field(default_factory=list)
|
|
51
|
+
transcript: dict[str, Any] | None = None
|
|
52
|
+
media_path: str | None = None
|
|
53
|
+
audio_path: str | None = None
|
|
54
|
+
local_identity: dict[str, Any] | None = None
|
|
55
|
+
version: int = CHECKPOINT_VERSION
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"version": self.version,
|
|
60
|
+
"source": self.source,
|
|
61
|
+
"model": self.model,
|
|
62
|
+
"language": self.language,
|
|
63
|
+
"engine": self.engine,
|
|
64
|
+
"device": self.device,
|
|
65
|
+
"options": dict(self.options),
|
|
66
|
+
"finished_stages": list(self.finished_stages),
|
|
67
|
+
"transcript": self.transcript,
|
|
68
|
+
"media_path": self.media_path,
|
|
69
|
+
"audio_path": self.audio_path,
|
|
70
|
+
"local_identity": self.local_identity,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_dict(cls, data: dict[str, Any]) -> CheckpointRecord:
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise CheckpointError("checkpoint is not an object")
|
|
77
|
+
# Records written before versioning was explicit are legacy v1, not v2.
|
|
78
|
+
version = data.get("version", 1)
|
|
79
|
+
if version not in {1, CHECKPOINT_VERSION}:
|
|
80
|
+
raise CheckpointError(f"unsupported checkpoint version: {version!r}")
|
|
81
|
+
source = data.get("source")
|
|
82
|
+
model = data.get("model")
|
|
83
|
+
if not isinstance(source, str) or not source:
|
|
84
|
+
raise CheckpointError("checkpoint is missing source")
|
|
85
|
+
if not isinstance(model, str) or not model:
|
|
86
|
+
raise CheckpointError("checkpoint is missing model")
|
|
87
|
+
language = data.get("language")
|
|
88
|
+
if language is not None and not isinstance(language, str):
|
|
89
|
+
raise CheckpointError("checkpoint language is invalid")
|
|
90
|
+
options = data.get("options") or {}
|
|
91
|
+
if not isinstance(options, dict):
|
|
92
|
+
raise CheckpointError("checkpoint options are invalid")
|
|
93
|
+
stages = data.get("finished_stages") or []
|
|
94
|
+
if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
|
|
95
|
+
raise CheckpointError("checkpoint finished_stages are invalid")
|
|
96
|
+
transcript = data.get("transcript")
|
|
97
|
+
if not isinstance(transcript, dict):
|
|
98
|
+
# A checkpoint without a validated transcript cannot be resumed;
|
|
99
|
+
# treat it as corrupt rather than "start from nothing".
|
|
100
|
+
raise CheckpointError("checkpoint is missing a transcript")
|
|
101
|
+
# Validate nested transcript structure now; callers should never
|
|
102
|
+
# discover corruption halfway through a resumed pipeline.
|
|
103
|
+
Transcript.from_dict(transcript)
|
|
104
|
+
if "transcribe" not in stages:
|
|
105
|
+
raise CheckpointError("checkpoint has not finished transcription")
|
|
106
|
+
identity = data.get("local_identity") if version == CHECKPOINT_VERSION else None
|
|
107
|
+
if identity is not None and not _valid_local_identity(identity):
|
|
108
|
+
raise CheckpointError("checkpoint local source identity is invalid")
|
|
109
|
+
return cls(
|
|
110
|
+
version=version,
|
|
111
|
+
source=source,
|
|
112
|
+
model=model,
|
|
113
|
+
language=language,
|
|
114
|
+
engine=str(data.get("engine") or "whisper"),
|
|
115
|
+
device=data.get("device") if isinstance(data.get("device"), str) else None,
|
|
116
|
+
options=dict(options),
|
|
117
|
+
finished_stages=list(stages),
|
|
118
|
+
transcript=transcript,
|
|
119
|
+
media_path=_optional_str(data.get("media_path")),
|
|
120
|
+
audio_path=_optional_str(data.get("audio_path")),
|
|
121
|
+
local_identity=identity,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _optional_str(value: Any) -> str | None:
|
|
126
|
+
return value if isinstance(value, str) and value else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _valid_local_identity(identity: Any) -> bool:
|
|
130
|
+
return (
|
|
131
|
+
isinstance(identity, dict)
|
|
132
|
+
and isinstance(identity.get("path"), str)
|
|
133
|
+
and bool(identity["path"])
|
|
134
|
+
and isinstance(identity.get("size"), int)
|
|
135
|
+
and identity["size"] >= 0
|
|
136
|
+
and isinstance(identity.get("sha256"), str)
|
|
137
|
+
and len(identity["sha256"]) == 64
|
|
138
|
+
and all(c in "0123456789abcdef" for c in identity["sha256"])
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_local_source(source: str) -> bool:
|
|
143
|
+
return not source.startswith(("http://", "https://"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def local_source_identity(
|
|
147
|
+
source: str,
|
|
148
|
+
*,
|
|
149
|
+
input_root: str | Path | None = None,
|
|
150
|
+
content_path: str | Path | None = None,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
"""Fingerprint bytes and a normalized source path, with a stable open handle.
|
|
153
|
+
|
|
154
|
+
``content_path`` is the handle-verified staged copy when input confinement is
|
|
155
|
+
active; the logical identity still names the original source. A changed
|
|
156
|
+
source is rechecked after transcription before a checkpoint is published.
|
|
157
|
+
"""
|
|
158
|
+
logical = resolve_input_path(source, root=input_root)
|
|
159
|
+
path = Path(content_path) if content_path is not None else logical
|
|
160
|
+
digest = hashlib.sha256()
|
|
161
|
+
with path.open("rb") as opened:
|
|
162
|
+
actual = opened_file_path(opened.fileno(), path)
|
|
163
|
+
if content_path is None and actual != logical:
|
|
164
|
+
raise ValueError("local source changed while it was opened")
|
|
165
|
+
before = os.fstat(opened.fileno())
|
|
166
|
+
while chunk := opened.read(1024 * 1024):
|
|
167
|
+
digest.update(chunk)
|
|
168
|
+
after = os.fstat(opened.fileno())
|
|
169
|
+
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
170
|
+
raise ValueError("local source changed while it was fingerprinted")
|
|
171
|
+
return {"path": str(logical), "size": after.st_size, "sha256": digest.hexdigest()}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_local_resume(
|
|
175
|
+
record: CheckpointRecord,
|
|
176
|
+
source: str,
|
|
177
|
+
*,
|
|
178
|
+
input_root: str | Path | None = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Fail closed for missing, changed, or pre-v2 local-source checkpoints."""
|
|
181
|
+
if not is_local_source(source):
|
|
182
|
+
return # URL bytes can change; URL resume is a separate explicit policy.
|
|
183
|
+
if record.version < CHECKPOINT_VERSION or record.local_identity is None:
|
|
184
|
+
raise ValueError("legacy local checkpoint has no fingerprint; resubmit without resume")
|
|
185
|
+
try:
|
|
186
|
+
current = local_source_identity(source, input_root=input_root)
|
|
187
|
+
except (FileNotFoundError, OSError) as exc:
|
|
188
|
+
raise ValueError("local source is missing or unreadable; resubmit without resume") from exc
|
|
189
|
+
if current != record.local_identity:
|
|
190
|
+
raise ValueError("local source changed since checkpoint; resubmit without resume")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_checkpoint(job: Job | None) -> CheckpointRecord | None:
|
|
194
|
+
"""Return a valid checkpoint from a job, or None on absent/corrupt data."""
|
|
195
|
+
if job is None or job.checkpoint is None:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
return CheckpointRecord.from_dict(job.checkpoint)
|
|
199
|
+
except (CheckpointError, TypeError, ValueError, KeyError):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_checkpoint(raw: Any) -> CheckpointRecord | None:
|
|
204
|
+
"""Parse an untrusted checkpoint value, returning None instead of raising."""
|
|
205
|
+
if raw is None:
|
|
206
|
+
return None
|
|
207
|
+
if isinstance(raw, str):
|
|
208
|
+
try:
|
|
209
|
+
raw = json.loads(raw)
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
return None
|
|
212
|
+
try:
|
|
213
|
+
return CheckpointRecord.from_dict(raw)
|
|
214
|
+
except (CheckpointError, TypeError, ValueError, KeyError):
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def checkpoint_for_request(
|
|
219
|
+
*,
|
|
220
|
+
source: str,
|
|
221
|
+
model: str,
|
|
222
|
+
language: str | None = None,
|
|
223
|
+
engine: str = "whisper",
|
|
224
|
+
device: str | None = None,
|
|
225
|
+
options: dict[str, Any] | None = None,
|
|
226
|
+
) -> CheckpointRecord:
|
|
227
|
+
"""Build a checkpoint keyed by the options that must match on resume."""
|
|
228
|
+
return CheckpointRecord(
|
|
229
|
+
source=source,
|
|
230
|
+
model=model,
|
|
231
|
+
language=language,
|
|
232
|
+
engine=engine,
|
|
233
|
+
device=device,
|
|
234
|
+
options=dict(options or {}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def matches(
|
|
239
|
+
checkpoint: CheckpointRecord,
|
|
240
|
+
*,
|
|
241
|
+
source: str,
|
|
242
|
+
model: str,
|
|
243
|
+
language: str | None = None,
|
|
244
|
+
engine: str = "whisper",
|
|
245
|
+
device: str | None = None,
|
|
246
|
+
options: dict[str, Any] | None = None,
|
|
247
|
+
) -> bool:
|
|
248
|
+
"""Whether a checkpoint can be safely reused for this request."""
|
|
249
|
+
if checkpoint.source != source:
|
|
250
|
+
return False
|
|
251
|
+
if checkpoint.model != model:
|
|
252
|
+
return False
|
|
253
|
+
if checkpoint.language != language:
|
|
254
|
+
return False
|
|
255
|
+
if checkpoint.engine != engine:
|
|
256
|
+
return False
|
|
257
|
+
if checkpoint.device != device:
|
|
258
|
+
return False
|
|
259
|
+
return options is None or checkpoint.options == dict(options)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def find_resumable_checkpoint(
|
|
263
|
+
store: JobStore,
|
|
264
|
+
*,
|
|
265
|
+
source: str,
|
|
266
|
+
model: str,
|
|
267
|
+
language: str | None = None,
|
|
268
|
+
engine: str = "whisper",
|
|
269
|
+
device: str | None = None,
|
|
270
|
+
options: dict[str, Any] | None = None,
|
|
271
|
+
limit: int = 10_000,
|
|
272
|
+
) -> tuple[Job, CheckpointRecord] | None:
|
|
273
|
+
"""Find the newest job with a matching, valid checkpoint.
|
|
274
|
+
|
|
275
|
+
Interrupted jobs are marked ERROR by startup reaping, so terminal jobs are
|
|
276
|
+
included deliberately. A DONE job may be reused too; that turns a repeated
|
|
277
|
+
invocation into a cheap no-op rather than recomputing identical work.
|
|
278
|
+
"""
|
|
279
|
+
for job in store.list(limit=limit):
|
|
280
|
+
if job.state not in RESUMABLE_STATES:
|
|
281
|
+
continue
|
|
282
|
+
record = load_checkpoint(job)
|
|
283
|
+
if record is None:
|
|
284
|
+
continue
|
|
285
|
+
if matches(
|
|
286
|
+
record,
|
|
287
|
+
source=source,
|
|
288
|
+
model=model,
|
|
289
|
+
language=language,
|
|
290
|
+
engine=engine,
|
|
291
|
+
device=device,
|
|
292
|
+
options=options,
|
|
293
|
+
):
|
|
294
|
+
return job, record
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def prepare_resume(
|
|
299
|
+
store: JobStore,
|
|
300
|
+
job: Job,
|
|
301
|
+
checkpoint: CheckpointRecord | dict[str, Any],
|
|
302
|
+
) -> tuple[Job, dict[str, Any]] | None:
|
|
303
|
+
"""Reopen a resumed job for another run, or return None if it is DONE.
|
|
304
|
+
|
|
305
|
+
A checkpoint from an ERROR/CANCELLED job is durable work, but the job row is
|
|
306
|
+
terminal, so `run_job` would refuse to start. Explicit resume is the one
|
|
307
|
+
place allowed to un-terminal that row: reset it to PENDING (clearing the
|
|
308
|
+
stale error/cancellation flag) while leaving the checkpoint byte-for-byte
|
|
309
|
+
intact. A DONE job is a different case: the transcript and outputs are the
|
|
310
|
+
real deliverable, so resuming it is a no-op and callers should render from
|
|
311
|
+
the stored transcript instead.
|
|
312
|
+
"""
|
|
313
|
+
current = store.get(job.id)
|
|
314
|
+
if current is None:
|
|
315
|
+
return None
|
|
316
|
+
if current.state is JobState.DONE:
|
|
317
|
+
return None
|
|
318
|
+
payload = checkpoint.to_dict() if isinstance(checkpoint, CheckpointRecord) else dict(checkpoint)
|
|
319
|
+
updated = store.update(
|
|
320
|
+
job.id,
|
|
321
|
+
state=JobState.PENDING,
|
|
322
|
+
progress="resuming",
|
|
323
|
+
error=None,
|
|
324
|
+
cancel_requested=False,
|
|
325
|
+
checkpoint=payload,
|
|
326
|
+
)
|
|
327
|
+
if updated is None:
|
|
328
|
+
return None
|
|
329
|
+
return updated, payload
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def write_checkpoint(
|
|
333
|
+
store: JobStore,
|
|
334
|
+
job_id: str,
|
|
335
|
+
checkpoint: CheckpointRecord | dict[str, Any],
|
|
336
|
+
) -> Job | None:
|
|
337
|
+
"""Persist a checkpoint through the existing store update path."""
|
|
338
|
+
payload = checkpoint.to_dict() if isinstance(checkpoint, CheckpointRecord) else dict(checkpoint)
|
|
339
|
+
return store.update(job_id, checkpoint=payload)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def transcript_for_job(job: Job | None) -> Transcript | None:
|
|
343
|
+
"""Return the stored transcript for a terminal job, or None if absent/corrupt."""
|
|
344
|
+
if job is None or job.transcript is None:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
return Transcript.from_dict(job.transcript)
|
|
348
|
+
except (KeyError, TypeError, ValueError):
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def reusable_done_result(
|
|
353
|
+
store: JobStore,
|
|
354
|
+
job: Job,
|
|
355
|
+
*,
|
|
356
|
+
transcript: Transcript | None = None,
|
|
357
|
+
formats: list[str] | None = None,
|
|
358
|
+
output_dir: str | Path | None = None,
|
|
359
|
+
stem: str | None = None,
|
|
360
|
+
) -> tuple[Transcript, list[Path]] | None:
|
|
361
|
+
"""Return stored transcript/outputs for a DONE job when it can be reused.
|
|
362
|
+
|
|
363
|
+
This is the no-op half of explicit resume: a job that already reached DONE
|
|
364
|
+
must not be reopened or re-run. Callers use this before falling back to the
|
|
365
|
+
normal pipeline, and render any missing requested formats from the stored
|
|
366
|
+
transcript without touching acquisition or the engine.
|
|
367
|
+
|
|
368
|
+
Returns None when the job is not DONE or its transcript is unreadable, which
|
|
369
|
+
makes the caller start clean rather than presenting corrupt output as a
|
|
370
|
+
successful resume.
|
|
371
|
+
"""
|
|
372
|
+
current = store.get(job.id)
|
|
373
|
+
if current is None or current.state is not JobState.DONE:
|
|
374
|
+
return None
|
|
375
|
+
parsed = transcript or transcript_for_job(current)
|
|
376
|
+
if parsed is None:
|
|
377
|
+
return None
|
|
378
|
+
if formats:
|
|
379
|
+
if not stem:
|
|
380
|
+
raise CheckpointError("stem is required when rendering reused outputs")
|
|
381
|
+
outputs = ensure_outputs(
|
|
382
|
+
parsed,
|
|
383
|
+
formats=formats,
|
|
384
|
+
output_dir=output_dir,
|
|
385
|
+
stem=stem,
|
|
386
|
+
existing=current.outputs,
|
|
387
|
+
)
|
|
388
|
+
return parsed, outputs
|
|
389
|
+
return parsed, [Path(path) for path in current.outputs]
|