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/cli.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .bundle import configure_bundle, init_bundle_config, verify_bundle
|
|
13
|
+
from .config import (
|
|
14
|
+
CONFIG_FILENAME,
|
|
15
|
+
UNSET,
|
|
16
|
+
VerifyConfig,
|
|
17
|
+
_UnsetType,
|
|
18
|
+
default_config,
|
|
19
|
+
load_config,
|
|
20
|
+
update_config,
|
|
21
|
+
)
|
|
22
|
+
from .core import verify_renderplan
|
|
23
|
+
from .doctor import DoctorResult, doctor_bundle, run_doctor
|
|
24
|
+
from .errors import BundleError, ConfigError, UtterVerifyError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="utterverify",
|
|
30
|
+
description="Validate VoiceRender speech WAVs against RenderPlan text with SpeechScope.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"utterverify {__version__}")
|
|
33
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
direct_config = commands.add_parser(
|
|
36
|
+
"configure", help="Create or update a direct verification config"
|
|
37
|
+
)
|
|
38
|
+
direct_config.add_argument("directory", type=Path)
|
|
39
|
+
direct_config.add_argument("--backend", "--engine", dest="backend")
|
|
40
|
+
direct_config.add_argument("--device")
|
|
41
|
+
direct_config.add_argument("--model")
|
|
42
|
+
direct_config.add_argument("--unset-model", action="store_true")
|
|
43
|
+
direct_config.add_argument("--normalization")
|
|
44
|
+
direct_config.add_argument(
|
|
45
|
+
"--use-language-hint", action=argparse.BooleanOptionalAction, default=None
|
|
46
|
+
)
|
|
47
|
+
direct_config.add_argument(
|
|
48
|
+
"--fail-on-review", action=argparse.BooleanOptionalAction, default=None
|
|
49
|
+
)
|
|
50
|
+
direct_config.add_argument("--fail-fast", action=argparse.BooleanOptionalAction, default=None)
|
|
51
|
+
|
|
52
|
+
direct = commands.add_parser("verify", help="Verify one RenderPlan handoff")
|
|
53
|
+
direct.add_argument("plan", type=Path)
|
|
54
|
+
direct.add_argument("--render-dir", type=Path, required=True)
|
|
55
|
+
direct.add_argument("--output-dir", type=Path, required=True)
|
|
56
|
+
direct.add_argument("--json", action="store_true")
|
|
57
|
+
|
|
58
|
+
bundle = commands.add_parser("bundle", help="Canonical .ssmd-bundle workflows")
|
|
59
|
+
bundle_commands = bundle.add_subparsers(dest="bundle_command", required=True)
|
|
60
|
+
|
|
61
|
+
init = bundle_commands.add_parser("init", help="Create utterverify/<variant>/utterverify.toml")
|
|
62
|
+
init.add_argument("bundle", type=Path)
|
|
63
|
+
init.add_argument("--variant")
|
|
64
|
+
init.add_argument("--backend", "--engine", dest="backend", default="redux")
|
|
65
|
+
init.add_argument("--device", default="cpu")
|
|
66
|
+
init.add_argument("--model")
|
|
67
|
+
init.add_argument("--normalization", default="basic-v1")
|
|
68
|
+
init.add_argument("--use-language-hint", action="store_true")
|
|
69
|
+
init.add_argument("--fail-on-review", action="store_true")
|
|
70
|
+
init.add_argument("--fail-fast", action="store_true")
|
|
71
|
+
init.add_argument("--force", action="store_true")
|
|
72
|
+
|
|
73
|
+
configure = bundle_commands.add_parser("configure", help="Update a bundle validation config")
|
|
74
|
+
configure.add_argument("bundle", type=Path)
|
|
75
|
+
configure.add_argument("--variant")
|
|
76
|
+
configure.add_argument("--backend", "--engine", dest="backend")
|
|
77
|
+
configure.add_argument("--device")
|
|
78
|
+
configure.add_argument("--model")
|
|
79
|
+
configure.add_argument("--unset-model", action="store_true")
|
|
80
|
+
configure.add_argument("--normalization")
|
|
81
|
+
configure.add_argument(
|
|
82
|
+
"--use-language-hint", action=argparse.BooleanOptionalAction, default=None
|
|
83
|
+
)
|
|
84
|
+
configure.add_argument("--fail-on-review", action=argparse.BooleanOptionalAction, default=None)
|
|
85
|
+
configure.add_argument("--fail-fast", action=argparse.BooleanOptionalAction, default=None)
|
|
86
|
+
|
|
87
|
+
verify = bundle_commands.add_parser("verify", help="Verify a rendered bundle variant")
|
|
88
|
+
verify.add_argument("bundle", type=Path)
|
|
89
|
+
verify.add_argument("--variant")
|
|
90
|
+
verify.add_argument("--chapters", default="all")
|
|
91
|
+
verify.add_argument("--json", action="store_true")
|
|
92
|
+
|
|
93
|
+
doctor = commands.add_parser("doctor", help="Check local validation-stack readiness")
|
|
94
|
+
doctor.add_argument("bundle", type=Path, nargs="?")
|
|
95
|
+
doctor.add_argument("--variant")
|
|
96
|
+
doctor.add_argument("--config", type=Path)
|
|
97
|
+
doctor.add_argument("--backend", "--engine", dest="backend")
|
|
98
|
+
doctor.add_argument("--device")
|
|
99
|
+
doctor.add_argument("--model")
|
|
100
|
+
doctor.add_argument("--json", action="store_true")
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _configure_direct(args: argparse.Namespace) -> Path:
|
|
105
|
+
if args.model is not None and args.unset_model:
|
|
106
|
+
raise ConfigError("--model and --unset-model cannot be used together")
|
|
107
|
+
directory = Path(args.directory).expanduser()
|
|
108
|
+
if directory.is_symlink():
|
|
109
|
+
raise ConfigError(f"verification config directory must not be a symlink: {directory}")
|
|
110
|
+
try:
|
|
111
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
except OSError as exc:
|
|
113
|
+
raise ConfigError(f"cannot create verification config directory: {directory}") from exc
|
|
114
|
+
if not directory.is_dir():
|
|
115
|
+
raise ConfigError(f"verification config path is not a directory: {directory}")
|
|
116
|
+
|
|
117
|
+
config_path = directory / CONFIG_FILENAME
|
|
118
|
+
if config_path.is_symlink():
|
|
119
|
+
raise ConfigError(f"verification config must not be a symlink: {config_path}")
|
|
120
|
+
existing = load_config(config_path) if config_path.exists() else default_config()
|
|
121
|
+
if (
|
|
122
|
+
args.backend is not None
|
|
123
|
+
and args.backend != existing.backend
|
|
124
|
+
and existing.model is not None
|
|
125
|
+
and args.model is None
|
|
126
|
+
and not args.unset_model
|
|
127
|
+
):
|
|
128
|
+
raise ConfigError(
|
|
129
|
+
"changing the backend while a model is configured requires --model or --unset-model"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
effective_backend = args.backend or existing.backend
|
|
133
|
+
model: str | None | _UnsetType = UNSET
|
|
134
|
+
if args.unset_model:
|
|
135
|
+
model = None
|
|
136
|
+
elif args.model is not None:
|
|
137
|
+
candidate = Path(args.model).expanduser()
|
|
138
|
+
if candidate.exists():
|
|
139
|
+
if not candidate.is_file():
|
|
140
|
+
raise ConfigError(f"whisper.cpp model path is not a regular file: {candidate}")
|
|
141
|
+
model = str(candidate.resolve())
|
|
142
|
+
elif effective_backend == "whispercpp" and not args.model.startswith("whispercpp:"):
|
|
143
|
+
raise ConfigError(
|
|
144
|
+
"whisper.cpp model must be an existing local file or a canonical "
|
|
145
|
+
"SpeechScope model reference such as whispercpp:tiny.en-q5_1"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
model = args.model
|
|
149
|
+
|
|
150
|
+
update_config(
|
|
151
|
+
config_path,
|
|
152
|
+
backend=args.backend,
|
|
153
|
+
device=args.device,
|
|
154
|
+
model=model,
|
|
155
|
+
normalization=args.normalization,
|
|
156
|
+
use_language_hint=args.use_language_hint,
|
|
157
|
+
fail_on_review=args.fail_on_review,
|
|
158
|
+
fail_fast=args.fail_fast,
|
|
159
|
+
)
|
|
160
|
+
return config_path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def main(argv: list[str] | None = None) -> int:
|
|
164
|
+
args = _parser().parse_args(argv)
|
|
165
|
+
try:
|
|
166
|
+
if args.command == "configure":
|
|
167
|
+
path = _configure_direct(args)
|
|
168
|
+
print(path)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
if args.command == "verify":
|
|
172
|
+
unit_result = verify_renderplan(
|
|
173
|
+
args.plan,
|
|
174
|
+
render_dir=args.render_dir,
|
|
175
|
+
output_dir=args.output_dir,
|
|
176
|
+
)
|
|
177
|
+
payload: dict[str, object] = {
|
|
178
|
+
"id": unit_result.id,
|
|
179
|
+
"status": unit_result.status,
|
|
180
|
+
"render_plan_id": unit_result.render_plan_id,
|
|
181
|
+
"source_plan_id": unit_result.source_plan_id,
|
|
182
|
+
"render_profile_id": unit_result.render_profile_id,
|
|
183
|
+
"manifest_id": unit_result.manifest_id,
|
|
184
|
+
"segments": [_segment_payload(segment) for segment in unit_result.segments],
|
|
185
|
+
"segment_count": len(unit_result.segments),
|
|
186
|
+
"passed": unit_result.passed,
|
|
187
|
+
"reviewed": unit_result.reviewed,
|
|
188
|
+
"failed": unit_result.failed,
|
|
189
|
+
"errors": unit_result.errors,
|
|
190
|
+
"output": str(unit_result.output_dir),
|
|
191
|
+
"report": str(unit_result.output_dir / "utterverify-report.toml"),
|
|
192
|
+
}
|
|
193
|
+
if args.json:
|
|
194
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
195
|
+
else:
|
|
196
|
+
_print_unit(payload, unit_result.segments)
|
|
197
|
+
config = load_config(args.output_dir / CONFIG_FILENAME)
|
|
198
|
+
if unit_result.status == "error":
|
|
199
|
+
return 2
|
|
200
|
+
if unit_result.status == "fail" or (
|
|
201
|
+
unit_result.status == "review" and config.fail_on_review
|
|
202
|
+
):
|
|
203
|
+
return 1
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
if args.command == "doctor":
|
|
207
|
+
if args.bundle is not None and args.config is not None:
|
|
208
|
+
raise BundleError("doctor BUNDLE and --config are mutually exclusive")
|
|
209
|
+
if args.variant is not None and args.bundle is None:
|
|
210
|
+
raise BundleError("--variant is valid only when doctor is given a bundle")
|
|
211
|
+
if args.bundle is not None:
|
|
212
|
+
doctor_result = doctor_bundle(
|
|
213
|
+
args.bundle,
|
|
214
|
+
variant=args.variant,
|
|
215
|
+
backend=args.backend,
|
|
216
|
+
device=args.device,
|
|
217
|
+
model=args.model if args.model is not None else UNSET,
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
doctor_result = run_doctor(
|
|
221
|
+
config_path=args.config,
|
|
222
|
+
backend=args.backend,
|
|
223
|
+
device=args.device,
|
|
224
|
+
model=args.model if args.model is not None else UNSET,
|
|
225
|
+
)
|
|
226
|
+
_print_doctor(doctor_result, json_output=args.json)
|
|
227
|
+
if doctor_result.input_error:
|
|
228
|
+
return 2
|
|
229
|
+
return 0 if doctor_result.ok else 3
|
|
230
|
+
|
|
231
|
+
if args.bundle_command == "init":
|
|
232
|
+
config = VerifyConfig(
|
|
233
|
+
backend=args.backend,
|
|
234
|
+
device=args.device,
|
|
235
|
+
model=args.model,
|
|
236
|
+
normalization=args.normalization,
|
|
237
|
+
use_language_hint=args.use_language_hint,
|
|
238
|
+
fail_on_review=args.fail_on_review,
|
|
239
|
+
fail_fast=args.fail_fast,
|
|
240
|
+
)
|
|
241
|
+
path = init_bundle_config(
|
|
242
|
+
args.bundle,
|
|
243
|
+
variant=args.variant,
|
|
244
|
+
config=config,
|
|
245
|
+
overwrite=args.force,
|
|
246
|
+
)
|
|
247
|
+
print(path)
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
if args.bundle_command == "configure":
|
|
251
|
+
path = configure_bundle(
|
|
252
|
+
args.bundle,
|
|
253
|
+
variant=args.variant,
|
|
254
|
+
backend=args.backend,
|
|
255
|
+
device=args.device,
|
|
256
|
+
model=args.model if args.model is not None else UNSET,
|
|
257
|
+
unset_model=args.unset_model,
|
|
258
|
+
normalization=args.normalization,
|
|
259
|
+
use_language_hint=args.use_language_hint,
|
|
260
|
+
fail_on_review=args.fail_on_review,
|
|
261
|
+
fail_fast=args.fail_fast,
|
|
262
|
+
)
|
|
263
|
+
print(path)
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
result = verify_bundle(
|
|
267
|
+
args.bundle,
|
|
268
|
+
variant=args.variant,
|
|
269
|
+
chapters=args.chapters,
|
|
270
|
+
on_start=_print_bundle_start,
|
|
271
|
+
on_progress=_print_bundle_progress,
|
|
272
|
+
)
|
|
273
|
+
unit_payloads: list[dict[str, object]] = []
|
|
274
|
+
segment_payloads: list[dict[str, object]] = []
|
|
275
|
+
for unit in result.units:
|
|
276
|
+
unit_segments = [_segment_payload(segment) for segment in unit.segments]
|
|
277
|
+
unit_payloads.append(
|
|
278
|
+
{
|
|
279
|
+
"id": unit.id,
|
|
280
|
+
"status": unit.status,
|
|
281
|
+
"renderplan": str(unit.renderplan),
|
|
282
|
+
"render_plan_id": unit.render_plan_id,
|
|
283
|
+
"source_plan_id": unit.source_plan_id,
|
|
284
|
+
"render_profile_id": unit.render_profile_id,
|
|
285
|
+
"manifest_id": unit.manifest_id,
|
|
286
|
+
"segments": unit_segments,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
segment_payloads.extend({"unit_id": unit.id, **segment} for segment in unit_segments)
|
|
290
|
+
payload = {
|
|
291
|
+
"bundle": str(result.bundle),
|
|
292
|
+
"kind": result.kind,
|
|
293
|
+
"variant": result.variant,
|
|
294
|
+
"status": result.status,
|
|
295
|
+
"segments": segment_payloads,
|
|
296
|
+
"segment_count": result.segments,
|
|
297
|
+
"passed": result.passed,
|
|
298
|
+
"reviewed": result.reviewed,
|
|
299
|
+
"failed": result.failed,
|
|
300
|
+
"errors": result.errors,
|
|
301
|
+
"units": unit_payloads,
|
|
302
|
+
"config": str(result.config_path),
|
|
303
|
+
"config_created": result.config_created,
|
|
304
|
+
"report": str(result.report_path),
|
|
305
|
+
"exit_code": result.exit_code,
|
|
306
|
+
}
|
|
307
|
+
if args.json:
|
|
308
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
309
|
+
else:
|
|
310
|
+
print("UtterVerify bundle summary", file=sys.stderr)
|
|
311
|
+
print(f"status: {result.status}", file=sys.stderr)
|
|
312
|
+
print(f"segments: {result.segments}", file=sys.stderr)
|
|
313
|
+
print(f"pass: {result.passed}", file=sys.stderr)
|
|
314
|
+
print(f"review: {result.reviewed}", file=sys.stderr)
|
|
315
|
+
print(f"fail: {result.failed}", file=sys.stderr)
|
|
316
|
+
print(f"errors: {result.errors}", file=sys.stderr)
|
|
317
|
+
print(f"config: {result.config_path}", file=sys.stderr)
|
|
318
|
+
print(f"report: {result.report_path}", file=sys.stderr)
|
|
319
|
+
for unit in result.units:
|
|
320
|
+
for segment in unit.segments:
|
|
321
|
+
_print_segment(segment, unit_id=unit.id, file=sys.stderr)
|
|
322
|
+
return result.exit_code
|
|
323
|
+
except UtterVerifyError as exc:
|
|
324
|
+
print(f"utterverify: {exc}", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _print_doctor(result: DoctorResult, *, json_output: bool) -> None:
|
|
329
|
+
if json_output:
|
|
330
|
+
print(json.dumps(result.to_dict(), ensure_ascii=False))
|
|
331
|
+
return
|
|
332
|
+
print("UtterVerify doctor")
|
|
333
|
+
print(f"backend: {result.backend}")
|
|
334
|
+
print(f"device: {result.device}")
|
|
335
|
+
model = result.model if result.model else "<none>"
|
|
336
|
+
source = f" ({result.model_source})" if result.model_source != "none" else ""
|
|
337
|
+
print(f"model: {model}{source}")
|
|
338
|
+
print()
|
|
339
|
+
for check in result.checks:
|
|
340
|
+
print(f"{check.status.upper():4} {check.name.replace('_', '-'):24} {check.detail}")
|
|
341
|
+
if check.hint:
|
|
342
|
+
print(f" hint: {check.hint}")
|
|
343
|
+
print("\nready" if result.ok else "\nnot ready")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _print_bundle_start(start) -> None:
|
|
347
|
+
print("UtterVerify bundle verify", file=sys.stderr)
|
|
348
|
+
print(f" workspace: {start.workspace}", file=sys.stderr)
|
|
349
|
+
print(f" kind: {start.kind}", file=sys.stderr)
|
|
350
|
+
print(f" variant: {start.variant}", file=sys.stderr)
|
|
351
|
+
print(f" chapters: {start.selector}", file=sys.stderr)
|
|
352
|
+
print(f" backend: {start.backend}", file=sys.stderr)
|
|
353
|
+
print(f" model: {start.model or '<none>'}", file=sys.stderr)
|
|
354
|
+
print(file=sys.stderr)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _print_bundle_progress(progress) -> None:
|
|
358
|
+
print(
|
|
359
|
+
f"[{progress.index}/{progress.total}] {progress.id}: {progress.status}",
|
|
360
|
+
file=sys.stderr,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _segment_payload(segment) -> dict[str, object]:
|
|
365
|
+
return {
|
|
366
|
+
"number": segment.number,
|
|
367
|
+
"segment_id": segment.segment_id,
|
|
368
|
+
"reference_text": segment.reference_text,
|
|
369
|
+
"wav": str(segment.wav),
|
|
370
|
+
"wav_sha256": segment.wav_sha256,
|
|
371
|
+
"report": str(segment.report) if segment.report is not None else None,
|
|
372
|
+
"status": segment.status,
|
|
373
|
+
"language": segment.language,
|
|
374
|
+
"message": segment.message,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _print_segment(segment, *, unit_id: str | None = None, file=None) -> None:
|
|
379
|
+
prefix = f"{unit_id} " if unit_id is not None else ""
|
|
380
|
+
message = f" — {segment.message}" if segment.message else ""
|
|
381
|
+
print(
|
|
382
|
+
f"{segment.status.upper():12} {prefix}{segment.number:06d} "
|
|
383
|
+
f"{segment.segment_id} {segment.wav_sha256}{message}",
|
|
384
|
+
file=sys.stdout if file is None else file,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _print_unit(payload: Mapping[str, object], segments) -> None:
|
|
389
|
+
print("UtterVerify RenderPlan validation")
|
|
390
|
+
for name in (
|
|
391
|
+
"status",
|
|
392
|
+
"segment_count",
|
|
393
|
+
"passed",
|
|
394
|
+
"reviewed",
|
|
395
|
+
"failed",
|
|
396
|
+
"errors",
|
|
397
|
+
"output",
|
|
398
|
+
"report",
|
|
399
|
+
):
|
|
400
|
+
print(f"{name + ':':10} {payload[name]}")
|
|
401
|
+
for segment in segments:
|
|
402
|
+
_print_segment(segment)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
raise SystemExit(main())
|
utterverify/config.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Persisted validation configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import tomlkit
|
|
13
|
+
|
|
14
|
+
from .errors import ConfigError
|
|
15
|
+
|
|
16
|
+
CONFIG_FILENAME = "utterverify.toml"
|
|
17
|
+
CONFIG_FORMAT = "utterverify.config"
|
|
18
|
+
CONFIG_SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _UnsetType:
|
|
22
|
+
"""Sentinel distinguishing an omitted setting from an explicit unset."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
UNSET = _UnsetType()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class VerifyConfig:
|
|
30
|
+
backend: str = "redux"
|
|
31
|
+
device: str = "cpu"
|
|
32
|
+
model: str | None = None
|
|
33
|
+
normalization: str = "basic-v1"
|
|
34
|
+
use_language_hint: bool = False
|
|
35
|
+
max_wer: float | None = None
|
|
36
|
+
max_compound_wer: float | None = 0.0
|
|
37
|
+
max_cer: float | None = 0.0
|
|
38
|
+
max_mer: float | None = None
|
|
39
|
+
min_reference_timestamp_coverage: float | None = None
|
|
40
|
+
max_invalid_timestamp_count: int | None = None
|
|
41
|
+
missing_timing: str = "review"
|
|
42
|
+
fail_on_review: bool = False
|
|
43
|
+
fail_fast: bool = False
|
|
44
|
+
extra: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
45
|
+
|
|
46
|
+
def threshold_kwargs(self) -> dict[str, Any]:
|
|
47
|
+
values: dict[str, Any] = {"schema_version": "1", "missing_timing": self.missing_timing}
|
|
48
|
+
for name in (
|
|
49
|
+
"max_wer",
|
|
50
|
+
"max_compound_wer",
|
|
51
|
+
"max_cer",
|
|
52
|
+
"max_mer",
|
|
53
|
+
"min_reference_timestamp_coverage",
|
|
54
|
+
"max_invalid_timestamp_count",
|
|
55
|
+
):
|
|
56
|
+
value = getattr(self, name)
|
|
57
|
+
if value is not None:
|
|
58
|
+
values[name] = value
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_config() -> VerifyConfig:
|
|
63
|
+
return VerifyConfig()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_config(
|
|
67
|
+
path: str | Path, config: VerifyConfig | None = None
|
|
68
|
+
) -> tuple[VerifyConfig, bool]:
|
|
69
|
+
target = Path(path)
|
|
70
|
+
if target.exists():
|
|
71
|
+
return load_config(target), False
|
|
72
|
+
selected = config or default_config()
|
|
73
|
+
write_config(target, selected)
|
|
74
|
+
return selected, True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_config(path: str | Path, config: VerifyConfig, *, overwrite: bool = False) -> Path:
|
|
78
|
+
target = Path(path)
|
|
79
|
+
if target.exists() and not overwrite:
|
|
80
|
+
raise ConfigError(f"configuration already exists: {target}")
|
|
81
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
doc = tomlkit.document()
|
|
83
|
+
doc.add("format", CONFIG_FORMAT)
|
|
84
|
+
doc.add("schema_version", CONFIG_SCHEMA_VERSION)
|
|
85
|
+
doc.add(tomlkit.nl())
|
|
86
|
+
|
|
87
|
+
scope = tomlkit.table()
|
|
88
|
+
scope.add("backend", config.backend)
|
|
89
|
+
scope.add("device", config.device)
|
|
90
|
+
if config.model is not None:
|
|
91
|
+
scope.add("model", config.model)
|
|
92
|
+
scope.add("normalization", config.normalization)
|
|
93
|
+
scope.add("use_language_hint", config.use_language_hint)
|
|
94
|
+
doc.add("speechscope", scope)
|
|
95
|
+
|
|
96
|
+
thresholds = tomlkit.table()
|
|
97
|
+
thresholds.add("schema_version", "1")
|
|
98
|
+
for name in (
|
|
99
|
+
"max_wer",
|
|
100
|
+
"max_compound_wer",
|
|
101
|
+
"max_cer",
|
|
102
|
+
"max_mer",
|
|
103
|
+
"min_reference_timestamp_coverage",
|
|
104
|
+
"max_invalid_timestamp_count",
|
|
105
|
+
):
|
|
106
|
+
value = getattr(config, name)
|
|
107
|
+
if value is not None:
|
|
108
|
+
thresholds.add(name, value)
|
|
109
|
+
thresholds.add("missing_timing", config.missing_timing)
|
|
110
|
+
doc.add("thresholds", thresholds)
|
|
111
|
+
|
|
112
|
+
run = tomlkit.table()
|
|
113
|
+
run.add("fail_on_review", config.fail_on_review)
|
|
114
|
+
run.add("fail_fast", config.fail_fast)
|
|
115
|
+
doc.add("run", run)
|
|
116
|
+
_atomic_text(target, tomlkit.dumps(doc))
|
|
117
|
+
return target
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_config(path: str | Path) -> VerifyConfig:
|
|
121
|
+
source = Path(path)
|
|
122
|
+
try:
|
|
123
|
+
data = tomllib.loads(source.read_text(encoding="utf-8"))
|
|
124
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
125
|
+
raise ConfigError(f"cannot read configuration {source}: {exc}") from exc
|
|
126
|
+
if data.get("format") != CONFIG_FORMAT or data.get("schema_version") != CONFIG_SCHEMA_VERSION:
|
|
127
|
+
raise ConfigError("unsupported utterverify.toml format or schema_version")
|
|
128
|
+
speechscope = data.get("speechscope")
|
|
129
|
+
thresholds = data.get("thresholds")
|
|
130
|
+
run = data.get("run", {})
|
|
131
|
+
if (
|
|
132
|
+
not isinstance(speechscope, dict)
|
|
133
|
+
or not isinstance(thresholds, dict)
|
|
134
|
+
or not isinstance(run, dict)
|
|
135
|
+
):
|
|
136
|
+
raise ConfigError("speechscope, thresholds, and run must be TOML tables")
|
|
137
|
+
|
|
138
|
+
backend = _nonempty_text(speechscope.get("backend"), "speechscope.backend")
|
|
139
|
+
device = _nonempty_text(speechscope.get("device"), "speechscope.device")
|
|
140
|
+
normalization = _nonempty_text(speechscope.get("normalization"), "speechscope.normalization")
|
|
141
|
+
model = speechscope.get("model")
|
|
142
|
+
if model is not None:
|
|
143
|
+
model = _nonempty_text(model, "speechscope.model")
|
|
144
|
+
use_language_hint = speechscope.get("use_language_hint", False)
|
|
145
|
+
if type(use_language_hint) is not bool:
|
|
146
|
+
raise ConfigError("speechscope.use_language_hint must be boolean")
|
|
147
|
+
if thresholds.get("schema_version", "1") != "1":
|
|
148
|
+
raise ConfigError("thresholds.schema_version must be '1'")
|
|
149
|
+
missing_timing = thresholds.get("missing_timing", "review")
|
|
150
|
+
if missing_timing not in {"review", "fail"}:
|
|
151
|
+
raise ConfigError("thresholds.missing_timing must be 'review' or 'fail'")
|
|
152
|
+
|
|
153
|
+
numbers: dict[str, Any] = {}
|
|
154
|
+
for name in (
|
|
155
|
+
"max_wer",
|
|
156
|
+
"max_compound_wer",
|
|
157
|
+
"max_cer",
|
|
158
|
+
"max_mer",
|
|
159
|
+
"min_reference_timestamp_coverage",
|
|
160
|
+
):
|
|
161
|
+
value = thresholds.get(name)
|
|
162
|
+
if value is not None:
|
|
163
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
|
|
164
|
+
raise ConfigError(f"thresholds.{name} must be a non-negative number")
|
|
165
|
+
if name == "min_reference_timestamp_coverage" and value > 1:
|
|
166
|
+
raise ConfigError("thresholds.min_reference_timestamp_coverage must be <= 1")
|
|
167
|
+
numbers[name] = float(value)
|
|
168
|
+
else:
|
|
169
|
+
numbers[name] = None
|
|
170
|
+
invalid_count = thresholds.get("max_invalid_timestamp_count")
|
|
171
|
+
if invalid_count is not None and (type(invalid_count) is not int or invalid_count < 0):
|
|
172
|
+
raise ConfigError("thresholds.max_invalid_timestamp_count must be a non-negative integer")
|
|
173
|
+
for flag in ("fail_on_review", "fail_fast"):
|
|
174
|
+
if flag in run and type(run[flag]) is not bool:
|
|
175
|
+
raise ConfigError(f"run.{flag} must be boolean")
|
|
176
|
+
|
|
177
|
+
return VerifyConfig(
|
|
178
|
+
backend=backend,
|
|
179
|
+
device=device,
|
|
180
|
+
model=model,
|
|
181
|
+
normalization=normalization,
|
|
182
|
+
use_language_hint=use_language_hint,
|
|
183
|
+
max_invalid_timestamp_count=invalid_count,
|
|
184
|
+
missing_timing=missing_timing,
|
|
185
|
+
fail_on_review=run.get("fail_on_review", False),
|
|
186
|
+
fail_fast=run.get("fail_fast", False),
|
|
187
|
+
**numbers,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _nonempty_text(value: Any, name: str) -> str:
|
|
192
|
+
if not isinstance(value, str) or not value.strip():
|
|
193
|
+
raise ConfigError(f"{name} must be non-empty text")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _atomic_text(path: Path, content: str) -> None:
|
|
198
|
+
temp_name: str | None = None
|
|
199
|
+
try:
|
|
200
|
+
with tempfile.NamedTemporaryFile(
|
|
201
|
+
mode="w",
|
|
202
|
+
encoding="utf-8",
|
|
203
|
+
newline="\n",
|
|
204
|
+
dir=path.parent,
|
|
205
|
+
prefix=f".{path.name}.",
|
|
206
|
+
suffix=".tmp",
|
|
207
|
+
delete=False,
|
|
208
|
+
) as handle:
|
|
209
|
+
temp_name = handle.name
|
|
210
|
+
handle.write(content)
|
|
211
|
+
os.replace(temp_name, path)
|
|
212
|
+
finally:
|
|
213
|
+
if temp_name is not None and os.path.exists(temp_name):
|
|
214
|
+
os.unlink(temp_name)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def update_config(
|
|
218
|
+
path: str | Path,
|
|
219
|
+
*,
|
|
220
|
+
backend: str | None = None,
|
|
221
|
+
device: str | None = None,
|
|
222
|
+
model: str | None | _UnsetType = UNSET,
|
|
223
|
+
normalization: str | None = None,
|
|
224
|
+
use_language_hint: bool | None = None,
|
|
225
|
+
fail_on_review: bool | None = None,
|
|
226
|
+
fail_fast: bool | None = None,
|
|
227
|
+
) -> VerifyConfig:
|
|
228
|
+
"""Update only supplied config keys, preserving unrelated TOML content."""
|
|
229
|
+
target = Path(path)
|
|
230
|
+
if not target.exists():
|
|
231
|
+
write_config(target, default_config())
|
|
232
|
+
load_config(target)
|
|
233
|
+
try:
|
|
234
|
+
document = tomlkit.parse(target.read_text(encoding="utf-8"))
|
|
235
|
+
except (OSError, tomlkit.exceptions.ParseError) as exc:
|
|
236
|
+
raise ConfigError(f"cannot update configuration {target}: {exc}") from exc
|
|
237
|
+
|
|
238
|
+
if "run" not in document:
|
|
239
|
+
document["run"] = tomlkit.table()
|
|
240
|
+
speechscope = document["speechscope"]
|
|
241
|
+
run = document["run"]
|
|
242
|
+
if backend is not None:
|
|
243
|
+
speechscope["backend"] = backend
|
|
244
|
+
if device is not None:
|
|
245
|
+
speechscope["device"] = device
|
|
246
|
+
if model is not UNSET:
|
|
247
|
+
if model is None:
|
|
248
|
+
speechscope.pop("model", None)
|
|
249
|
+
else:
|
|
250
|
+
speechscope["model"] = model
|
|
251
|
+
if normalization is not None:
|
|
252
|
+
speechscope["normalization"] = normalization
|
|
253
|
+
if use_language_hint is not None:
|
|
254
|
+
speechscope["use_language_hint"] = use_language_hint
|
|
255
|
+
if fail_on_review is not None:
|
|
256
|
+
run["fail_on_review"] = fail_on_review
|
|
257
|
+
if fail_fast is not None:
|
|
258
|
+
run["fail_fast"] = fail_fast
|
|
259
|
+
|
|
260
|
+
_atomic_text(target, tomlkit.dumps(document))
|
|
261
|
+
return load_config(target)
|