utterplan 0.1.1__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.
- utterplan/__init__.py +81 -0
- utterplan/__main__.py +4 -0
- utterplan/_version.py +24 -0
- utterplan/cli.py +271 -0
- utterplan/config.py +161 -0
- utterplan/diagnostics.py +3 -0
- utterplan/directives.py +82 -0
- utterplan/exceptions.py +47 -0
- utterplan/format.py +34 -0
- utterplan/hashing.py +43 -0
- utterplan/language.py +93 -0
- utterplan/linguistics.py +179 -0
- utterplan/model.py +931 -0
- utterplan/parsers.py +272 -0
- utterplan/pauses.py +66 -0
- utterplan/planner.py +474 -0
- utterplan/preparation.py +224 -0
- utterplan/py.typed +0 -0
- utterplan/serialization.py +22 -0
- utterplan/spacy_models.py +21 -0
- utterplan/spans.py +3 -0
- utterplan/stages/__init__.py +0 -0
- utterplan/stages/document/__init__.py +0 -0
- utterplan/stages/document/plain.py +4 -0
- utterplan/stages/document/ssmd.py +3 -0
- utterplan/stages/preparation/__init__.py +0 -0
- utterplan/stages/preparation/identity.py +3 -0
- utterplan/stages/preparation/spokenform.py +3 -0
- utterplan/stages/protocols.py +25 -0
- utterplan/stages/segmentation/__init__.py +0 -0
- utterplan/stages/segmentation/phrasplit.py +35 -0
- utterplan/units.py +81 -0
- utterplan/utterplan.schema.json +260 -0
- utterplan-0.1.1.dist-info/METADATA +348 -0
- utterplan-0.1.1.dist-info/RECORD +40 -0
- utterplan-0.1.1.dist-info/WHEEL +5 -0
- utterplan-0.1.1.dist-info/entry_points.txt +2 -0
- utterplan-0.1.1.dist-info/licenses/LICENSE +201 -0
- utterplan-0.1.1.dist-info/licenses/NOTICE +1 -0
- utterplan-0.1.1.dist-info/top_level.txt +1 -0
utterplan/model.py
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .config import semantic_config
|
|
11
|
+
from .exceptions import PlanFormatError, PlanValidationError, UnsupportedSchemaError
|
|
12
|
+
from .hashing import semantic_hash, unit_hash_payload
|
|
13
|
+
from .language import LanguageRun
|
|
14
|
+
|
|
15
|
+
FORMAT = "utterplan"
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _plain(value: Any) -> Any:
|
|
20
|
+
if hasattr(value, "to_dict"):
|
|
21
|
+
return value.to_dict()
|
|
22
|
+
if isinstance(value, Mapping):
|
|
23
|
+
return {str(k): _plain(v) for k, v in value.items()}
|
|
24
|
+
if isinstance(value, (tuple, list)):
|
|
25
|
+
return [_plain(v) for v in value]
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PlanSource:
|
|
31
|
+
format: str
|
|
32
|
+
text: str
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {"format": self.format, "text": self.text}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class PlanTexts:
|
|
40
|
+
structural: str
|
|
41
|
+
spoken: str
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, str]:
|
|
44
|
+
return {"structural": self.structural, "spoken": self.spoken}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class TextPreparationInfo:
|
|
49
|
+
backend: str
|
|
50
|
+
version: str | None
|
|
51
|
+
languages: tuple[str, ...] = ()
|
|
52
|
+
replacements: tuple[Mapping[str, Any], ...] = ()
|
|
53
|
+
warnings: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return _plain(
|
|
57
|
+
{
|
|
58
|
+
"backend": self.backend,
|
|
59
|
+
"version": self.version,
|
|
60
|
+
"languages": self.languages,
|
|
61
|
+
"replacements": self.replacements,
|
|
62
|
+
"warnings": self.warnings,
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class AnnotationSpan:
|
|
69
|
+
id: str
|
|
70
|
+
kind: str
|
|
71
|
+
attrs: Mapping[str, Any]
|
|
72
|
+
structural_start: int
|
|
73
|
+
structural_end: int
|
|
74
|
+
spoken_start: int | None = None
|
|
75
|
+
spoken_end: int | None = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def char_start(self) -> int:
|
|
79
|
+
"""Structural start retained as a documented compatibility alias."""
|
|
80
|
+
return self.structural_start
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def char_end(self) -> int:
|
|
84
|
+
"""Structural end retained as a documented compatibility alias."""
|
|
85
|
+
return self.structural_end
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"id": self.id,
|
|
90
|
+
"kind": self.kind,
|
|
91
|
+
"attrs": _plain(self.attrs),
|
|
92
|
+
"structural_start": self.structural_start,
|
|
93
|
+
"structural_end": self.structural_end,
|
|
94
|
+
"spoken_start": self.spoken_start,
|
|
95
|
+
"spoken_end": self.spoken_end,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class TokenAnnotation:
|
|
101
|
+
spoken_start: int
|
|
102
|
+
spoken_end: int
|
|
103
|
+
text: str
|
|
104
|
+
pos: str | None = None
|
|
105
|
+
tag: str | None = None
|
|
106
|
+
lemma: str | None = None
|
|
107
|
+
language: str | None = None
|
|
108
|
+
id: str | None = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def start(self) -> int:
|
|
112
|
+
return self.spoken_start
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def end(self) -> int:
|
|
116
|
+
return self.spoken_end
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, Any]:
|
|
119
|
+
result = {
|
|
120
|
+
"spoken_start": self.spoken_start,
|
|
121
|
+
"spoken_end": self.spoken_end,
|
|
122
|
+
"text": self.text,
|
|
123
|
+
}
|
|
124
|
+
for key, value in (
|
|
125
|
+
("pos", self.pos),
|
|
126
|
+
("tag", self.tag),
|
|
127
|
+
("lemma", self.lemma),
|
|
128
|
+
("language", self.language),
|
|
129
|
+
):
|
|
130
|
+
if value is not None:
|
|
131
|
+
result[key] = value
|
|
132
|
+
if self.id is not None:
|
|
133
|
+
result["id"] = self.id
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class BoundaryEvent:
|
|
139
|
+
id: str
|
|
140
|
+
position: int
|
|
141
|
+
kind: str
|
|
142
|
+
seconds: float | None = None
|
|
143
|
+
origin: str = "planner"
|
|
144
|
+
strength: str | None = None
|
|
145
|
+
attrs: Mapping[str, Any] = field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def pos(self) -> int:
|
|
149
|
+
return self.position
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def duration_s(self) -> float | None:
|
|
153
|
+
return self.seconds
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict[str, Any]:
|
|
156
|
+
result = {
|
|
157
|
+
"id": self.id,
|
|
158
|
+
"position": self.position,
|
|
159
|
+
"kind": self.kind,
|
|
160
|
+
"seconds": self.seconds,
|
|
161
|
+
"origin": self.origin,
|
|
162
|
+
"strength": self.strength,
|
|
163
|
+
}
|
|
164
|
+
if self.attrs:
|
|
165
|
+
result["attrs"] = _plain(self.attrs)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class ResolvedPause:
|
|
171
|
+
seconds: float = 0.0
|
|
172
|
+
events: tuple[str, ...] = ()
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def duration_s(self) -> float:
|
|
176
|
+
return self.seconds
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
return {"seconds": self.seconds, "events": list(self.events)}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, slots=True)
|
|
183
|
+
class VoiceDirective:
|
|
184
|
+
reference: str
|
|
185
|
+
|
|
186
|
+
def to_dict(self) -> dict[str, str]:
|
|
187
|
+
return {"reference": self.reference}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class PronunciationDirective:
|
|
192
|
+
phonemes: str
|
|
193
|
+
alphabet: str = "ipa"
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict[str, str]:
|
|
196
|
+
return {"phonemes": self.phonemes, "alphabet": self.alphabet}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class ProsodyDirective:
|
|
201
|
+
rate: str | None = None
|
|
202
|
+
pitch: str | None = None
|
|
203
|
+
volume: str | None = None
|
|
204
|
+
|
|
205
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
206
|
+
return {"rate": self.rate, "pitch": self.pitch, "volume": self.volume}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class EmphasisDirective:
|
|
211
|
+
level: str
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict[str, str]:
|
|
214
|
+
return {"level": self.level}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class AudioDirective:
|
|
219
|
+
src: str
|
|
220
|
+
alt_text: str | None = None
|
|
221
|
+
clip_begin: str | None = None
|
|
222
|
+
clip_end: str | None = None
|
|
223
|
+
speed: str | None = None
|
|
224
|
+
repeat_duration: str | None = None
|
|
225
|
+
repeat_count: int | None = None
|
|
226
|
+
sound_level: str | None = None
|
|
227
|
+
|
|
228
|
+
def to_dict(self) -> dict[str, Any]:
|
|
229
|
+
return {
|
|
230
|
+
"src": self.src,
|
|
231
|
+
"alt_text": self.alt_text,
|
|
232
|
+
"clip_begin": self.clip_begin,
|
|
233
|
+
"clip_end": self.clip_end,
|
|
234
|
+
"speed": self.speed,
|
|
235
|
+
"repeat_duration": self.repeat_duration,
|
|
236
|
+
"repeat_count": self.repeat_count,
|
|
237
|
+
"sound_level": self.sound_level,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True, slots=True)
|
|
242
|
+
class SegmentDirectives:
|
|
243
|
+
voice: VoiceDirective | None = None
|
|
244
|
+
pronunciation: PronunciationDirective | None = None
|
|
245
|
+
prosody: ProsodyDirective | None = None
|
|
246
|
+
emphasis: EmphasisDirective | None = None
|
|
247
|
+
audio: AudioDirective | None = None
|
|
248
|
+
|
|
249
|
+
def to_dict(self) -> dict[str, Any]:
|
|
250
|
+
result: dict[str, Any] = {}
|
|
251
|
+
for key, value in (
|
|
252
|
+
("voice", self.voice),
|
|
253
|
+
("pronunciation", self.pronunciation),
|
|
254
|
+
("prosody", self.prosody),
|
|
255
|
+
("emphasis", self.emphasis),
|
|
256
|
+
("audio", self.audio),
|
|
257
|
+
):
|
|
258
|
+
if value is not None:
|
|
259
|
+
result[key] = _plain(value)
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class PlanSegment:
|
|
265
|
+
id: str
|
|
266
|
+
text: str
|
|
267
|
+
spoken_start: int
|
|
268
|
+
spoken_end: int
|
|
269
|
+
language: str
|
|
270
|
+
paragraph: int = 0
|
|
271
|
+
sentence: int = 0
|
|
272
|
+
clause: int = 0
|
|
273
|
+
structural_start: int | None = None
|
|
274
|
+
structural_end: int | None = None
|
|
275
|
+
pause_before: ResolvedPause = field(default_factory=ResolvedPause)
|
|
276
|
+
pause_after: ResolvedPause = field(default_factory=ResolvedPause)
|
|
277
|
+
directives: SegmentDirectives = field(default_factory=SegmentDirectives)
|
|
278
|
+
token_indices: tuple[int, ...] = ()
|
|
279
|
+
annotation_ids: tuple[str, ...] = ()
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def char_start(self) -> int:
|
|
283
|
+
return self.spoken_start
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def char_end(self) -> int:
|
|
287
|
+
return self.spoken_end
|
|
288
|
+
|
|
289
|
+
@property
|
|
290
|
+
def paragraph_idx(self) -> int:
|
|
291
|
+
return self.paragraph
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def sentence_idx(self) -> int:
|
|
295
|
+
return self.sentence
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def clause_idx(self) -> int:
|
|
299
|
+
return self.clause
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def meta(self) -> dict[str, Any]:
|
|
303
|
+
return {"language": self.language}
|
|
304
|
+
|
|
305
|
+
def to_dict(self) -> dict[str, Any]:
|
|
306
|
+
result = {
|
|
307
|
+
"id": self.id,
|
|
308
|
+
"text": self.text,
|
|
309
|
+
"spoken_start": self.spoken_start,
|
|
310
|
+
"spoken_end": self.spoken_end,
|
|
311
|
+
"language": self.language,
|
|
312
|
+
"paragraph": self.paragraph,
|
|
313
|
+
"sentence": self.sentence,
|
|
314
|
+
"clause": self.clause,
|
|
315
|
+
"pause_before": self.pause_before.to_dict(),
|
|
316
|
+
"pause_after": self.pause_after.to_dict(),
|
|
317
|
+
"directives": self.directives.to_dict(),
|
|
318
|
+
"token_indices": list(self.token_indices),
|
|
319
|
+
"annotation_ids": list(self.annotation_ids),
|
|
320
|
+
}
|
|
321
|
+
if self.structural_start is not None:
|
|
322
|
+
result["structural_start"] = self.structural_start
|
|
323
|
+
if self.structural_end is not None:
|
|
324
|
+
result["structural_end"] = self.structural_end
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True, slots=True)
|
|
329
|
+
class Marker:
|
|
330
|
+
id: str
|
|
331
|
+
name: str
|
|
332
|
+
spoken_position: int
|
|
333
|
+
attrs: Mapping[str, Any] = field(default_factory=dict)
|
|
334
|
+
|
|
335
|
+
def to_dict(self) -> dict[str, Any]:
|
|
336
|
+
result = {"id": self.id, "name": self.name, "spoken_position": self.spoken_position}
|
|
337
|
+
if self.attrs:
|
|
338
|
+
result["attrs"] = _plain(self.attrs)
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True, slots=True)
|
|
343
|
+
class PlanUnit:
|
|
344
|
+
id: str
|
|
345
|
+
index: int
|
|
346
|
+
kind: str
|
|
347
|
+
spoken_start: int
|
|
348
|
+
spoken_end: int
|
|
349
|
+
segment_ids: tuple[str, ...]
|
|
350
|
+
marker_ids: tuple[str, ...] = ()
|
|
351
|
+
content_hash: str = ""
|
|
352
|
+
|
|
353
|
+
def to_dict(self) -> dict[str, Any]:
|
|
354
|
+
return {
|
|
355
|
+
"id": self.id,
|
|
356
|
+
"index": self.index,
|
|
357
|
+
"kind": self.kind,
|
|
358
|
+
"spoken_start": self.spoken_start,
|
|
359
|
+
"spoken_end": self.spoken_end,
|
|
360
|
+
"segment_ids": list(self.segment_ids),
|
|
361
|
+
"marker_ids": list(self.marker_ids),
|
|
362
|
+
"content_hash": self.content_hash,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass(frozen=True, slots=True)
|
|
367
|
+
class Diagnostic:
|
|
368
|
+
code: str
|
|
369
|
+
message: str
|
|
370
|
+
severity: str = "info"
|
|
371
|
+
path: str | None = None
|
|
372
|
+
|
|
373
|
+
def to_dict(self) -> dict[str, Any]:
|
|
374
|
+
return {
|
|
375
|
+
"code": self.code,
|
|
376
|
+
"message": self.message,
|
|
377
|
+
"severity": self.severity,
|
|
378
|
+
"path": self.path,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True, slots=True)
|
|
383
|
+
class UtterancePlan:
|
|
384
|
+
source: PlanSource
|
|
385
|
+
config: Mapping[str, Any]
|
|
386
|
+
texts: PlanTexts
|
|
387
|
+
preparation: TextPreparationInfo
|
|
388
|
+
languages: tuple[LanguageRun, ...] = ()
|
|
389
|
+
annotations: tuple[AnnotationSpan, ...] = ()
|
|
390
|
+
boundaries: tuple[BoundaryEvent, ...] = ()
|
|
391
|
+
tokens: tuple[TokenAnnotation, ...] = ()
|
|
392
|
+
segments: tuple[PlanSegment, ...] = ()
|
|
393
|
+
units: tuple[PlanUnit, ...] = ()
|
|
394
|
+
markers: tuple[Marker, ...] = ()
|
|
395
|
+
document_metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
396
|
+
warnings: tuple[str, ...] = ()
|
|
397
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
398
|
+
plan_id: str = ""
|
|
399
|
+
producer: Mapping[str, Any] = field(
|
|
400
|
+
default_factory=lambda: {"name": FORMAT, "version": __version__}
|
|
401
|
+
)
|
|
402
|
+
format: str = FORMAT
|
|
403
|
+
schema_version: int = SCHEMA_VERSION
|
|
404
|
+
|
|
405
|
+
def semantic_dict(self) -> dict[str, Any]:
|
|
406
|
+
data = self.to_dict()
|
|
407
|
+
for key in ("plan_id", "producer", "diagnostics", "warnings"):
|
|
408
|
+
data.pop(key, None)
|
|
409
|
+
data["config"] = semantic_config(data["config"])
|
|
410
|
+
return data
|
|
411
|
+
|
|
412
|
+
def with_identity(self) -> UtterancePlan:
|
|
413
|
+
return (
|
|
414
|
+
self
|
|
415
|
+
if self.plan_id == semantic_hash(self.semantic_dict())
|
|
416
|
+
else _replace_plan(self, plan_id=semantic_hash(self.semantic_dict()))
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def to_dict(self) -> dict[str, Any]:
|
|
420
|
+
return {
|
|
421
|
+
"format": self.format,
|
|
422
|
+
"schema_version": self.schema_version,
|
|
423
|
+
"producer": _plain(self.producer),
|
|
424
|
+
"plan_id": self.plan_id,
|
|
425
|
+
"source": self.source.to_dict(),
|
|
426
|
+
"config": _plain(self.config),
|
|
427
|
+
"texts": self.texts.to_dict(),
|
|
428
|
+
"preparation": self.preparation.to_dict(),
|
|
429
|
+
"languages": [_plain(x) for x in self.languages],
|
|
430
|
+
"annotations": [_plain(x) for x in self.annotations],
|
|
431
|
+
"boundaries": [_plain(x) for x in self.boundaries],
|
|
432
|
+
"tokens": [_plain(x) for x in self.tokens],
|
|
433
|
+
"segments": [_plain(x) for x in self.segments],
|
|
434
|
+
"units": [_plain(x) for x in self.units],
|
|
435
|
+
"markers": [_plain(x) for x in self.markers],
|
|
436
|
+
"document_metadata": _plain(self.document_metadata),
|
|
437
|
+
"warnings": list(self.warnings),
|
|
438
|
+
"diagnostics": [_plain(x) for x in self.diagnostics],
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
442
|
+
return (
|
|
443
|
+
__import__("json").dumps(
|
|
444
|
+
self.to_dict(), ensure_ascii=False, sort_keys=True, indent=indent, allow_nan=False
|
|
445
|
+
)
|
|
446
|
+
+ "\n"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def save(self, path: str | Path) -> None:
|
|
450
|
+
Path(path).write_text(self.to_json(), encoding="utf-8")
|
|
451
|
+
|
|
452
|
+
@classmethod
|
|
453
|
+
def from_json(cls, value: str) -> UtterancePlan:
|
|
454
|
+
import json
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
data = json.loads(value)
|
|
458
|
+
except json.JSONDecodeError as exc:
|
|
459
|
+
raise PlanFormatError(str(exc), code="json.invalid") from exc
|
|
460
|
+
return cls.from_dict(data)
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def load(cls, path: str | Path) -> UtterancePlan:
|
|
464
|
+
return cls.from_json(Path(path).read_text(encoding="utf-8"))
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def from_dict(cls, data: Mapping[str, Any]) -> UtterancePlan:
|
|
468
|
+
_check_shape(data)
|
|
469
|
+
try:
|
|
470
|
+
plan = _from_dict(data)
|
|
471
|
+
except (KeyError, TypeError, ValueError, IndexError) as exc:
|
|
472
|
+
raise PlanFormatError(f"invalid plan value: {exc}", code="plan.value") from exc
|
|
473
|
+
plan.validate()
|
|
474
|
+
return plan
|
|
475
|
+
|
|
476
|
+
def validate(self) -> None:
|
|
477
|
+
validate_plan(self)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _replace_plan(plan: UtterancePlan, **changes: Any) -> UtterancePlan:
|
|
481
|
+
values = {field: getattr(plan, field) for field in plan.__dataclass_fields__}
|
|
482
|
+
values.update(changes)
|
|
483
|
+
return UtterancePlan(**values)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _check_shape(data: Mapping[str, Any]) -> None:
|
|
487
|
+
if not isinstance(data, Mapping):
|
|
488
|
+
raise PlanFormatError("plan must be an object", code="json.type")
|
|
489
|
+
if data.get("format") != FORMAT:
|
|
490
|
+
raise PlanFormatError("format must be 'utterplan'", code="format.invalid", path="$.format")
|
|
491
|
+
allowed = {
|
|
492
|
+
"format",
|
|
493
|
+
"schema_version",
|
|
494
|
+
"producer",
|
|
495
|
+
"plan_id",
|
|
496
|
+
"source",
|
|
497
|
+
"config",
|
|
498
|
+
"texts",
|
|
499
|
+
"preparation",
|
|
500
|
+
"languages",
|
|
501
|
+
"annotations",
|
|
502
|
+
"boundaries",
|
|
503
|
+
"tokens",
|
|
504
|
+
"segments",
|
|
505
|
+
"units",
|
|
506
|
+
"markers",
|
|
507
|
+
"document_metadata",
|
|
508
|
+
"warnings",
|
|
509
|
+
"diagnostics",
|
|
510
|
+
}
|
|
511
|
+
unknown = set(data) - allowed
|
|
512
|
+
if unknown:
|
|
513
|
+
raise PlanFormatError(f"unknown top-level fields: {sorted(unknown)}", code="field.unknown")
|
|
514
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
515
|
+
raise UnsupportedSchemaError(data.get("schema_version"))
|
|
516
|
+
for key in ("source", "config", "texts", "preparation", "segments", "units"):
|
|
517
|
+
if key not in data:
|
|
518
|
+
raise PlanFormatError(
|
|
519
|
+
f"required field {key!r} is missing", code="field.required", path=f"$.{key}"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
_check_nested_types(data)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _check_nested_types(data: Mapping[str, Any]) -> None:
|
|
526
|
+
_expect(data.get("producer"), Mapping, "$.producer")
|
|
527
|
+
_expect(data.get("source"), Mapping, "$.source")
|
|
528
|
+
_expect(data["source"].get("format"), str, "$.source.format")
|
|
529
|
+
_expect(data["source"].get("text"), str, "$.source.text")
|
|
530
|
+
_expect(data.get("config"), Mapping, "$.config")
|
|
531
|
+
texts = _expect(data.get("texts"), Mapping, "$.texts")
|
|
532
|
+
_expect(texts.get("structural"), str, "$.texts.structural")
|
|
533
|
+
_expect(texts.get("spoken"), str, "$.texts.spoken")
|
|
534
|
+
preparation = _expect(data.get("preparation"), Mapping, "$.preparation")
|
|
535
|
+
unknown = set(preparation) - {"backend", "version", "languages", "replacements", "warnings"}
|
|
536
|
+
if unknown:
|
|
537
|
+
raise PlanFormatError(
|
|
538
|
+
f"unknown preparation fields: {sorted(unknown)}",
|
|
539
|
+
code="field.unknown",
|
|
540
|
+
path="$.preparation",
|
|
541
|
+
)
|
|
542
|
+
_expect(preparation.get("backend"), str, "$.preparation.backend")
|
|
543
|
+
if preparation.get("version") is not None:
|
|
544
|
+
_expect(preparation.get("version"), str, "$.preparation.version")
|
|
545
|
+
for key in ("languages", "replacements", "warnings"):
|
|
546
|
+
_expect(preparation.get(key), list, f"$.preparation.{key}")
|
|
547
|
+
for key in (
|
|
548
|
+
"languages",
|
|
549
|
+
"annotations",
|
|
550
|
+
"boundaries",
|
|
551
|
+
"tokens",
|
|
552
|
+
"segments",
|
|
553
|
+
"units",
|
|
554
|
+
"markers",
|
|
555
|
+
"warnings",
|
|
556
|
+
"diagnostics",
|
|
557
|
+
):
|
|
558
|
+
_expect(data.get(key), list, f"$.{key}")
|
|
559
|
+
for index, item in enumerate(data["languages"]):
|
|
560
|
+
value = _expect(item, Mapping, f"$.languages[{index}]")
|
|
561
|
+
_expect(value.get("id"), str, f"$.languages[{index}].id")
|
|
562
|
+
_expect(value.get("spoken_start"), int, f"$.languages[{index}].spoken_start")
|
|
563
|
+
_expect(value.get("spoken_end"), int, f"$.languages[{index}].spoken_end")
|
|
564
|
+
_expect(value.get("language"), str, f"$.languages[{index}].language")
|
|
565
|
+
for index, item in enumerate(data["annotations"]):
|
|
566
|
+
value = _expect(item, Mapping, f"$.annotations[{index}]")
|
|
567
|
+
for key in ("id", "kind"):
|
|
568
|
+
_expect(value.get(key), str, f"$.annotations[{index}].{key}")
|
|
569
|
+
for key in ("structural_start", "structural_end"):
|
|
570
|
+
_expect(value.get(key), int, f"$.annotations[{index}].{key}")
|
|
571
|
+
for key in ("spoken_start", "spoken_end"):
|
|
572
|
+
if value.get(key) is not None:
|
|
573
|
+
_expect(value.get(key), int, f"$.annotations[{index}].{key}")
|
|
574
|
+
_expect(value.get("attrs"), Mapping, f"$.annotations[{index}].attrs")
|
|
575
|
+
for index, item in enumerate(data["segments"]):
|
|
576
|
+
value = _expect(item, Mapping, f"$.segments[{index}]")
|
|
577
|
+
_expect(value.get("text"), str, f"$.segments[{index}].text")
|
|
578
|
+
for key in ("spoken_start", "spoken_end"):
|
|
579
|
+
_expect(value.get(key), int, f"$.segments[{index}].{key}")
|
|
580
|
+
_expect(value.get("language"), str, f"$.segments[{index}].language")
|
|
581
|
+
for key in ("token_indices", "annotation_ids"):
|
|
582
|
+
_expect(value.get(key), list, f"$.segments[{index}].{key}")
|
|
583
|
+
for index, item in enumerate(data["units"]):
|
|
584
|
+
value = _expect(item, Mapping, f"$.units[{index}]")
|
|
585
|
+
for key in ("id", "kind", "content_hash"):
|
|
586
|
+
_expect(value.get(key), str, f"$.units[{index}].{key}")
|
|
587
|
+
_expect(value.get("segment_ids"), list, f"$.units[{index}].segment_ids")
|
|
588
|
+
_expect(value.get("marker_ids"), list, f"$.units[{index}].marker_ids")
|
|
589
|
+
for index, item in enumerate(data["markers"]):
|
|
590
|
+
value = _expect(item, Mapping, f"$.markers[{index}]")
|
|
591
|
+
for key in ("id", "name"):
|
|
592
|
+
_expect(value.get(key), str, f"$.markers[{index}].{key}")
|
|
593
|
+
_expect(value.get("spoken_position"), int, f"$.markers[{index}].spoken_position")
|
|
594
|
+
for index, item in enumerate(data["warnings"]):
|
|
595
|
+
_expect(item, str, f"$.warnings[{index}]")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _expect(value: Any, expected: type | tuple[type, ...], path: str) -> Any:
|
|
599
|
+
valid = type(value) is int if expected is int else isinstance(value, expected)
|
|
600
|
+
if not valid:
|
|
601
|
+
raise PlanFormatError(f"expected {expected} at {path}", code="field.type", path=path)
|
|
602
|
+
return value
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _pause(data: Mapping[str, Any] | None) -> ResolvedPause:
|
|
606
|
+
data = data or {}
|
|
607
|
+
return ResolvedPause(float(data.get("seconds", 0.0)), tuple(data.get("events", ())))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _directive(data: Mapping[str, Any] | None) -> SegmentDirectives:
|
|
611
|
+
data = data or {}
|
|
612
|
+
voice = data.get("voice")
|
|
613
|
+
pronunciation = data.get("pronunciation")
|
|
614
|
+
prosody = data.get("prosody")
|
|
615
|
+
emphasis = data.get("emphasis")
|
|
616
|
+
audio = data.get("audio")
|
|
617
|
+
return SegmentDirectives(
|
|
618
|
+
VoiceDirective(str(voice["reference"])) if voice else None,
|
|
619
|
+
PronunciationDirective(
|
|
620
|
+
str(pronunciation["phonemes"]), str(pronunciation.get("alphabet", "ipa"))
|
|
621
|
+
)
|
|
622
|
+
if pronunciation
|
|
623
|
+
else None,
|
|
624
|
+
ProsodyDirective(prosody.get("rate"), prosody.get("pitch"), prosody.get("volume"))
|
|
625
|
+
if prosody
|
|
626
|
+
else None,
|
|
627
|
+
EmphasisDirective(str(emphasis["level"])) if emphasis else None,
|
|
628
|
+
AudioDirective(
|
|
629
|
+
str(audio["src"]),
|
|
630
|
+
audio.get("alt_text"),
|
|
631
|
+
audio.get("clip_begin"),
|
|
632
|
+
audio.get("clip_end"),
|
|
633
|
+
audio.get("speed"),
|
|
634
|
+
audio.get("repeat_duration"),
|
|
635
|
+
audio.get("repeat_count"),
|
|
636
|
+
audio.get("sound_level"),
|
|
637
|
+
)
|
|
638
|
+
if audio
|
|
639
|
+
else None,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _optional_int(value: Any) -> int | None:
|
|
644
|
+
if value is None:
|
|
645
|
+
return None
|
|
646
|
+
return int(value)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _from_dict(data: Mapping[str, Any]) -> UtterancePlan:
|
|
650
|
+
source = data["source"]
|
|
651
|
+
texts = data["texts"]
|
|
652
|
+
prep = data["preparation"]
|
|
653
|
+
return UtterancePlan(
|
|
654
|
+
source=PlanSource(str(source["format"]), str(source["text"])),
|
|
655
|
+
config=dict(data["config"]),
|
|
656
|
+
texts=PlanTexts(str(texts["structural"]), str(texts["spoken"])),
|
|
657
|
+
preparation=TextPreparationInfo(
|
|
658
|
+
backend=str(prep["backend"]),
|
|
659
|
+
version=prep.get("version"),
|
|
660
|
+
languages=tuple(prep["languages"]),
|
|
661
|
+
replacements=tuple(prep["replacements"]),
|
|
662
|
+
warnings=tuple(prep["warnings"]),
|
|
663
|
+
),
|
|
664
|
+
languages=tuple(
|
|
665
|
+
LanguageRun(
|
|
666
|
+
str(x["id"]),
|
|
667
|
+
int(x["spoken_start"]),
|
|
668
|
+
int(x["spoken_end"]),
|
|
669
|
+
str(x["language"]),
|
|
670
|
+
str(x.get("source", "document-default")),
|
|
671
|
+
)
|
|
672
|
+
for x in data.get("languages", ())
|
|
673
|
+
),
|
|
674
|
+
annotations=tuple(
|
|
675
|
+
AnnotationSpan(
|
|
676
|
+
id=str(x.get("id", f"annotation-{i:06d}")),
|
|
677
|
+
kind=str(x.get("kind", "annotation")),
|
|
678
|
+
attrs=dict(x.get("attrs", {})),
|
|
679
|
+
structural_start=int(x.get("structural_start", x.get("char_start", 0))),
|
|
680
|
+
structural_end=int(x.get("structural_end", x.get("char_end", 0))),
|
|
681
|
+
spoken_start=_optional_int(x.get("spoken_start")),
|
|
682
|
+
spoken_end=_optional_int(x.get("spoken_end")),
|
|
683
|
+
)
|
|
684
|
+
for i, x in enumerate(data.get("annotations", ()))
|
|
685
|
+
),
|
|
686
|
+
boundaries=tuple(
|
|
687
|
+
BoundaryEvent(
|
|
688
|
+
str(x["id"]),
|
|
689
|
+
int(x.get("position", x.get("pos", 0))),
|
|
690
|
+
str(x["kind"]),
|
|
691
|
+
x.get("seconds", x.get("duration_s")),
|
|
692
|
+
str(x.get("origin", "planner")),
|
|
693
|
+
x.get("strength"),
|
|
694
|
+
dict(x.get("attrs", {})),
|
|
695
|
+
)
|
|
696
|
+
for x in data.get("boundaries", ())
|
|
697
|
+
),
|
|
698
|
+
tokens=tuple(
|
|
699
|
+
TokenAnnotation(
|
|
700
|
+
int(x["spoken_start"]),
|
|
701
|
+
int(x["spoken_end"]),
|
|
702
|
+
str(x.get("text", "")),
|
|
703
|
+
x.get("pos"),
|
|
704
|
+
x.get("tag"),
|
|
705
|
+
x.get("lemma"),
|
|
706
|
+
x.get("language"),
|
|
707
|
+
x.get("id"),
|
|
708
|
+
)
|
|
709
|
+
for x in data.get("tokens", ())
|
|
710
|
+
),
|
|
711
|
+
segments=tuple(
|
|
712
|
+
PlanSegment(
|
|
713
|
+
str(x["id"]),
|
|
714
|
+
str(x["text"]),
|
|
715
|
+
int(x["spoken_start"]),
|
|
716
|
+
int(x["spoken_end"]),
|
|
717
|
+
str(x.get("language", "")),
|
|
718
|
+
int(x.get("paragraph", 0)),
|
|
719
|
+
int(x.get("sentence", 0)),
|
|
720
|
+
int(x.get("clause", 0)),
|
|
721
|
+
x.get("structural_start"),
|
|
722
|
+
x.get("structural_end"),
|
|
723
|
+
_pause(x.get("pause_before")),
|
|
724
|
+
_pause(x.get("pause_after")),
|
|
725
|
+
_directive(x.get("directives")),
|
|
726
|
+
tuple(x.get("token_indices", ())),
|
|
727
|
+
tuple(x.get("annotation_ids", ())),
|
|
728
|
+
)
|
|
729
|
+
for x in data.get("segments", ())
|
|
730
|
+
),
|
|
731
|
+
units=tuple(
|
|
732
|
+
PlanUnit(
|
|
733
|
+
str(x["id"]),
|
|
734
|
+
int(x["index"]),
|
|
735
|
+
str(x["kind"]),
|
|
736
|
+
int(x["spoken_start"]),
|
|
737
|
+
int(x["spoken_end"]),
|
|
738
|
+
tuple(x.get("segment_ids", ())),
|
|
739
|
+
tuple(x.get("marker_ids", ())),
|
|
740
|
+
str(x.get("content_hash", "")),
|
|
741
|
+
)
|
|
742
|
+
for x in data.get("units", ())
|
|
743
|
+
),
|
|
744
|
+
markers=tuple(
|
|
745
|
+
Marker(
|
|
746
|
+
str(x["id"]),
|
|
747
|
+
str(x["name"]),
|
|
748
|
+
int(x.get("spoken_position", x.get("position", 0))),
|
|
749
|
+
dict(x.get("attrs", {})),
|
|
750
|
+
)
|
|
751
|
+
for x in data.get("markers", ())
|
|
752
|
+
),
|
|
753
|
+
document_metadata=dict(data.get("document_metadata", {})),
|
|
754
|
+
warnings=tuple(data.get("warnings", ())),
|
|
755
|
+
diagnostics=tuple(
|
|
756
|
+
Diagnostic(
|
|
757
|
+
str(x["code"]), str(x["message"]), str(x.get("severity", "info")), x.get("path")
|
|
758
|
+
)
|
|
759
|
+
for x in data.get("diagnostics", ())
|
|
760
|
+
),
|
|
761
|
+
plan_id=str(data.get("plan_id", "")),
|
|
762
|
+
producer=dict(data.get("producer", {"name": FORMAT, "version": __version__})),
|
|
763
|
+
format=str(data["format"]),
|
|
764
|
+
schema_version=int(data["schema_version"]),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def validate_plan(plan: UtterancePlan) -> None:
|
|
769
|
+
text = plan.texts.spoken
|
|
770
|
+
if plan.source.format not in {"plain", "ssmd"}:
|
|
771
|
+
raise PlanValidationError("unsupported source format", code="source.format")
|
|
772
|
+
if plan.source.text is None:
|
|
773
|
+
raise PlanValidationError("source text is required", code="source.text")
|
|
774
|
+
if not plan.plan_id:
|
|
775
|
+
raise PlanValidationError("plan_id is required", code="plan_id.required")
|
|
776
|
+
ids: set[str] = set()
|
|
777
|
+
for collection, _name in (
|
|
778
|
+
(plan.languages, "language"),
|
|
779
|
+
(plan.annotations, "annotation"),
|
|
780
|
+
(plan.boundaries, "boundary"),
|
|
781
|
+
(plan.tokens, "token"),
|
|
782
|
+
(plan.segments, "segment"),
|
|
783
|
+
(plan.units, "unit"),
|
|
784
|
+
(plan.markers, "marker"),
|
|
785
|
+
):
|
|
786
|
+
for item in collection:
|
|
787
|
+
item_id = getattr(item, "id", None)
|
|
788
|
+
if item_id is not None and item_id in ids:
|
|
789
|
+
raise PlanValidationError(f"duplicate id {item_id}", code="id.duplicate")
|
|
790
|
+
if item_id is not None:
|
|
791
|
+
ids.add(item_id)
|
|
792
|
+
previous = 0
|
|
793
|
+
boundary_ids = {event.id for event in plan.boundaries}
|
|
794
|
+
for segment in plan.segments:
|
|
795
|
+
if not (0 <= segment.spoken_start <= segment.spoken_end <= len(text)):
|
|
796
|
+
raise PlanValidationError(
|
|
797
|
+
"segment range is outside spoken text", code="segment.out_of_range"
|
|
798
|
+
)
|
|
799
|
+
if segment.text != text[segment.spoken_start : segment.spoken_end]:
|
|
800
|
+
raise PlanValidationError(
|
|
801
|
+
"segment text does not match spoken range", code="segment.range_mismatch"
|
|
802
|
+
)
|
|
803
|
+
if segment.spoken_start < previous:
|
|
804
|
+
raise PlanValidationError("segments are not sorted", code="segment.order")
|
|
805
|
+
previous = segment.spoken_end
|
|
806
|
+
for pause in (segment.pause_before, segment.pause_after):
|
|
807
|
+
if not math.isfinite(pause.seconds) or pause.seconds < 0:
|
|
808
|
+
raise PlanValidationError(
|
|
809
|
+
"pause must be finite and non-negative", code="pause.invalid"
|
|
810
|
+
)
|
|
811
|
+
for event_id in pause.events:
|
|
812
|
+
if event_id not in boundary_ids:
|
|
813
|
+
raise PlanValidationError(
|
|
814
|
+
f"unknown boundary {event_id}", code="pause.unknown_boundary"
|
|
815
|
+
)
|
|
816
|
+
for boundary in plan.boundaries:
|
|
817
|
+
if not (0 <= boundary.position <= len(text)):
|
|
818
|
+
raise PlanValidationError(
|
|
819
|
+
"boundary position is outside spoken text", code="boundary.out_of_range"
|
|
820
|
+
)
|
|
821
|
+
if boundary.seconds is not None and (
|
|
822
|
+
not math.isfinite(float(boundary.seconds)) or float(boundary.seconds) < 0
|
|
823
|
+
):
|
|
824
|
+
raise PlanValidationError(
|
|
825
|
+
"boundary seconds must be finite and non-negative", code="boundary.seconds"
|
|
826
|
+
)
|
|
827
|
+
annotation_ids = {annotation.id for annotation in plan.annotations}
|
|
828
|
+
for annotation in plan.annotations:
|
|
829
|
+
if not (
|
|
830
|
+
0
|
|
831
|
+
<= annotation.structural_start
|
|
832
|
+
<= annotation.structural_end
|
|
833
|
+
<= len(plan.texts.structural)
|
|
834
|
+
):
|
|
835
|
+
raise PlanValidationError(
|
|
836
|
+
"annotation structural range is outside structural text",
|
|
837
|
+
code="annotation.structural_range",
|
|
838
|
+
)
|
|
839
|
+
if (annotation.spoken_start is None) != (annotation.spoken_end is None):
|
|
840
|
+
raise PlanValidationError(
|
|
841
|
+
"annotation spoken range must be both null or both present",
|
|
842
|
+
code="annotation.spoken_range",
|
|
843
|
+
)
|
|
844
|
+
spoken_start = annotation.spoken_start
|
|
845
|
+
spoken_end = annotation.spoken_end
|
|
846
|
+
if (
|
|
847
|
+
spoken_start is not None
|
|
848
|
+
and spoken_end is not None
|
|
849
|
+
and not (0 <= spoken_start <= spoken_end <= len(text))
|
|
850
|
+
):
|
|
851
|
+
raise PlanValidationError(
|
|
852
|
+
"annotation spoken range is outside spoken text",
|
|
853
|
+
code="annotation.spoken_range",
|
|
854
|
+
)
|
|
855
|
+
for run in plan.languages:
|
|
856
|
+
if not (0 <= run.spoken_start <= run.spoken_end <= len(text)):
|
|
857
|
+
raise PlanValidationError(
|
|
858
|
+
"language range is outside spoken text", code="language.out_of_range"
|
|
859
|
+
)
|
|
860
|
+
for token in plan.tokens:
|
|
861
|
+
if not (0 <= token.spoken_start <= token.spoken_end <= len(text)):
|
|
862
|
+
raise PlanValidationError(
|
|
863
|
+
"token range is outside spoken text", code="token.out_of_range"
|
|
864
|
+
)
|
|
865
|
+
segment_ids = {segment.id for segment in plan.segments}
|
|
866
|
+
token_count = len(plan.tokens)
|
|
867
|
+
for segment in plan.segments:
|
|
868
|
+
if any(index < 0 or index >= token_count for index in segment.token_indices):
|
|
869
|
+
raise PlanValidationError(
|
|
870
|
+
"segment references unknown token", code="segment.unknown_token"
|
|
871
|
+
)
|
|
872
|
+
if any(annotation_id not in annotation_ids for annotation_id in segment.annotation_ids):
|
|
873
|
+
raise PlanValidationError(
|
|
874
|
+
"segment references unknown annotation", code="segment.unknown_annotation"
|
|
875
|
+
)
|
|
876
|
+
marker_ids = {marker.id for marker in plan.markers}
|
|
877
|
+
for marker in plan.markers:
|
|
878
|
+
if not (0 <= marker.spoken_position <= len(text)):
|
|
879
|
+
raise PlanValidationError(
|
|
880
|
+
"marker position is outside spoken text", code="marker.out_of_range"
|
|
881
|
+
)
|
|
882
|
+
for unit in plan.units:
|
|
883
|
+
if not all(segment_id in segment_ids for segment_id in unit.segment_ids):
|
|
884
|
+
raise PlanValidationError(
|
|
885
|
+
"unit references unknown segment", code="unit.unknown_segment"
|
|
886
|
+
)
|
|
887
|
+
if not all(marker_id in marker_ids for marker_id in unit.marker_ids):
|
|
888
|
+
raise PlanValidationError("unit references unknown marker", code="unit.unknown_marker")
|
|
889
|
+
if not (0 <= unit.spoken_start <= unit.spoken_end <= len(text)):
|
|
890
|
+
raise PlanValidationError("unit range is outside spoken text", code="unit.out_of_range")
|
|
891
|
+
unit_segments = [segment for segment in plan.segments if segment.id in unit.segment_ids]
|
|
892
|
+
marker_values = tuple(marker for marker in plan.markers if marker.id in unit.marker_ids)
|
|
893
|
+
if unit.content_hash != semantic_hash(
|
|
894
|
+
unit_hash_payload(_HashUnit(unit_segments, unit.marker_ids, marker_values))
|
|
895
|
+
):
|
|
896
|
+
raise PlanValidationError(
|
|
897
|
+
"unit content hash does not match semantics", code="unit.hash_mismatch"
|
|
898
|
+
)
|
|
899
|
+
if unit_segments and (
|
|
900
|
+
unit.spoken_start > unit_segments[0].spoken_start
|
|
901
|
+
or unit.spoken_end < unit_segments[-1].spoken_end
|
|
902
|
+
):
|
|
903
|
+
raise PlanValidationError("unit range does not contain its segments", code="unit.range")
|
|
904
|
+
flattened_segment_ids = [segment_id for unit in plan.units for segment_id in unit.segment_ids]
|
|
905
|
+
expected_segment_ids = [segment.id for segment in plan.segments]
|
|
906
|
+
if flattened_segment_ids != expected_segment_ids:
|
|
907
|
+
raise PlanValidationError(
|
|
908
|
+
"units must contain every segment exactly once in global order",
|
|
909
|
+
code="unit.segment_membership",
|
|
910
|
+
)
|
|
911
|
+
assigned_markers = [marker_id for unit in plan.units for marker_id in unit.marker_ids]
|
|
912
|
+
if len(assigned_markers) != len(set(assigned_markers)):
|
|
913
|
+
raise PlanValidationError(
|
|
914
|
+
"marker belongs to more than one unit", code="marker.unit_membership"
|
|
915
|
+
)
|
|
916
|
+
if plan.plan_id != semantic_hash(plan.semantic_dict()):
|
|
917
|
+
raise PlanValidationError(
|
|
918
|
+
"plan_id does not match semantic contents", code="plan_id.mismatch"
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
class _HashUnit:
|
|
923
|
+
def __init__(
|
|
924
|
+
self,
|
|
925
|
+
segments: list[PlanSegment],
|
|
926
|
+
marker_ids: tuple[str, ...],
|
|
927
|
+
marker_values: tuple[Marker, ...],
|
|
928
|
+
) -> None:
|
|
929
|
+
self.segments = segments
|
|
930
|
+
self.marker_ids = marker_ids
|
|
931
|
+
self.marker_values = marker_values
|