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/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