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/__init__.py +30 -0
- utterverify/__main__.py +3 -0
- utterverify/_version.py +24 -0
- utterverify/bundle.py +646 -0
- utterverify/cli.py +406 -0
- utterverify/config.py +261 -0
- utterverify/core.py +419 -0
- utterverify/doctor.py +509 -0
- utterverify/errors.py +17 -0
- utterverify/py.typed +0 -0
- utterverify/types.py +102 -0
- utterverify-0.1.0.dist-info/METADATA +251 -0
- utterverify-0.1.0.dist-info/RECORD +17 -0
- utterverify-0.1.0.dist-info/WHEEL +5 -0
- utterverify-0.1.0.dist-info/entry_points.txt +2 -0
- utterverify-0.1.0.dist-info/licenses/LICENSE +201 -0
- utterverify-0.1.0.dist-info/top_level.txt +1 -0
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)
|