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/core.py ADDED
@@ -0,0 +1,419 @@
1
+ """Verification of one RenderPlan + VoiceRender directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ import tomlkit
13
+
14
+ from .config import CONFIG_FILENAME, VerifyConfig, ensure_config, load_config
15
+ from .errors import HandoffError
16
+ from .types import SegmentOutcome, SegmentStatus, UnitStatus, UnitVerifyResult
17
+
18
+ RENDER_MANIFEST_FILENAME = "render-manifest.toml"
19
+
20
+
21
+ def verify_renderplan(
22
+ plan: str | Path,
23
+ *,
24
+ render_dir: str | Path,
25
+ output_dir: str | Path,
26
+ config: VerifyConfig | None = None,
27
+ unit_id: str = "document-0001",
28
+ verifier: Any | None = None,
29
+ ) -> UnitVerifyResult:
30
+ """Verify every rendered speech occurrence in one complete RenderPlan handoff.
31
+
32
+ ``utterverify.toml`` is created in ``output_dir`` before RenderPlan loading or
33
+ SpeechScope backend initialization.
34
+ """
35
+ plan_path = Path(plan).expanduser().resolve(strict=True)
36
+ render_root = Path(render_dir).expanduser().resolve(strict=True)
37
+ requested_output = Path(output_dir).expanduser()
38
+ if requested_output.is_symlink():
39
+ raise HandoffError(
40
+ f"UtterVerify output directory must not be a symlink: {requested_output}"
41
+ )
42
+ out = Path(output_dir).expanduser().resolve(strict=False)
43
+ try:
44
+ out.relative_to(render_root)
45
+ except ValueError:
46
+ pass
47
+ else:
48
+ raise HandoffError("UtterVerify output directory must not be inside the VoiceRender input")
49
+ out.mkdir(parents=True, exist_ok=True)
50
+ config_path = out / CONFIG_FILENAME
51
+ if config_path.is_symlink():
52
+ raise HandoffError(f"UtterVerify config must not be a symlink: {config_path}")
53
+ if config is None:
54
+ selected, _ = ensure_config(config_path)
55
+ else:
56
+ if config_path.exists():
57
+ selected = load_config(config_path)
58
+ else:
59
+ selected, _ = ensure_config(config_path, config)
60
+
61
+ parsed_plan, manifest = _load_handoff(plan_path, render_root)
62
+ preflight = _preflight_loaded(parsed_plan, manifest, render_root)
63
+ owns_verifier = verifier is None
64
+ entered = False
65
+ if owns_verifier:
66
+ verifier = _open_verifier(selected)
67
+ try:
68
+ verifier.__enter__()
69
+ entered = True
70
+ except Exception as exc:
71
+ raise HandoffError(f"SpeechScope backend failed: {exc}") from exc
72
+ assert verifier is not None
73
+ try:
74
+ unit_result = _verify_loaded(
75
+ parsed_plan,
76
+ manifest,
77
+ plan_path=plan_path,
78
+ render_root=render_root,
79
+ output_dir=out,
80
+ config=selected,
81
+ unit_id=unit_id,
82
+ verifier=verifier,
83
+ preflight=preflight,
84
+ )
85
+ _write_unit_report(unit_result)
86
+ return unit_result
87
+ finally:
88
+ if owns_verifier and entered:
89
+ try:
90
+ verifier.__exit__(None, None, None)
91
+ except Exception as exc:
92
+ raise HandoffError(f"SpeechScope backend failed to close: {exc}") from exc
93
+
94
+
95
+ def _load_handoff(
96
+ plan_path: Path,
97
+ render_root: Path,
98
+ *,
99
+ plan_set: Any | None = None,
100
+ set_entry: Any | None = None,
101
+ ) -> tuple[Any, Any]:
102
+ try:
103
+ from renderplan import load_manifest, load_plan, validate_manifest_handoff
104
+ except ImportError as exc: # pragma: no cover - packaging/runtime guard
105
+ raise HandoffError("renderplan is required to validate VoiceRender handoffs") from exc
106
+
107
+ try:
108
+ if (plan_set is None) != (set_entry is None):
109
+ raise ValueError("RenderPlan set and chapter entry must be provided together")
110
+ parsed_plan = load_plan(plan_path)
111
+ if plan_set is not None and set_entry is not None:
112
+ from renderplan import validate_set_chapter_plan
113
+
114
+ validate_set_chapter_plan(plan_set, set_entry, parsed_plan)
115
+ manifest_path = render_root / RENDER_MANIFEST_FILENAME
116
+ if manifest_path.is_symlink() or not manifest_path.is_file():
117
+ raise HandoffError(f"complete VoiceRender manifest is missing: {manifest_path}")
118
+ manifest = load_manifest(manifest_path)
119
+ validate_manifest_handoff(parsed_plan, manifest, require_complete=True)
120
+ except Exception as exc:
121
+ raise HandoffError(f"invalid RenderPlan / VoiceRender handoff: {exc}") from exc
122
+ return parsed_plan, manifest
123
+
124
+
125
+ def _preflight_loaded(
126
+ plan: Any,
127
+ manifest: Any,
128
+ render_root: Path,
129
+ ) -> tuple[tuple[Any, str, Path, str], ...]:
130
+ """Validate all deterministic WAV paths and checksums before any ASR work."""
131
+ by_key = {
132
+ (artifact.segment_number, artifact.segment_id): artifact for artifact in manifest.speech
133
+ }
134
+ verified: list[tuple[Any, str, Path, str]] = []
135
+ for speech in plan.speech:
136
+ segment_id = speech.segment_id or speech.computed_id()
137
+ key = (speech.number, segment_id)
138
+ artifact = by_key.get(key)
139
+ if artifact is None:
140
+ raise HandoffError(
141
+ f"manifest is missing speech occurrence {speech.number}:{segment_id}"
142
+ )
143
+ wav, wav_sha256 = _verified_artifact_path(render_root, speech, artifact)
144
+ verified.append((speech, segment_id, wav, wav_sha256))
145
+ return tuple(verified)
146
+
147
+
148
+ def _verify_loaded(
149
+ plan: Any,
150
+ manifest: Any,
151
+ *,
152
+ plan_path: Path,
153
+ render_root: Path,
154
+ output_dir: Path,
155
+ config: VerifyConfig,
156
+ unit_id: str,
157
+ verifier: Any,
158
+ preflight: tuple[tuple[Any, str, Path, str], ...] | None = None,
159
+ ) -> UnitVerifyResult:
160
+ if output_dir.is_symlink():
161
+ raise HandoffError(f"UtterVerify output directory must not be a symlink: {output_dir}")
162
+ verified = (
163
+ preflight if preflight is not None else _preflight_loaded(plan, manifest, render_root)
164
+ )
165
+ outcomes: list[SegmentOutcome] = []
166
+ for speech, segment_id, wav, wav_sha256 in verified:
167
+ report_path = output_dir / f"{speech.number:06d}.speechscope.json"
168
+ if report_path.is_symlink():
169
+ raise HandoffError(f"SpeechScope report output must not be a symlink: {report_path}")
170
+ try:
171
+ request = _verification_request(speech, wav, config)
172
+ result = verifier.analyze(request)
173
+ status_value = str(result.evaluation.get("status", "not_evaluated"))
174
+ if status_value not in {"pass", "review", "fail", "not_evaluated"}:
175
+ status: SegmentStatus = "review"
176
+ else:
177
+ status = cast(SegmentStatus, status_value)
178
+ result.write_json(report_path)
179
+ outcomes.append(
180
+ SegmentOutcome(
181
+ number=speech.number,
182
+ segment_id=segment_id,
183
+ reference_text=speech.text,
184
+ wav=wav,
185
+ wav_sha256=wav_sha256,
186
+ report=report_path,
187
+ status=status,
188
+ language=speech.language,
189
+ )
190
+ )
191
+ if config.fail_fast and status in {"fail", "error"}:
192
+ break
193
+ except Exception as exc: # noqa: BLE001
194
+ outcomes.append(
195
+ SegmentOutcome(
196
+ number=speech.number,
197
+ segment_id=segment_id,
198
+ reference_text=speech.text,
199
+ wav=wav,
200
+ wav_sha256=wav_sha256,
201
+ report=None,
202
+ status="error",
203
+ language=speech.language,
204
+ message=str(exc),
205
+ )
206
+ )
207
+ if config.fail_fast:
208
+ break
209
+
210
+ if config.fail_fast and len(outcomes) < len(verified):
211
+ for speech, segment_id, wav, wav_sha256 in verified[len(outcomes) :]:
212
+ outcomes.append(
213
+ SegmentOutcome(
214
+ number=speech.number,
215
+ segment_id=segment_id,
216
+ reference_text=speech.text,
217
+ wav=wav,
218
+ wav_sha256=wav_sha256,
219
+ report=None,
220
+ status="not_evaluated",
221
+ language=speech.language,
222
+ message="not evaluated because fail-fast stopped after an earlier failure/error",
223
+ )
224
+ )
225
+
226
+ return UnitVerifyResult(
227
+ id=unit_id,
228
+ renderplan=plan_path,
229
+ render_plan_id=plan.plan_id or plan.computed_id(),
230
+ source_plan_id=plan.source_plan_id,
231
+ render_profile_id=plan.render_profile_id,
232
+ render_dir=render_root,
233
+ manifest_id=getattr(manifest, "manifest_id", None),
234
+ output_dir=output_dir,
235
+ segments=tuple(outcomes),
236
+ status=_unit_status(outcomes),
237
+ )
238
+
239
+
240
+ def _not_evaluated_loaded(
241
+ plan: Any,
242
+ manifest: Any,
243
+ *,
244
+ plan_path: Path,
245
+ render_root: Path,
246
+ output_dir: Path,
247
+ unit_id: str,
248
+ preflight: tuple[tuple[Any, str, Path, str], ...],
249
+ message: str,
250
+ ) -> UnitVerifyResult:
251
+ outcomes = tuple(
252
+ SegmentOutcome(
253
+ number=speech.number,
254
+ segment_id=segment_id,
255
+ reference_text=speech.text,
256
+ wav=wav,
257
+ wav_sha256=wav_sha256,
258
+ report=None,
259
+ status="not_evaluated",
260
+ language=speech.language,
261
+ message=message,
262
+ )
263
+ for speech, segment_id, wav, wav_sha256 in preflight
264
+ )
265
+ return UnitVerifyResult(
266
+ id=unit_id,
267
+ renderplan=plan_path,
268
+ render_plan_id=plan.plan_id or plan.computed_id(),
269
+ source_plan_id=plan.source_plan_id,
270
+ render_profile_id=plan.render_profile_id,
271
+ render_dir=render_root,
272
+ manifest_id=getattr(manifest, "manifest_id", None),
273
+ output_dir=output_dir,
274
+ segments=outcomes,
275
+ status="review",
276
+ )
277
+
278
+
279
+ def _verified_artifact_path(render_root: Path, speech: Any, artifact: Any) -> tuple[Path, str]:
280
+ expected_name = f"{speech.number:06d}.wav"
281
+ if artifact.wav != expected_name or getattr(speech, "wav", expected_name) != expected_name:
282
+ raise HandoffError(f"unexpected WAV name for speech {speech.number}")
283
+ candidate = render_root / artifact.wav
284
+ if candidate.is_symlink():
285
+ raise HandoffError(f"rendered WAV must not be a symlink: {candidate}")
286
+ try:
287
+ resolved = candidate.resolve(strict=True)
288
+ except OSError as exc:
289
+ raise HandoffError(f"rendered WAV is missing: {candidate}") from exc
290
+ try:
291
+ resolved.relative_to(render_root)
292
+ except ValueError as exc:
293
+ raise HandoffError(f"rendered WAV escapes render directory: {candidate}") from exc
294
+ if not resolved.is_file():
295
+ raise HandoffError(f"rendered WAV is not a regular file: {candidate}")
296
+ expected_hash = str(artifact.sha256).removeprefix("sha256:")
297
+ got_hash = hashlib.sha256(resolved.read_bytes()).hexdigest()
298
+ if got_hash != expected_hash:
299
+ raise HandoffError(f"rendered WAV checksum mismatch: {candidate.name}")
300
+ return resolved, f"sha256:{got_hash}"
301
+
302
+
303
+ def _open_verifier(config: VerifyConfig) -> Any:
304
+ try:
305
+ from speechscope import Verifier
306
+ except ImportError as exc: # pragma: no cover - packaging/runtime guard
307
+ raise HandoffError("speechscope is required for validation") from exc
308
+ return Verifier(
309
+ backend=config.backend,
310
+ device=config.device,
311
+ model=config.model,
312
+ )
313
+
314
+
315
+ def _verification_request(speech: Any, wav: Path, config: VerifyConfig) -> Any:
316
+ try:
317
+ from speechscope import ThresholdPolicy, VerificationRequest
318
+ except ImportError as exc: # pragma: no cover
319
+ raise HandoffError("speechscope is required for validation") from exc
320
+ thresholds = ThresholdPolicy(**config.threshold_kwargs())
321
+ return VerificationRequest(
322
+ wav,
323
+ reference_text=speech.text,
324
+ backend=config.backend,
325
+ language=speech.language if config.use_language_hint else None,
326
+ device=config.device,
327
+ model=config.model,
328
+ normalization=config.normalization,
329
+ thresholds=thresholds,
330
+ )
331
+
332
+
333
+ def _unit_status(outcomes: list[SegmentOutcome]) -> UnitStatus:
334
+ if any(item.status == "error" for item in outcomes):
335
+ return "error"
336
+ if any(item.status == "fail" for item in outcomes):
337
+ return "fail"
338
+ if any(item.status in {"review", "not_evaluated"} for item in outcomes):
339
+ return "review"
340
+ return "pass"
341
+
342
+
343
+ def segment_json_summary(outcome: SegmentOutcome) -> str:
344
+ return json.dumps(
345
+ {
346
+ "number": outcome.number,
347
+ "segment_id": outcome.segment_id,
348
+ "wav": str(outcome.wav),
349
+ "report": str(outcome.report) if outcome.report else None,
350
+ "status": outcome.status,
351
+ "message": outcome.message,
352
+ },
353
+ ensure_ascii=False,
354
+ )
355
+
356
+
357
+ def _write_unit_report(result: UnitVerifyResult) -> Path:
358
+ report_path = result.output_dir / "utterverify-report.toml"
359
+ if report_path.is_symlink():
360
+ raise HandoffError(f"UtterVerify report must not be a symlink: {report_path}")
361
+
362
+ doc = tomlkit.document()
363
+ doc.add("format", "utterverify.report")
364
+ doc.add("schema_version", 1)
365
+ doc.add("id", result.id)
366
+ doc.add("status", result.status)
367
+ doc.add("renderplan", str(result.renderplan))
368
+ doc.add("render_plan_id", result.render_plan_id)
369
+ doc.add("source_plan_id", result.source_plan_id)
370
+ doc.add("render_profile_id", result.render_profile_id)
371
+ doc.add("render_dir", str(result.render_dir))
372
+ if result.manifest_id is not None:
373
+ doc.add("manifest_id", result.manifest_id)
374
+ doc.add("output_dir", str(result.output_dir))
375
+ doc.add("segments", len(result.segments))
376
+ doc.add("passed", result.passed)
377
+ doc.add("reviewed", result.reviewed)
378
+ doc.add("failed", result.failed)
379
+ doc.add("errors", result.errors)
380
+
381
+ segment_tables = tomlkit.aot()
382
+ for segment in result.segments:
383
+ table = tomlkit.table()
384
+ table.add("number", segment.number)
385
+ table.add("segment_id", segment.segment_id)
386
+ table.add("reference_text", segment.reference_text)
387
+ table.add("wav", str(segment.wav))
388
+ table.add("wav_sha256", segment.wav_sha256)
389
+ if segment.report is not None:
390
+ table.add("report", str(segment.report))
391
+ table.add("status", segment.status)
392
+ if segment.language is not None:
393
+ table.add("language", segment.language)
394
+ if segment.message is not None:
395
+ table.add("message", segment.message)
396
+ segment_tables.append(table)
397
+ doc.add("segment", segment_tables)
398
+ _atomic_text(report_path, tomlkit.dumps(doc))
399
+ return report_path
400
+
401
+
402
+ def _atomic_text(path: Path, content: str) -> None:
403
+ temp_name: str | None = None
404
+ try:
405
+ with tempfile.NamedTemporaryFile(
406
+ mode="w",
407
+ encoding="utf-8",
408
+ newline="\n",
409
+ dir=path.parent,
410
+ prefix=f".{path.name}.",
411
+ suffix=".tmp",
412
+ delete=False,
413
+ ) as handle:
414
+ temp_name = handle.name
415
+ handle.write(content)
416
+ os.replace(temp_name, path)
417
+ finally:
418
+ if temp_name is not None and os.path.exists(temp_name):
419
+ os.unlink(temp_name)