renderplan 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.
- renderplan/__init__.py +77 -0
- renderplan/__main__.py +3 -0
- renderplan/_toml.py +82 -0
- renderplan/_version.py +24 -0
- renderplan/cli.py +71 -0
- renderplan/codec.py +688 -0
- renderplan/errors.py +10 -0
- renderplan/identity.py +80 -0
- renderplan/model.py +937 -0
- renderplan/py.typed +0 -0
- renderplan/version.py +13 -0
- renderplan-0.1.0.dist-info/METADATA +160 -0
- renderplan-0.1.0.dist-info/RECORD +17 -0
- renderplan-0.1.0.dist-info/WHEEL +5 -0
- renderplan-0.1.0.dist-info/entry_points.txt +2 -0
- renderplan-0.1.0.dist-info/licenses/LICENSE +201 -0
- renderplan-0.1.0.dist-info/top_level.txt +1 -0
renderplan/model.py
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from .errors import ValidationError
|
|
8
|
+
from .identity import (
|
|
9
|
+
canonical_json_bytes,
|
|
10
|
+
require_nonempty,
|
|
11
|
+
require_sha256_id,
|
|
12
|
+
require_toml_value,
|
|
13
|
+
sha256_id,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
PROFILE_FORMAT = "renderprofile"
|
|
17
|
+
PLAN_FORMAT = "renderplan"
|
|
18
|
+
PREFLIGHT_FORMAT = "renderplan-preflight"
|
|
19
|
+
MANIFEST_FORMAT = "render-manifest"
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
SplitLevel = Literal["paragraph", "sentence", "clause"]
|
|
23
|
+
FallbackLevel = Literal["clause", "token"]
|
|
24
|
+
Guarantee = Literal["hard", "advisory"]
|
|
25
|
+
PreflightStatus = Literal["ok", "replan", "reject"]
|
|
26
|
+
CheckStatus = Literal["ok", "too_long", "error"]
|
|
27
|
+
|
|
28
|
+
_ALLOWED_TOKEN_FIELDS = frozenset({"span", "pos", "tag", "lemma", "morph"})
|
|
29
|
+
_ALLOWED_SPLITS = frozenset({"paragraph", "sentence", "clause"})
|
|
30
|
+
_ALLOWED_FALLBACKS = frozenset({"clause", "token"})
|
|
31
|
+
_ALLOWED_SEMANTIC_ENDS = frozenset({"none", "clause", "sentence", "paragraph", "voice_change"})
|
|
32
|
+
_ALLOWED_SPLIT_REASONS = frozenset(
|
|
33
|
+
{"policy", "capacity", "voice_change", "directive_change", "media"}
|
|
34
|
+
)
|
|
35
|
+
_ALLOWED_PLANNING_LEVELS = frozenset({"paragraph", "sentence", "clause", "token", "hard"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tuple_strings(values: tuple[str, ...], field_name: str) -> tuple[str, ...]:
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
for item in values:
|
|
41
|
+
require_nonempty(item, field_name)
|
|
42
|
+
if item in seen:
|
|
43
|
+
raise ValidationError(f"{field_name} contains duplicate {item!r}")
|
|
44
|
+
seen.add(item)
|
|
45
|
+
return values
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Producer:
|
|
50
|
+
name: str
|
|
51
|
+
version: str
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
require_nonempty(self.name, "producer.name")
|
|
55
|
+
require_nonempty(self.version, "producer.version")
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {"name": self.name, "version": self.version}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class CapacityConstraint:
|
|
63
|
+
metric: str
|
|
64
|
+
maximum: int
|
|
65
|
+
guarantee: Guarantee = "advisory"
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
require_nonempty(self.metric, "capacity.metric")
|
|
69
|
+
if (
|
|
70
|
+
isinstance(self.maximum, bool)
|
|
71
|
+
or not isinstance(self.maximum, int)
|
|
72
|
+
or self.maximum <= 0
|
|
73
|
+
):
|
|
74
|
+
raise ValidationError("capacity.maximum must be a positive integer")
|
|
75
|
+
if self.guarantee not in {"hard", "advisory"}:
|
|
76
|
+
raise ValidationError("capacity.guarantee must be 'hard' or 'advisory'")
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return {"metric": self.metric, "maximum": self.maximum, "guarantee": self.guarantee}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class RoleRequirements:
|
|
84
|
+
token_fields: tuple[str, ...] = ()
|
|
85
|
+
frontend: str = "renderer"
|
|
86
|
+
pronunciation: str = "renderer"
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
_tuple_strings(self.token_fields, "requirements.token_fields")
|
|
90
|
+
unknown = set(self.token_fields).difference(_ALLOWED_TOKEN_FIELDS)
|
|
91
|
+
if unknown:
|
|
92
|
+
raise ValidationError(f"unknown token fields: {', '.join(sorted(unknown))}")
|
|
93
|
+
require_nonempty(self.frontend, "requirements.frontend")
|
|
94
|
+
require_nonempty(self.pronunciation, "requirements.pronunciation")
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"token_fields": list(self.token_fields),
|
|
99
|
+
"frontend": self.frontend,
|
|
100
|
+
"pronunciation": self.pronunciation,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class RoleProfile:
|
|
106
|
+
language: str
|
|
107
|
+
binding: Mapping[str, Any]
|
|
108
|
+
requirements: RoleRequirements = field(default_factory=RoleRequirements)
|
|
109
|
+
planner_capacity: CapacityConstraint | None = None
|
|
110
|
+
renderer_capacity: CapacityConstraint | None = None
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
require_nonempty(self.language, "role.language")
|
|
114
|
+
if not self.binding:
|
|
115
|
+
raise ValidationError("role.binding must not be empty")
|
|
116
|
+
require_toml_value(self.binding, "role.binding")
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict[str, Any]:
|
|
119
|
+
data: dict[str, Any] = {
|
|
120
|
+
"language": self.language,
|
|
121
|
+
"binding": dict(self.binding),
|
|
122
|
+
"requirements": self.requirements.to_dict(),
|
|
123
|
+
}
|
|
124
|
+
capacity: dict[str, Any] = {}
|
|
125
|
+
if self.planner_capacity is not None:
|
|
126
|
+
capacity["planner"] = self.planner_capacity.to_dict()
|
|
127
|
+
if self.renderer_capacity is not None:
|
|
128
|
+
capacity["renderer"] = self.renderer_capacity.to_dict()
|
|
129
|
+
if capacity:
|
|
130
|
+
data["capacity"] = capacity
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class PlanningPolicy:
|
|
136
|
+
split: SplitLevel = "sentence"
|
|
137
|
+
capacity_fallback: tuple[FallbackLevel, ...] = ("clause", "token")
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if self.split not in _ALLOWED_SPLITS:
|
|
141
|
+
raise ValidationError(f"planning.split must be one of {sorted(_ALLOWED_SPLITS)}")
|
|
142
|
+
_tuple_strings(self.capacity_fallback, "planning.capacity_fallback")
|
|
143
|
+
unknown = set(self.capacity_fallback).difference(_ALLOWED_FALLBACKS)
|
|
144
|
+
if unknown:
|
|
145
|
+
raise ValidationError(f"unknown capacity fallback: {', '.join(sorted(unknown))}")
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> dict[str, Any]:
|
|
148
|
+
return {"split": self.split, "capacity_fallback": list(self.capacity_fallback)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class PausePolicy:
|
|
153
|
+
mode: Literal["manual", "none"] = "manual"
|
|
154
|
+
durations_ms: Mapping[str, int] = field(default_factory=dict)
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
if self.mode not in {"manual", "none"}:
|
|
158
|
+
raise ValidationError("pauses.mode must be manual or none")
|
|
159
|
+
for name, duration in self.durations_ms.items():
|
|
160
|
+
require_nonempty(name, "pause name")
|
|
161
|
+
if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0:
|
|
162
|
+
raise ValidationError(
|
|
163
|
+
f"pause {name!r} duration must be a non-negative integer ms"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict[str, Any]:
|
|
167
|
+
data: dict[str, Any] = {"mode": self.mode}
|
|
168
|
+
for key, value in sorted(self.durations_ms.items()):
|
|
169
|
+
data[key] = f"{value}ms"
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class RenderProfile:
|
|
175
|
+
roles: Mapping[str, RoleProfile]
|
|
176
|
+
default_role: str
|
|
177
|
+
planning: PlanningPolicy = field(default_factory=PlanningPolicy)
|
|
178
|
+
pauses: PausePolicy = field(default_factory=PausePolicy)
|
|
179
|
+
profile_id: str | None = None
|
|
180
|
+
producer: Producer | None = None
|
|
181
|
+
format: str = PROFILE_FORMAT
|
|
182
|
+
schema_version: int = SCHEMA_VERSION
|
|
183
|
+
|
|
184
|
+
def __post_init__(self) -> None:
|
|
185
|
+
if self.format != PROFILE_FORMAT or self.schema_version != SCHEMA_VERSION:
|
|
186
|
+
raise ValidationError("unsupported render profile format/schema")
|
|
187
|
+
if not self.roles:
|
|
188
|
+
raise ValidationError("render profile requires at least one role")
|
|
189
|
+
for role in self.roles:
|
|
190
|
+
require_nonempty(role, "role name")
|
|
191
|
+
require_nonempty(self.default_role, "default_role")
|
|
192
|
+
if self.default_role not in self.roles:
|
|
193
|
+
raise ValidationError("default_role must name one of roles")
|
|
194
|
+
if self.profile_id is not None:
|
|
195
|
+
require_sha256_id(self.profile_id, "profile_id")
|
|
196
|
+
if self.profile_id != self.computed_id():
|
|
197
|
+
raise ValidationError("profile_id does not match profile content")
|
|
198
|
+
|
|
199
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
200
|
+
return {
|
|
201
|
+
"format": self.format,
|
|
202
|
+
"schema_version": self.schema_version,
|
|
203
|
+
"roles": {name: role.to_dict() for name, role in sorted(self.roles.items())},
|
|
204
|
+
"default_role": self.default_role,
|
|
205
|
+
"planning": self.planning.to_dict(),
|
|
206
|
+
"pauses": self.pauses.to_dict(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def computed_id(self) -> str:
|
|
210
|
+
return sha256_id(self.identity_payload())
|
|
211
|
+
|
|
212
|
+
def with_identity(self) -> RenderProfile:
|
|
213
|
+
return replace(self, profile_id=self.computed_id())
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, slots=True)
|
|
217
|
+
class SourceSpan:
|
|
218
|
+
locator: str
|
|
219
|
+
start: int
|
|
220
|
+
end: int
|
|
221
|
+
|
|
222
|
+
def __post_init__(self) -> None:
|
|
223
|
+
require_nonempty(self.locator, "source.locator")
|
|
224
|
+
if any(isinstance(v, bool) or not isinstance(v, int) for v in (self.start, self.end)):
|
|
225
|
+
raise ValidationError("source start/end must be integers")
|
|
226
|
+
if self.start < 0 or self.end <= self.start:
|
|
227
|
+
raise ValidationError("source span must satisfy 0 <= start < end")
|
|
228
|
+
|
|
229
|
+
def to_dict(self) -> dict[str, Any]:
|
|
230
|
+
return {"locator": self.locator, "start": self.start, "end": self.end}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True, slots=True)
|
|
234
|
+
class SpeechControl:
|
|
235
|
+
start: int
|
|
236
|
+
end: int
|
|
237
|
+
name: str
|
|
238
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
239
|
+
|
|
240
|
+
def __post_init__(self) -> None:
|
|
241
|
+
if any(
|
|
242
|
+
isinstance(value, bool) or not isinstance(value, int)
|
|
243
|
+
for value in (self.start, self.end)
|
|
244
|
+
):
|
|
245
|
+
raise ValidationError("control start/end must be integers")
|
|
246
|
+
if self.start < 0 or self.end <= self.start:
|
|
247
|
+
raise ValidationError("control must satisfy 0 <= start < end")
|
|
248
|
+
require_nonempty(self.name, "control.name")
|
|
249
|
+
require_toml_value(self.params, "control.params")
|
|
250
|
+
|
|
251
|
+
def ordering_key(self) -> tuple[int, int, str, bytes]:
|
|
252
|
+
return (self.start, self.end, self.name, canonical_json_bytes(self.params))
|
|
253
|
+
|
|
254
|
+
def to_dict(self) -> dict[str, Any]:
|
|
255
|
+
return {
|
|
256
|
+
"start": self.start,
|
|
257
|
+
"end": self.end,
|
|
258
|
+
"name": self.name,
|
|
259
|
+
"params": dict(self.params),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class TokenBundle:
|
|
265
|
+
"""Dense token spans/pos/tag and sparse, token-indexed lemma/morph values."""
|
|
266
|
+
|
|
267
|
+
span: tuple[tuple[int, int], ...]
|
|
268
|
+
pos: tuple[str, ...] = ()
|
|
269
|
+
tag: tuple[str, ...] = ()
|
|
270
|
+
lemma: tuple[tuple[int, str], ...] = ()
|
|
271
|
+
morph: tuple[tuple[int, str], ...] = ()
|
|
272
|
+
|
|
273
|
+
def __post_init__(self) -> None:
|
|
274
|
+
previous_end = -1
|
|
275
|
+
for index, pair in enumerate(self.span):
|
|
276
|
+
if len(pair) != 2:
|
|
277
|
+
raise ValidationError(f"token.span[{index}] must contain start and end offsets")
|
|
278
|
+
start, end = pair
|
|
279
|
+
if any(isinstance(value, bool) or not isinstance(value, int) for value in pair):
|
|
280
|
+
raise ValidationError(f"token.span[{index}] must contain integer offsets")
|
|
281
|
+
if start < 0 or end <= start or start < previous_end:
|
|
282
|
+
raise ValidationError(f"token.span[{index}] is invalid or out of order")
|
|
283
|
+
previous_end = end
|
|
284
|
+
for field_name, dense_values in (("pos", self.pos), ("tag", self.tag)):
|
|
285
|
+
if dense_values and len(dense_values) != len(self.span):
|
|
286
|
+
raise ValidationError(f"token.{field_name} length must match token.span")
|
|
287
|
+
for field_name, sparse_values in (("lemma", self.lemma), ("morph", self.morph)):
|
|
288
|
+
previous_index = -1
|
|
289
|
+
for token_index, value in sparse_values:
|
|
290
|
+
if (
|
|
291
|
+
isinstance(token_index, bool)
|
|
292
|
+
or not isinstance(token_index, int)
|
|
293
|
+
or token_index < 0
|
|
294
|
+
or token_index >= len(self.span)
|
|
295
|
+
or token_index <= previous_index
|
|
296
|
+
):
|
|
297
|
+
raise ValidationError(
|
|
298
|
+
f"token.{field_name} has invalid or unordered token index {token_index}"
|
|
299
|
+
)
|
|
300
|
+
require_nonempty(value, f"token.{field_name} value")
|
|
301
|
+
previous_index = token_index
|
|
302
|
+
|
|
303
|
+
def validate_text(self, text: str) -> None:
|
|
304
|
+
for index, (_, end) in enumerate(self.span):
|
|
305
|
+
if end > len(text):
|
|
306
|
+
raise ValidationError(f"token.span[{index}] exceeds speech text length")
|
|
307
|
+
|
|
308
|
+
def field_is_complete(self, field_name: str) -> bool:
|
|
309
|
+
if not self.span:
|
|
310
|
+
return False
|
|
311
|
+
if field_name == "span":
|
|
312
|
+
return True
|
|
313
|
+
if field_name in {"pos", "tag"}:
|
|
314
|
+
dense_values = self.pos if field_name == "pos" else self.tag
|
|
315
|
+
return len(dense_values) == len(self.span) and all(
|
|
316
|
+
bool(value.strip()) for value in dense_values
|
|
317
|
+
)
|
|
318
|
+
if field_name in {"lemma", "morph"}:
|
|
319
|
+
sparse_values = self.lemma if field_name == "lemma" else self.morph
|
|
320
|
+
return tuple(index for index, _ in sparse_values) == tuple(range(len(self.span)))
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
def fields_present(self) -> frozenset[str]:
|
|
324
|
+
present = {"span"}
|
|
325
|
+
if self.pos:
|
|
326
|
+
present.add("pos")
|
|
327
|
+
if self.tag:
|
|
328
|
+
present.add("tag")
|
|
329
|
+
if self.lemma:
|
|
330
|
+
present.add("lemma")
|
|
331
|
+
if self.morph:
|
|
332
|
+
present.add("morph")
|
|
333
|
+
return frozenset(present)
|
|
334
|
+
|
|
335
|
+
def to_dict(self) -> dict[str, Any]:
|
|
336
|
+
data: dict[str, Any] = {"span": [list(pair) for pair in self.span]}
|
|
337
|
+
if self.pos:
|
|
338
|
+
data["pos"] = list(self.pos)
|
|
339
|
+
if self.tag:
|
|
340
|
+
data["tag"] = list(self.tag)
|
|
341
|
+
if self.lemma:
|
|
342
|
+
data["lemma"] = [[index, value] for index, value in self.lemma]
|
|
343
|
+
if self.morph:
|
|
344
|
+
data["morph"] = [[index, value] for index, value in self.morph]
|
|
345
|
+
return data
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True, slots=True)
|
|
349
|
+
class SpeechItem:
|
|
350
|
+
number: int
|
|
351
|
+
text: str
|
|
352
|
+
language: str
|
|
353
|
+
role: str
|
|
354
|
+
binding: Mapping[str, Any]
|
|
355
|
+
source: tuple[SourceSpan, ...]
|
|
356
|
+
split_reason: str = "policy"
|
|
357
|
+
planning_level: str = "sentence"
|
|
358
|
+
semantic_end: str = "none"
|
|
359
|
+
tokens: TokenBundle | None = None
|
|
360
|
+
controls: tuple[SpeechControl, ...] = ()
|
|
361
|
+
preflight_required: bool = True
|
|
362
|
+
segment_id: str | None = None
|
|
363
|
+
kind: str = "speech"
|
|
364
|
+
|
|
365
|
+
def __post_init__(self) -> None:
|
|
366
|
+
if (
|
|
367
|
+
isinstance(self.number, bool)
|
|
368
|
+
or not isinstance(self.number, int)
|
|
369
|
+
or self.number <= 0
|
|
370
|
+
):
|
|
371
|
+
raise ValidationError("speech.number must be a positive integer")
|
|
372
|
+
require_nonempty(self.text, "speech.text")
|
|
373
|
+
require_nonempty(self.language, "speech.language")
|
|
374
|
+
require_nonempty(self.role, "speech.role")
|
|
375
|
+
if not self.binding:
|
|
376
|
+
raise ValidationError("speech.binding must not be empty")
|
|
377
|
+
require_toml_value(self.binding, "speech.binding")
|
|
378
|
+
if not self.source:
|
|
379
|
+
raise ValidationError("speech.source must not be empty")
|
|
380
|
+
if self.split_reason not in _ALLOWED_SPLIT_REASONS:
|
|
381
|
+
raise ValidationError(f"unsupported speech.split_reason {self.split_reason!r}")
|
|
382
|
+
if self.planning_level not in _ALLOWED_PLANNING_LEVELS:
|
|
383
|
+
raise ValidationError(f"unsupported speech.planning_level {self.planning_level!r}")
|
|
384
|
+
if self.semantic_end not in _ALLOWED_SEMANTIC_ENDS:
|
|
385
|
+
raise ValidationError(f"unsupported speech.semantic_end {self.semantic_end!r}")
|
|
386
|
+
if self.tokens is not None:
|
|
387
|
+
self.tokens.validate_text(self.text)
|
|
388
|
+
for index, control in enumerate(self.controls):
|
|
389
|
+
if not isinstance(control, SpeechControl):
|
|
390
|
+
raise ValidationError(f"speech.controls[{index}] must be a SpeechControl")
|
|
391
|
+
if control.end > len(self.text):
|
|
392
|
+
raise ValidationError(f"speech.controls[{index}] exceeds speech text length")
|
|
393
|
+
if (
|
|
394
|
+
tuple(sorted(self.controls, key=lambda control: control.ordering_key()))
|
|
395
|
+
!= self.controls
|
|
396
|
+
):
|
|
397
|
+
raise ValidationError(
|
|
398
|
+
"speech.controls must be ordered by start, end, name, and params"
|
|
399
|
+
)
|
|
400
|
+
if self.segment_id is not None:
|
|
401
|
+
require_sha256_id(self.segment_id, "speech.segment_id")
|
|
402
|
+
if self.segment_id != self.computed_id():
|
|
403
|
+
raise ValidationError(f"speech {self.number} segment_id does not match content")
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def wav(self) -> str:
|
|
407
|
+
return f"{self.number:06d}.wav"
|
|
408
|
+
|
|
409
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
410
|
+
"""Identity of the exact renderer request, excluding plan provenance and numbering."""
|
|
411
|
+
data: dict[str, Any] = {
|
|
412
|
+
"text": self.text,
|
|
413
|
+
"language": self.language,
|
|
414
|
+
"role": self.role,
|
|
415
|
+
"binding": dict(self.binding),
|
|
416
|
+
"controls": [control.to_dict() for control in self.controls],
|
|
417
|
+
}
|
|
418
|
+
if self.tokens is not None:
|
|
419
|
+
data["tokens"] = self.tokens.to_dict()
|
|
420
|
+
return data
|
|
421
|
+
|
|
422
|
+
def plan_payload(self) -> dict[str, Any]:
|
|
423
|
+
return {
|
|
424
|
+
"kind": self.kind,
|
|
425
|
+
"number": self.number,
|
|
426
|
+
"segment_id": self.segment_id or self.computed_id(),
|
|
427
|
+
"wav": self.wav,
|
|
428
|
+
"source": [item.to_dict() for item in self.source],
|
|
429
|
+
"split_reason": self.split_reason,
|
|
430
|
+
"planning_level": self.planning_level,
|
|
431
|
+
"semantic_end": self.semantic_end,
|
|
432
|
+
"preflight_required": self.preflight_required,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
def computed_id(self) -> str:
|
|
436
|
+
return sha256_id(self.identity_payload())
|
|
437
|
+
|
|
438
|
+
def with_identity(self) -> SpeechItem:
|
|
439
|
+
return replace(self, segment_id=self.computed_id())
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass(frozen=True, slots=True)
|
|
443
|
+
class PauseItem:
|
|
444
|
+
duration_ms: int
|
|
445
|
+
reason: str
|
|
446
|
+
source: tuple[SourceSpan, ...] = ()
|
|
447
|
+
pause_id: str | None = None
|
|
448
|
+
kind: str = "pause"
|
|
449
|
+
|
|
450
|
+
def __post_init__(self) -> None:
|
|
451
|
+
if (
|
|
452
|
+
isinstance(self.duration_ms, bool)
|
|
453
|
+
or not isinstance(self.duration_ms, int)
|
|
454
|
+
or self.duration_ms < 0
|
|
455
|
+
):
|
|
456
|
+
raise ValidationError("pause.duration_ms must be a non-negative integer")
|
|
457
|
+
require_nonempty(self.reason, "pause.reason")
|
|
458
|
+
if self.pause_id is not None:
|
|
459
|
+
require_sha256_id(self.pause_id, "pause.pause_id")
|
|
460
|
+
if self.pause_id != self.computed_id():
|
|
461
|
+
raise ValidationError("pause_id does not match pause content")
|
|
462
|
+
|
|
463
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
464
|
+
return {
|
|
465
|
+
"kind": self.kind,
|
|
466
|
+
"duration_ms": self.duration_ms,
|
|
467
|
+
"reason": self.reason,
|
|
468
|
+
"source": [item.to_dict() for item in self.source],
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
def computed_id(self) -> str:
|
|
472
|
+
return sha256_id(self.identity_payload())
|
|
473
|
+
|
|
474
|
+
def with_identity(self) -> PauseItem:
|
|
475
|
+
return replace(self, pause_id=self.computed_id())
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@dataclass(frozen=True, slots=True)
|
|
479
|
+
class AudioItem:
|
|
480
|
+
src: str
|
|
481
|
+
source: tuple[SourceSpan, ...]
|
|
482
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
483
|
+
audio_id: str | None = None
|
|
484
|
+
kind: str = "audio"
|
|
485
|
+
|
|
486
|
+
def __post_init__(self) -> None:
|
|
487
|
+
require_nonempty(self.src, "audio.src")
|
|
488
|
+
if not self.source:
|
|
489
|
+
raise ValidationError("audio.source must not be empty")
|
|
490
|
+
require_toml_value(self.options, "audio.options")
|
|
491
|
+
if self.audio_id is not None:
|
|
492
|
+
require_sha256_id(self.audio_id, "audio.audio_id")
|
|
493
|
+
if self.audio_id != self.computed_id():
|
|
494
|
+
raise ValidationError("audio_id does not match audio content")
|
|
495
|
+
|
|
496
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
497
|
+
return {
|
|
498
|
+
"kind": self.kind,
|
|
499
|
+
"src": self.src,
|
|
500
|
+
"source": [item.to_dict() for item in self.source],
|
|
501
|
+
"options": dict(self.options),
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
def computed_id(self) -> str:
|
|
505
|
+
return sha256_id(self.identity_payload())
|
|
506
|
+
|
|
507
|
+
def with_identity(self) -> AudioItem:
|
|
508
|
+
return replace(self, audio_id=self.computed_id())
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
FlowItem = SpeechItem | PauseItem | AudioItem
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass(frozen=True, slots=True)
|
|
515
|
+
class RenderPlan:
|
|
516
|
+
source_plan_id: str
|
|
517
|
+
render_profile_id: str
|
|
518
|
+
flow: tuple[FlowItem, ...]
|
|
519
|
+
plan_id: str | None = None
|
|
520
|
+
producer: Producer | None = None
|
|
521
|
+
format: str = PLAN_FORMAT
|
|
522
|
+
schema_version: int = SCHEMA_VERSION
|
|
523
|
+
|
|
524
|
+
def __post_init__(self) -> None:
|
|
525
|
+
if self.format != PLAN_FORMAT or self.schema_version != SCHEMA_VERSION:
|
|
526
|
+
raise ValidationError("unsupported render plan format/schema")
|
|
527
|
+
require_sha256_id(self.source_plan_id, "source_plan_id")
|
|
528
|
+
require_sha256_id(self.render_profile_id, "render_profile_id")
|
|
529
|
+
if not self.flow:
|
|
530
|
+
raise ValidationError("render plan flow must not be empty")
|
|
531
|
+
expected_number = 1
|
|
532
|
+
for index, item in enumerate(self.flow):
|
|
533
|
+
if isinstance(item, SpeechItem):
|
|
534
|
+
if item.number != expected_number:
|
|
535
|
+
raise ValidationError(
|
|
536
|
+
f"speech numbers must be contiguous from 1; flow[{index}] is {item.number}, expected {expected_number}"
|
|
537
|
+
)
|
|
538
|
+
if item.segment_id is None and self.plan_id is not None:
|
|
539
|
+
raise ValidationError(f"speech {item.number} must have segment_id")
|
|
540
|
+
expected_number += 1
|
|
541
|
+
elif isinstance(item, PauseItem):
|
|
542
|
+
if item.pause_id is None and self.plan_id is not None:
|
|
543
|
+
raise ValidationError(f"flow[{index}] pause must have pause_id")
|
|
544
|
+
elif isinstance(item, AudioItem):
|
|
545
|
+
if item.audio_id is None and self.plan_id is not None:
|
|
546
|
+
raise ValidationError(f"flow[{index}] audio must have audio_id")
|
|
547
|
+
else:
|
|
548
|
+
raise ValidationError(f"flow[{index}] has an unsupported item type")
|
|
549
|
+
if self.plan_id is not None:
|
|
550
|
+
require_sha256_id(self.plan_id, "plan_id")
|
|
551
|
+
if self.plan_id != self.computed_id():
|
|
552
|
+
raise ValidationError("plan_id does not match render plan content")
|
|
553
|
+
|
|
554
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
555
|
+
flow: list[dict[str, Any]] = []
|
|
556
|
+
for item in self.flow:
|
|
557
|
+
if isinstance(item, SpeechItem):
|
|
558
|
+
payload = item.plan_payload()
|
|
559
|
+
elif isinstance(item, PauseItem):
|
|
560
|
+
payload = {
|
|
561
|
+
**item.identity_payload(),
|
|
562
|
+
"pause_id": item.pause_id or item.computed_id(),
|
|
563
|
+
}
|
|
564
|
+
else:
|
|
565
|
+
payload = {
|
|
566
|
+
**item.identity_payload(),
|
|
567
|
+
"audio_id": item.audio_id or item.computed_id(),
|
|
568
|
+
}
|
|
569
|
+
flow.append(payload)
|
|
570
|
+
return {
|
|
571
|
+
"format": self.format,
|
|
572
|
+
"schema_version": self.schema_version,
|
|
573
|
+
"source_plan_id": self.source_plan_id,
|
|
574
|
+
"render_profile_id": self.render_profile_id,
|
|
575
|
+
"flow": flow,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
def validate_against_profile(self, profile: RenderProfile) -> None:
|
|
579
|
+
expected_profile_id = profile.profile_id or profile.computed_id()
|
|
580
|
+
if self.render_profile_id != expected_profile_id:
|
|
581
|
+
raise ValidationError("render plan render_profile_id does not match profile")
|
|
582
|
+
for speech in self.speech:
|
|
583
|
+
role = profile.roles.get(speech.role)
|
|
584
|
+
if role is None:
|
|
585
|
+
raise ValidationError(
|
|
586
|
+
f"speech {speech.number} names unknown profile role {speech.role!r}"
|
|
587
|
+
)
|
|
588
|
+
if dict(speech.binding) != dict(role.binding):
|
|
589
|
+
raise ValidationError(
|
|
590
|
+
f"speech {speech.number} binding does not match role {speech.role!r}"
|
|
591
|
+
)
|
|
592
|
+
speech_language = speech.language.lower().replace("_", "-")
|
|
593
|
+
role_language = role.language.lower().replace("_", "-")
|
|
594
|
+
if not (
|
|
595
|
+
speech_language == role_language
|
|
596
|
+
or speech_language.startswith(role_language + "-")
|
|
597
|
+
or role_language.startswith(speech_language + "-")
|
|
598
|
+
):
|
|
599
|
+
raise ValidationError(
|
|
600
|
+
f"speech {speech.number} language {speech.language!r} is incompatible with role language {role.language!r}"
|
|
601
|
+
)
|
|
602
|
+
required_fields = role.requirements.token_fields
|
|
603
|
+
if required_fields and speech.tokens is None:
|
|
604
|
+
raise ValidationError(
|
|
605
|
+
f"speech {speech.number} is missing required token fields"
|
|
606
|
+
)
|
|
607
|
+
if speech.tokens is not None:
|
|
608
|
+
missing = [
|
|
609
|
+
field_name
|
|
610
|
+
for field_name in required_fields
|
|
611
|
+
if not speech.tokens.field_is_complete(field_name)
|
|
612
|
+
]
|
|
613
|
+
if missing:
|
|
614
|
+
raise ValidationError(
|
|
615
|
+
f"speech {speech.number} has incomplete required token fields: {', '.join(missing)}"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def computed_id(self) -> str:
|
|
619
|
+
return sha256_id(self.identity_payload())
|
|
620
|
+
|
|
621
|
+
def with_identity(self) -> RenderPlan:
|
|
622
|
+
identified: list[FlowItem] = []
|
|
623
|
+
for item in self.flow:
|
|
624
|
+
identified.append(item.with_identity())
|
|
625
|
+
provisional = replace(self, flow=tuple(identified), plan_id=None)
|
|
626
|
+
return replace(provisional, plan_id=provisional.computed_id())
|
|
627
|
+
|
|
628
|
+
@property
|
|
629
|
+
def speech(self) -> tuple[SpeechItem, ...]:
|
|
630
|
+
return tuple(item for item in self.flow if isinstance(item, SpeechItem))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@dataclass(frozen=True, slots=True)
|
|
634
|
+
class PreflightCheck:
|
|
635
|
+
segment_number: int
|
|
636
|
+
segment_id: str
|
|
637
|
+
status: CheckStatus
|
|
638
|
+
metric: str
|
|
639
|
+
measured: int | None
|
|
640
|
+
maximum: int | None
|
|
641
|
+
source: str
|
|
642
|
+
detail: str | None = None
|
|
643
|
+
|
|
644
|
+
def __post_init__(self) -> None:
|
|
645
|
+
if self.segment_number <= 0:
|
|
646
|
+
raise ValidationError("preflight check segment_number must be positive")
|
|
647
|
+
require_sha256_id(self.segment_id, "preflight.segment_id")
|
|
648
|
+
if self.status not in {"ok", "too_long", "error"}:
|
|
649
|
+
raise ValidationError("invalid preflight check status")
|
|
650
|
+
require_nonempty(self.metric, "preflight.metric")
|
|
651
|
+
require_nonempty(self.source, "preflight.source")
|
|
652
|
+
for field_name, value in (("measured", self.measured), ("maximum", self.maximum)):
|
|
653
|
+
if value is not None and (
|
|
654
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
655
|
+
):
|
|
656
|
+
raise ValidationError(
|
|
657
|
+
f"preflight.{field_name} must be a non-negative integer or null"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
def to_dict(self) -> dict[str, Any]:
|
|
661
|
+
data: dict[str, Any] = {
|
|
662
|
+
"segment_number": self.segment_number,
|
|
663
|
+
"segment_id": self.segment_id,
|
|
664
|
+
"status": self.status,
|
|
665
|
+
"metric": self.metric,
|
|
666
|
+
"source": self.source,
|
|
667
|
+
}
|
|
668
|
+
if self.measured is not None:
|
|
669
|
+
data["measured"] = self.measured
|
|
670
|
+
if self.maximum is not None:
|
|
671
|
+
data["maximum"] = self.maximum
|
|
672
|
+
if self.detail is not None:
|
|
673
|
+
data["detail"] = self.detail
|
|
674
|
+
return data
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@dataclass(frozen=True, slots=True)
|
|
678
|
+
class ReplanRequest:
|
|
679
|
+
segment_number: int
|
|
680
|
+
segment_id: str
|
|
681
|
+
source: tuple[SourceSpan, ...]
|
|
682
|
+
current_level: str
|
|
683
|
+
action: str = "split"
|
|
684
|
+
reason: str = "capacity"
|
|
685
|
+
metric: str | None = None
|
|
686
|
+
measured: int | None = None
|
|
687
|
+
maximum: int | None = None
|
|
688
|
+
|
|
689
|
+
def __post_init__(self) -> None:
|
|
690
|
+
if self.segment_number <= 0:
|
|
691
|
+
raise ValidationError("replan segment_number must be positive")
|
|
692
|
+
require_sha256_id(self.segment_id, "replan.segment_id")
|
|
693
|
+
if not self.source:
|
|
694
|
+
raise ValidationError("replan.source must not be empty")
|
|
695
|
+
if self.current_level not in _ALLOWED_PLANNING_LEVELS:
|
|
696
|
+
raise ValidationError("replan.current_level is invalid")
|
|
697
|
+
if self.action != "split":
|
|
698
|
+
raise ValidationError("MVP replan.action must be 'split'")
|
|
699
|
+
require_nonempty(self.reason, "replan.reason")
|
|
700
|
+
if self.reason != "capacity":
|
|
701
|
+
raise ValidationError("replan.reason must be 'capacity'")
|
|
702
|
+
if self.metric is not None:
|
|
703
|
+
require_nonempty(self.metric, "replan.metric")
|
|
704
|
+
for field_name, value in (("measured", self.measured), ("maximum", self.maximum)):
|
|
705
|
+
if value is not None and (
|
|
706
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
707
|
+
):
|
|
708
|
+
raise ValidationError(
|
|
709
|
+
f"replan.{field_name} must be a non-negative integer or null"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def to_dict(self) -> dict[str, Any]:
|
|
713
|
+
data: dict[str, Any] = {
|
|
714
|
+
"segment_number": self.segment_number,
|
|
715
|
+
"segment_id": self.segment_id,
|
|
716
|
+
"source": [item.to_dict() for item in self.source],
|
|
717
|
+
"current_level": self.current_level,
|
|
718
|
+
"action": self.action,
|
|
719
|
+
"reason": self.reason,
|
|
720
|
+
}
|
|
721
|
+
if self.metric is not None:
|
|
722
|
+
data["metric"] = self.metric
|
|
723
|
+
if self.measured is not None:
|
|
724
|
+
data["measured"] = self.measured
|
|
725
|
+
if self.maximum is not None:
|
|
726
|
+
data["maximum"] = self.maximum
|
|
727
|
+
return data
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@dataclass(frozen=True, slots=True)
|
|
731
|
+
class PreflightReport:
|
|
732
|
+
render_plan_id: str
|
|
733
|
+
source_plan_id: str
|
|
734
|
+
render_profile_id: str
|
|
735
|
+
status: PreflightStatus
|
|
736
|
+
checks: tuple[PreflightCheck, ...]
|
|
737
|
+
replan: tuple[ReplanRequest, ...] = ()
|
|
738
|
+
producer: Producer | None = None
|
|
739
|
+
report_id: str | None = None
|
|
740
|
+
format: str = PREFLIGHT_FORMAT
|
|
741
|
+
schema_version: int = SCHEMA_VERSION
|
|
742
|
+
|
|
743
|
+
def __post_init__(self) -> None:
|
|
744
|
+
if self.format != PREFLIGHT_FORMAT or self.schema_version != SCHEMA_VERSION:
|
|
745
|
+
raise ValidationError("unsupported preflight format/schema")
|
|
746
|
+
require_sha256_id(self.render_plan_id, "render_plan_id")
|
|
747
|
+
require_sha256_id(self.source_plan_id, "source_plan_id")
|
|
748
|
+
require_sha256_id(self.render_profile_id, "render_profile_id")
|
|
749
|
+
if self.status not in {"ok", "replan", "reject"}:
|
|
750
|
+
raise ValidationError("preflight.status must be ok, replan, or reject")
|
|
751
|
+
if self.status == "reject" and self.replan:
|
|
752
|
+
raise ValidationError("reject preflight cannot contain replan requests")
|
|
753
|
+
check_keys = {(item.segment_number, item.segment_id) for item in self.checks}
|
|
754
|
+
if len(check_keys) != len(self.checks):
|
|
755
|
+
raise ValidationError("preflight checks contain duplicate segments")
|
|
756
|
+
for request in self.replan:
|
|
757
|
+
if (request.segment_number, request.segment_id) not in check_keys:
|
|
758
|
+
raise ValidationError("replan request must refer to a checked segment")
|
|
759
|
+
if self.status == "ok" and (
|
|
760
|
+
self.replan or any(item.status != "ok" for item in self.checks)
|
|
761
|
+
):
|
|
762
|
+
raise ValidationError(
|
|
763
|
+
"ok preflight cannot contain failed checks or replan requests"
|
|
764
|
+
)
|
|
765
|
+
if self.status == "replan" and not self.replan:
|
|
766
|
+
raise ValidationError("replan preflight requires at least one replan request")
|
|
767
|
+
if self.report_id is not None:
|
|
768
|
+
require_sha256_id(self.report_id, "report_id")
|
|
769
|
+
if self.report_id != self.computed_id():
|
|
770
|
+
raise ValidationError("report_id does not match preflight content")
|
|
771
|
+
|
|
772
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
773
|
+
return {
|
|
774
|
+
"format": self.format,
|
|
775
|
+
"schema_version": self.schema_version,
|
|
776
|
+
"render_plan_id": self.render_plan_id,
|
|
777
|
+
"source_plan_id": self.source_plan_id,
|
|
778
|
+
"render_profile_id": self.render_profile_id,
|
|
779
|
+
"status": self.status,
|
|
780
|
+
"checks": [item.to_dict() for item in self.checks],
|
|
781
|
+
"replan": [item.to_dict() for item in self.replan],
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
def computed_id(self) -> str:
|
|
785
|
+
return sha256_id(self.identity_payload())
|
|
786
|
+
|
|
787
|
+
def with_identity(self) -> PreflightReport:
|
|
788
|
+
return replace(self, report_id=self.computed_id())
|
|
789
|
+
|
|
790
|
+
def validate_against(self, plan: RenderPlan) -> None:
|
|
791
|
+
expected_plan_id = plan.plan_id or plan.computed_id()
|
|
792
|
+
if self.render_plan_id != expected_plan_id:
|
|
793
|
+
raise ValidationError("preflight render_plan_id does not match plan")
|
|
794
|
+
if self.source_plan_id != plan.source_plan_id:
|
|
795
|
+
raise ValidationError("preflight source_plan_id does not match plan")
|
|
796
|
+
if self.render_profile_id != plan.render_profile_id:
|
|
797
|
+
raise ValidationError("preflight render_profile_id does not match plan")
|
|
798
|
+
|
|
799
|
+
occurrences = {
|
|
800
|
+
(speech.number, speech.segment_id or speech.computed_id()): speech
|
|
801
|
+
for speech in plan.speech
|
|
802
|
+
}
|
|
803
|
+
checks = {(check.segment_number, check.segment_id): check for check in self.checks}
|
|
804
|
+
for key in checks:
|
|
805
|
+
if key not in occurrences:
|
|
806
|
+
raise ValidationError(
|
|
807
|
+
f"preflight check refers to unknown speech occurrence {key}"
|
|
808
|
+
)
|
|
809
|
+
if not occurrences[key].preflight_required:
|
|
810
|
+
raise ValidationError(
|
|
811
|
+
f"preflight check refers to speech occurrence not requiring preflight {key}"
|
|
812
|
+
)
|
|
813
|
+
if self.status == "ok":
|
|
814
|
+
missing = [
|
|
815
|
+
key
|
|
816
|
+
for key, speech in occurrences.items()
|
|
817
|
+
if speech.preflight_required and key not in checks
|
|
818
|
+
]
|
|
819
|
+
if missing:
|
|
820
|
+
raise ValidationError(
|
|
821
|
+
f"ok preflight is missing required speech checks: {missing}"
|
|
822
|
+
)
|
|
823
|
+
for request in self.replan:
|
|
824
|
+
key = (request.segment_number, request.segment_id)
|
|
825
|
+
speech = occurrences.get(key)
|
|
826
|
+
matched_check = checks.get(key)
|
|
827
|
+
if matched_check is None or matched_check.status != "too_long":
|
|
828
|
+
raise ValidationError(
|
|
829
|
+
f"replan request {key} must refer to a failed too_long check"
|
|
830
|
+
)
|
|
831
|
+
if speech is None:
|
|
832
|
+
raise ValidationError(
|
|
833
|
+
f"replan request refers to unknown speech occurrence {key}"
|
|
834
|
+
)
|
|
835
|
+
if request.source != speech.source:
|
|
836
|
+
raise ValidationError(
|
|
837
|
+
f"replan request {key} source does not match speech source coverage"
|
|
838
|
+
)
|
|
839
|
+
if request.current_level != speech.planning_level:
|
|
840
|
+
raise ValidationError(
|
|
841
|
+
f"replan request {key} current_level does not match speech planning_level"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
@dataclass(frozen=True, slots=True)
|
|
846
|
+
class SpeechArtifact:
|
|
847
|
+
segment_number: int
|
|
848
|
+
segment_id: str
|
|
849
|
+
wav: str
|
|
850
|
+
sha256: str
|
|
851
|
+
frames: int
|
|
852
|
+
sample_rate: int
|
|
853
|
+
channels: int
|
|
854
|
+
|
|
855
|
+
def __post_init__(self) -> None:
|
|
856
|
+
if self.segment_number <= 0:
|
|
857
|
+
raise ValidationError("artifact.segment_number must be positive")
|
|
858
|
+
require_sha256_id(self.segment_id, "artifact.segment_id")
|
|
859
|
+
expected = f"{self.segment_number:06d}.wav"
|
|
860
|
+
if self.wav != expected:
|
|
861
|
+
raise ValidationError(f"artifact.wav must be {expected!r}")
|
|
862
|
+
require_sha256_id(self.sha256, "artifact.sha256")
|
|
863
|
+
for field_name, value in (
|
|
864
|
+
("frames", self.frames),
|
|
865
|
+
("sample_rate", self.sample_rate),
|
|
866
|
+
("channels", self.channels),
|
|
867
|
+
):
|
|
868
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
869
|
+
raise ValidationError(f"artifact.{field_name} must be a positive integer")
|
|
870
|
+
|
|
871
|
+
def to_dict(self) -> dict[str, Any]:
|
|
872
|
+
return {
|
|
873
|
+
"segment_number": self.segment_number,
|
|
874
|
+
"segment_id": self.segment_id,
|
|
875
|
+
"wav": self.wav,
|
|
876
|
+
"sha256": self.sha256,
|
|
877
|
+
"frames": self.frames,
|
|
878
|
+
"sample_rate": self.sample_rate,
|
|
879
|
+
"channels": self.channels,
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@dataclass(frozen=True, slots=True)
|
|
884
|
+
class RenderManifest:
|
|
885
|
+
render_plan_id: str
|
|
886
|
+
speech: tuple[SpeechArtifact, ...]
|
|
887
|
+
complete: bool
|
|
888
|
+
producer: Producer | None = None
|
|
889
|
+
manifest_id: str | None = None
|
|
890
|
+
format: str = MANIFEST_FORMAT
|
|
891
|
+
schema_version: int = SCHEMA_VERSION
|
|
892
|
+
|
|
893
|
+
def __post_init__(self) -> None:
|
|
894
|
+
if self.format != MANIFEST_FORMAT or self.schema_version != SCHEMA_VERSION:
|
|
895
|
+
raise ValidationError("unsupported render manifest format/schema")
|
|
896
|
+
require_sha256_id(self.render_plan_id, "render_plan_id")
|
|
897
|
+
occurrences = [
|
|
898
|
+
(artifact.segment_number, artifact.segment_id) for artifact in self.speech
|
|
899
|
+
]
|
|
900
|
+
if len(set(occurrences)) != len(occurrences):
|
|
901
|
+
raise ValidationError("manifest speech contains duplicate occurrences")
|
|
902
|
+
if self.manifest_id is not None:
|
|
903
|
+
require_sha256_id(self.manifest_id, "manifest_id")
|
|
904
|
+
if self.manifest_id != self.computed_id():
|
|
905
|
+
raise ValidationError("manifest_id does not match manifest content")
|
|
906
|
+
|
|
907
|
+
def identity_payload(self) -> dict[str, Any]:
|
|
908
|
+
return {
|
|
909
|
+
"format": self.format,
|
|
910
|
+
"schema_version": self.schema_version,
|
|
911
|
+
"render_plan_id": self.render_plan_id,
|
|
912
|
+
"complete": self.complete,
|
|
913
|
+
"speech": [item.to_dict() for item in self.speech],
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
def computed_id(self) -> str:
|
|
917
|
+
return sha256_id(self.identity_payload())
|
|
918
|
+
|
|
919
|
+
def with_identity(self) -> RenderManifest:
|
|
920
|
+
return replace(self, manifest_id=self.computed_id())
|
|
921
|
+
|
|
922
|
+
def validate_against(self, plan: RenderPlan) -> None:
|
|
923
|
+
expected_plan_id = plan.plan_id or plan.computed_id()
|
|
924
|
+
if self.render_plan_id != expected_plan_id:
|
|
925
|
+
raise ValidationError("manifest does not belong to this render plan")
|
|
926
|
+
planned = {(item.number, item.segment_id or item.computed_id()) for item in plan.speech}
|
|
927
|
+
included = {(item.segment_number, item.segment_id) for item in self.speech}
|
|
928
|
+
extra = included - planned
|
|
929
|
+
if extra:
|
|
930
|
+
raise ValidationError(
|
|
931
|
+
f"manifest contains speech occurrences not in plan: {sorted(extra)}"
|
|
932
|
+
)
|
|
933
|
+
missing = planned - included
|
|
934
|
+
if self.complete and missing:
|
|
935
|
+
raise ValidationError(
|
|
936
|
+
f"complete manifest is missing speech occurrences: {sorted(missing)}"
|
|
937
|
+
)
|