utterverify 0.1.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.
utterverify/doctor.py ADDED
@@ -0,0 +1,509 @@
1
+ """Side-effect-free readiness checks for the selected validation stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import asdict, dataclass, replace
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as package_version
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from .config import CONFIG_FILENAME, UNSET, VerifyConfig, _UnsetType, default_config, load_config
15
+ from .errors import BundleError, ConfigError, UtterVerifyError
16
+
17
+ CheckStatus = Literal["pass", "warn", "fail"]
18
+ ModelSource = Literal["cli", "config", "environment", "none"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class DoctorCheck:
23
+ name: str
24
+ status: CheckStatus
25
+ detail: str
26
+ hint: str | None = None
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class DoctorResult:
31
+ ok: bool
32
+ backend: str
33
+ device: str
34
+ model: str | None
35
+ model_source: ModelSource
36
+ checks: tuple[DoctorCheck, ...]
37
+ input_error: bool = False
38
+
39
+ def to_dict(self) -> dict[str, object]:
40
+ """Return the stable doctor JSON envelope."""
41
+ return {
42
+ "format": "utterverify.doctor",
43
+ "schema_version": 1,
44
+ "ok": self.ok,
45
+ "backend": self.backend,
46
+ "device": self.device,
47
+ "model": self.model,
48
+ "model_source": self.model_source,
49
+ "checks": [asdict(check) for check in self.checks],
50
+ }
51
+
52
+
53
+ def run_doctor(
54
+ *,
55
+ config_path: str | Path | None = None,
56
+ config: VerifyConfig | None = None,
57
+ backend: str | None = None,
58
+ device: str | None = None,
59
+ model: str | _UnsetType = UNSET,
60
+ ) -> DoctorResult:
61
+ """Check importability and local readiness without creating files or running ASR."""
62
+ checks: list[DoctorCheck] = []
63
+ input_error = False
64
+ selected_config = config
65
+ if selected_config is None and config_path is not None:
66
+ try:
67
+ selected_config = load_config(config_path)
68
+ checks.append(DoctorCheck("config", "pass", f"loaded {Path(config_path)}"))
69
+ except ConfigError as exc:
70
+ selected_config = default_config()
71
+ input_error = True
72
+ checks.append(DoctorCheck("config", "fail", str(exc)))
73
+ elif selected_config is not None:
74
+ detail = (
75
+ f"loaded {Path(config_path)}" if config_path is not None else "configuration is valid"
76
+ )
77
+ checks.append(DoctorCheck("config", "pass", detail))
78
+ else:
79
+ selected_config = default_config()
80
+ checks.append(DoctorCheck("config", "pass", "using built-in defaults"))
81
+
82
+ assert selected_config is not None
83
+ effective_backend = backend if backend is not None else selected_config.backend
84
+ effective_device = device if device is not None else selected_config.device
85
+ if isinstance(model, str):
86
+ effective_model = model
87
+ model_source: ModelSource = "cli"
88
+ elif selected_config.model is not None:
89
+ effective_model = selected_config.model
90
+ model_source = "config"
91
+ elif effective_backend == "whispercpp" and os.environ.get("SPEECHSCOPE_WHISPERCPP_MODEL"):
92
+ effective_model = os.environ["SPEECHSCOPE_WHISPERCPP_MODEL"]
93
+ model_source = "environment"
94
+ else:
95
+ effective_model = None
96
+ model_source = "none"
97
+
98
+ _check_renderplan(checks)
99
+ speechscope = _import_speechscope(checks)
100
+ backend_info = None
101
+ if speechscope is None:
102
+ checks.append(
103
+ DoctorCheck(
104
+ "backend",
105
+ "fail",
106
+ "SpeechScope is unavailable; backend metadata cannot be checked",
107
+ "Install the utterverify package dependencies.",
108
+ )
109
+ )
110
+ checks.append(DoctorCheck("device", "fail", "device support cannot be checked"))
111
+ else:
112
+ try:
113
+ backend_info = speechscope.backend_info(effective_backend)
114
+ except Exception as exc: # noqa: BLE001
115
+ checks.append(
116
+ DoctorCheck(
117
+ "backend",
118
+ "fail",
119
+ f"unknown or unavailable SpeechScope backend {effective_backend!r}: {exc}",
120
+ )
121
+ )
122
+ checks.append(DoctorCheck("device", "fail", "device support cannot be checked"))
123
+ else:
124
+ checks.append(
125
+ DoctorCheck(
126
+ "backend",
127
+ "pass",
128
+ f"{backend_info.name}"
129
+ + (f" {backend_info.version}" if backend_info.version else ""),
130
+ )
131
+ )
132
+ supported = tuple(backend_info.supported_devices)
133
+ if effective_device in supported:
134
+ checks.append(DoctorCheck("device", "pass", effective_device))
135
+ else:
136
+ checks.append(
137
+ DoctorCheck(
138
+ "device",
139
+ "fail",
140
+ f"{effective_backend} does not support device {effective_device!r}; "
141
+ f"supported: {', '.join(supported)}",
142
+ )
143
+ )
144
+
145
+ if speechscope is not None and backend_info is not None:
146
+ if backend_info.name == "whispercpp":
147
+ _check_whispercpp(speechscope, effective_model, model_source, checks)
148
+ elif backend_info.name == "redux":
149
+ _check_redux(backend_info, checks)
150
+ else:
151
+ checks.append(
152
+ DoctorCheck(
153
+ "backend_readiness",
154
+ "warn",
155
+ f"deep readiness checks are specific to SpeechScope backend {backend_info.name!r}",
156
+ )
157
+ )
158
+
159
+ ok = not input_error and all(check.status != "fail" for check in checks)
160
+ return DoctorResult(
161
+ ok=ok,
162
+ backend=effective_backend,
163
+ device=effective_device,
164
+ model=effective_model,
165
+ model_source=model_source,
166
+ checks=tuple(checks),
167
+ input_error=input_error,
168
+ )
169
+
170
+
171
+ def doctor_bundle(
172
+ bundle: str | Path,
173
+ *,
174
+ variant: str | None = None,
175
+ backend: str | None = None,
176
+ device: str | None = None,
177
+ model: str | _UnsetType = UNSET,
178
+ ) -> DoctorResult:
179
+ """Run doctor with a validated bundle's config and selected input handoffs."""
180
+ from .bundle import _contained_file, _load_bundle, _resolve_units, _resolve_variant
181
+ from .core import _load_handoff, _preflight_loaded
182
+
183
+ identity = _load_bundle(bundle)
184
+ selected_variant = _resolve_variant(identity, variant)
185
+ config_path = identity.root / "utterverify" / selected_variant / CONFIG_FILENAME
186
+ bundle_checks: list[DoctorCheck] = [
187
+ DoctorCheck("bundle_manifest", "pass", "canonical bundle manifest is valid"),
188
+ DoctorCheck("bundle_variant", "pass", selected_variant),
189
+ ]
190
+
191
+ if config_path.exists() or config_path.is_symlink():
192
+ if config_path.is_symlink():
193
+ raise BundleError(f"bundle UtterVerify config must not be a symlink: {config_path}")
194
+ config_file = _contained_file(config_path, identity.root)
195
+ selected_config = load_config(config_file)
196
+ bundle_checks.append(DoctorCheck("bundle_config", "pass", str(config_file)))
197
+ result = run_doctor(
198
+ config_path=config_file,
199
+ config=selected_config,
200
+ backend=backend,
201
+ device=device,
202
+ model=model,
203
+ )
204
+ else:
205
+ selected_config = default_config()
206
+ bundle_checks.append(
207
+ DoctorCheck(
208
+ "bundle_config",
209
+ "warn",
210
+ "config is missing; doctor uses defaults and bundle verify would create it",
211
+ )
212
+ )
213
+ result = run_doctor(
214
+ config=selected_config,
215
+ backend=backend,
216
+ device=device,
217
+ model=model,
218
+ )
219
+
220
+ try:
221
+ units = _resolve_units(identity, selected_variant, "all")
222
+ for unit in units:
223
+ handoff_plan, handoff_manifest = _load_handoff(
224
+ unit.renderplan,
225
+ unit.render_dir,
226
+ plan_set=unit.plan_set,
227
+ set_entry=unit.set_entry,
228
+ )
229
+ _preflight_loaded(handoff_plan, handoff_manifest, unit.render_dir)
230
+ except UtterVerifyError:
231
+ raise
232
+ except Exception as exc:
233
+ raise BundleError(f"cannot preflight selected bundle inputs: {exc}") from exc
234
+ bundle_checks.append(
235
+ DoctorCheck("bundle_inputs", "pass", f"{len(units)} selected handoff(s) are complete")
236
+ )
237
+ checks = tuple(bundle_checks) + result.checks
238
+ return replace(
239
+ result,
240
+ ok=result.ok and all(check.status != "fail" for check in bundle_checks),
241
+ checks=checks,
242
+ )
243
+
244
+
245
+ def _check_renderplan(checks: list[DoctorCheck]) -> None:
246
+ """Check that installed RenderPlan exposes the handoff contracts we require."""
247
+ try:
248
+ module = importlib.import_module("renderplan")
249
+ except ImportError as exc:
250
+ checks.append(
251
+ DoctorCheck(
252
+ "renderplan",
253
+ "fail",
254
+ f"renderplan is not importable: {exc}",
255
+ "Install UtterVerify and its RenderPlan dependency.",
256
+ )
257
+ )
258
+ return
259
+
260
+ try:
261
+ version = package_version("renderplan")
262
+ except PackageNotFoundError:
263
+ version = getattr(module, "__version__", "unknown version")
264
+
265
+ try:
266
+ contracts = module.supported_contracts()
267
+ except Exception as exc: # noqa: BLE001
268
+ checks.append(
269
+ DoctorCheck(
270
+ "renderplan",
271
+ "fail",
272
+ f"cannot inspect RenderPlan public contracts: {exc}",
273
+ "Install renderplan>=0.2.1,<0.3.",
274
+ )
275
+ )
276
+ return
277
+
278
+ expected_apis = (
279
+ "load_manifest",
280
+ "load_plan",
281
+ "load_renderplan_set",
282
+ "validate_manifest_handoff",
283
+ "validate_renderplan_set",
284
+ "validate_set_chapter_plan",
285
+ )
286
+ missing_apis = [name for name in expected_apis if not callable(getattr(module, name, None))]
287
+ renderplan_contract = contracts.get("renderplan") if isinstance(contracts, Mapping) else None
288
+ if renderplan_contract != 2 or missing_apis:
289
+ problems = []
290
+ if renderplan_contract != 2:
291
+ problems.append(
292
+ f"RenderPlan contract v2 is unsupported (found {renderplan_contract!r})"
293
+ )
294
+ if missing_apis:
295
+ problems.append(f"missing public APIs: {', '.join(missing_apis)}")
296
+ checks.append(
297
+ DoctorCheck(
298
+ "renderplan",
299
+ "fail",
300
+ f"renderplan {version}: " + "; ".join(problems),
301
+ "Install renderplan>=0.2.1,<0.3.",
302
+ )
303
+ )
304
+ return
305
+
306
+ checks.append(
307
+ DoctorCheck(
308
+ "renderplan",
309
+ "pass",
310
+ f"renderplan {version} supports RenderPlan contract v2 and required public handoff APIs",
311
+ )
312
+ )
313
+
314
+
315
+ def _import_speechscope(checks: list[DoctorCheck]):
316
+ try:
317
+ module = importlib.import_module("speechscope")
318
+ except ImportError as exc:
319
+ checks.append(
320
+ DoctorCheck(
321
+ "speechscope",
322
+ "fail",
323
+ f"speechscope is not importable: {exc}",
324
+ "Install UtterVerify and its SpeechScope dependency.",
325
+ )
326
+ )
327
+ return None
328
+ version = getattr(module, "__version__", None)
329
+ if not isinstance(version, str):
330
+ try:
331
+ from importlib.metadata import version as package_version
332
+
333
+ version = package_version("speechscope")
334
+ except Exception: # noqa: BLE001
335
+ version = None
336
+ detail = f"speechscope {version}" if version else "speechscope is importable"
337
+ checks.append(DoctorCheck("speechscope", "pass", detail))
338
+ return module
339
+
340
+
341
+ def _check_redux(backend_info: object, checks: list[DoctorCheck]) -> None:
342
+ version = getattr(backend_info, "version", None)
343
+ if version is None:
344
+ checks.append(
345
+ DoctorCheck(
346
+ "redux_dependencies",
347
+ "fail",
348
+ "SpeechScope reports that the Redux dependency is unavailable",
349
+ "pip install 'utterverify[redux]'",
350
+ )
351
+ )
352
+ return
353
+ checks.append(DoctorCheck("redux_dependencies", "pass", f"dependency metadata: {version}"))
354
+ checks.append(
355
+ DoctorCheck(
356
+ "redux_runtime_readiness",
357
+ "warn",
358
+ "metadata preflight passed; full model/device readiness is not proven without runtime initialization",
359
+ )
360
+ )
361
+
362
+
363
+ def _check_whispercpp(
364
+ speechscope: object,
365
+ model: str | None,
366
+ model_source: ModelSource,
367
+ checks: list[DoctorCheck],
368
+ ) -> None:
369
+ try:
370
+ executable, version = _probe_whisper_executable()
371
+ except Exception as exc: # noqa: BLE001
372
+ checks.append(
373
+ DoctorCheck(
374
+ "whisper_executable",
375
+ "fail",
376
+ str(exc),
377
+ "Install whisper.cpp or set SPEECHSCOPE_WHISPERCPP_BIN.",
378
+ )
379
+ )
380
+ else:
381
+ detail = executable + (f" (whisper.cpp {version})" if version else "")
382
+ checks.append(DoctorCheck("whisper_executable", "pass", detail))
383
+
384
+ if model is None or not model.strip():
385
+ checks.append(
386
+ DoctorCheck(
387
+ "model_selected",
388
+ "fail",
389
+ "No whisper.cpp model is selected.",
390
+ "Configure a model with `utterverify bundle configure <bundle> --engine whispercpp --model whispercpp:<id>` or set SPEECHSCOPE_WHISPERCPP_MODEL.",
391
+ )
392
+ )
393
+ return
394
+ checks.append(DoctorCheck("model_selected", "pass", model))
395
+ if model_source == "environment":
396
+ checks.append(
397
+ DoctorCheck(
398
+ "model_pinned",
399
+ "warn",
400
+ "using SPEECHSCOPE_WHISPERCPP_MODEL; bundle config does not pin a target model",
401
+ )
402
+ )
403
+
404
+ if model.startswith("whispercpp:"):
405
+ _check_managed_model(speechscope, model, checks)
406
+ else:
407
+ _check_local_model(model, checks)
408
+
409
+
410
+ def _probe_whisper_executable() -> tuple[str, str | None]:
411
+ # Adapter-internal SpeechScope helper; isolated here in case a public API appears.
412
+ from speechscope.backends.whispercpp import probe_executable
413
+
414
+ return probe_executable()
415
+
416
+
417
+ def _check_managed_model(speechscope: object, ref: str, checks: list[DoctorCheck]) -> None:
418
+ manager_type = getattr(speechscope, "ModelManager", None)
419
+ if manager_type is None:
420
+ checks.append(
421
+ DoctorCheck("model_catalogued", "fail", "SpeechScope ModelManager is unavailable")
422
+ )
423
+ checks.extend(
424
+ DoctorCheck(name, "warn", "not checked")
425
+ for name in ("model_managed", "model_installed", "model_integrity", "model_readable")
426
+ )
427
+ return
428
+ try:
429
+ manager = manager_type(packaged_catalog=True)
430
+ metadata = manager.show(ref, backend="whispercpp")
431
+ except Exception as exc: # noqa: BLE001
432
+ checks.append(DoctorCheck("model_catalogued", "fail", str(exc)))
433
+ checks.extend(
434
+ DoctorCheck(name, "warn", "not checked because model is not catalogued")
435
+ for name in ("model_managed", "model_installed", "model_integrity", "model_readable")
436
+ )
437
+ return
438
+ checks.append(DoctorCheck("model_catalogued", "pass", str(metadata.get("ref", ref))))
439
+
440
+ acquisition = metadata.get("acquisition")
441
+ managed = isinstance(acquisition, dict) and acquisition.get("mode") == "managed"
442
+ if not managed:
443
+ checks.append(DoctorCheck("model_managed", "fail", f"{ref} is not SpeechScope-managed"))
444
+ checks.extend(
445
+ DoctorCheck(name, "warn", "not checked for an unmanaged catalog model")
446
+ for name in ("model_installed", "model_integrity", "model_readable")
447
+ )
448
+ return
449
+ checks.append(DoctorCheck("model_managed", "pass", "managed by SpeechScope"))
450
+
451
+ try:
452
+ path = Path(manager.path(ref, backend="whispercpp"))
453
+ except Exception as exc: # noqa: BLE001
454
+ error_name = type(exc).__name__
455
+ if error_name == "ModelNotInstalledError":
456
+ checks.append(
457
+ DoctorCheck(
458
+ "model_installed", "fail", str(exc), f"speechscope models install {ref}"
459
+ )
460
+ )
461
+ checks.extend(
462
+ DoctorCheck(name, "warn", "not checked because model is not installed")
463
+ for name in ("model_integrity", "model_readable")
464
+ )
465
+ elif error_name == "ModelIntegrityError":
466
+ checks.append(DoctorCheck("model_installed", "pass", "managed install found"))
467
+ checks.append(DoctorCheck("model_integrity", "fail", str(exc)))
468
+ checks.append(DoctorCheck("model_readable", "fail", "integrity verification failed"))
469
+ else:
470
+ checks.append(
471
+ DoctorCheck(
472
+ "model_installed", "fail", str(exc), f"speechscope models install {ref}"
473
+ )
474
+ )
475
+ checks.extend(
476
+ DoctorCheck(name, "warn", "not checked because managed model resolution failed")
477
+ for name in ("model_integrity", "model_readable")
478
+ )
479
+ return
480
+
481
+ checks.append(DoctorCheck("model_installed", "pass", str(path)))
482
+ checks.append(
483
+ DoctorCheck("model_integrity", "pass", "verified by SpeechScope ModelManager.path()")
484
+ )
485
+ if path.is_file() and os.access(path, os.R_OK):
486
+ checks.append(DoctorCheck("model_readable", "pass", str(path)))
487
+ else:
488
+ checks.append(
489
+ DoctorCheck("model_readable", "fail", f"model is not a readable regular file: {path}")
490
+ )
491
+
492
+
493
+ def _check_local_model(value: str, checks: list[DoctorCheck]) -> None:
494
+ path = Path(value).expanduser()
495
+ try:
496
+ if not path.is_file():
497
+ raise OSError("path is not an existing regular file")
498
+ with path.open("rb") as model_file:
499
+ model_file.read(1)
500
+ except OSError as exc:
501
+ checks.append(
502
+ DoctorCheck(
503
+ "model_readable",
504
+ "fail",
505
+ f"whisper.cpp model is not a readable local file: {path} ({exc})",
506
+ )
507
+ )
508
+ else:
509
+ checks.append(DoctorCheck("model_readable", "pass", str(path.resolve())))
utterverify/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Public errors for UtterVerify."""
2
+
3
+
4
+ class UtterVerifyError(Exception):
5
+ """Base error."""
6
+
7
+
8
+ class ConfigError(UtterVerifyError, ValueError):
9
+ """Configuration is invalid."""
10
+
11
+
12
+ class BundleError(UtterVerifyError, ValueError):
13
+ """Bundle input or layout is invalid."""
14
+
15
+
16
+ class HandoffError(UtterVerifyError, ValueError):
17
+ """RenderPlan / VoiceRender handoff is incomplete or inconsistent."""
utterverify/py.typed ADDED
File without changes
utterverify/types.py ADDED
@@ -0,0 +1,102 @@
1
+ """Result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ SegmentStatus = Literal["pass", "review", "fail", "not_evaluated", "error"]
10
+ UnitStatus = Literal["pass", "review", "fail", "error"]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SegmentOutcome:
15
+ number: int
16
+ segment_id: str
17
+ reference_text: str
18
+ wav: Path
19
+ wav_sha256: str
20
+ report: Path | None
21
+ status: SegmentStatus
22
+ language: str | None = None
23
+ message: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class UnitVerifyResult:
28
+ id: str
29
+ renderplan: Path
30
+ render_plan_id: str
31
+ source_plan_id: str
32
+ render_profile_id: str
33
+ render_dir: Path
34
+ manifest_id: str | None
35
+ output_dir: Path
36
+ segments: tuple[SegmentOutcome, ...]
37
+ status: UnitStatus
38
+
39
+ @property
40
+ def passed(self) -> int:
41
+ return sum(item.status == "pass" for item in self.segments)
42
+
43
+ @property
44
+ def reviewed(self) -> int:
45
+ return sum(item.status in {"review", "not_evaluated"} for item in self.segments)
46
+
47
+ @property
48
+ def failed(self) -> int:
49
+ return sum(item.status == "fail" for item in self.segments)
50
+
51
+ @property
52
+ def errors(self) -> int:
53
+ return sum(item.status == "error" for item in self.segments)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class BundleVerifyResult:
58
+ bundle: Path
59
+ kind: Literal["book", "document"]
60
+ variant: str
61
+ output_dir: Path
62
+ config_path: Path
63
+ report_path: Path
64
+ config_created: bool
65
+ units: tuple[UnitVerifyResult, ...]
66
+ status: UnitStatus
67
+ fail_on_review: bool = False
68
+
69
+ speechscope_backend: str = "redux"
70
+ speechscope_device: str = "cpu"
71
+ speechscope_model: str | None = None
72
+ normalization: str = "basic-v1"
73
+ use_language_hint: bool = False
74
+ config_sha256: str | None = None
75
+
76
+ @property
77
+ def segments(self) -> int:
78
+ return sum(len(unit.segments) for unit in self.units)
79
+
80
+ @property
81
+ def passed(self) -> int:
82
+ return sum(unit.passed for unit in self.units)
83
+
84
+ @property
85
+ def reviewed(self) -> int:
86
+ return sum(unit.reviewed for unit in self.units)
87
+
88
+ @property
89
+ def failed(self) -> int:
90
+ return sum(unit.failed for unit in self.units)
91
+
92
+ @property
93
+ def errors(self) -> int:
94
+ return sum(unit.errors for unit in self.units)
95
+
96
+ @property
97
+ def exit_code(self) -> int:
98
+ if self.status == "error":
99
+ return 2
100
+ if self.status == "fail" or (self.status == "review" and self.fail_on_review):
101
+ return 1
102
+ return 0