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/parsers.py ADDED
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from .config import PlannerConfig, parse_duration
8
+ from .exceptions import ConfigurationError, PlanFormatError, PlanningError
9
+ from .model import AnnotationSpan, BoundaryEvent, Marker
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ParsedDocument:
14
+ source_text: str
15
+ structural_text: str
16
+ annotations: tuple[AnnotationSpan, ...] = ()
17
+ boundaries: tuple[BoundaryEvent, ...] = ()
18
+ markers: tuple[Marker, ...] = ()
19
+ header: dict[str, Any] = field(default_factory=dict)
20
+ metadata: dict[str, Any] = field(default_factory=dict)
21
+ warnings: tuple[str, ...] = ()
22
+
23
+
24
+ class PlainDocumentParser:
25
+ def parse(self, text: str, config: PlannerConfig) -> ParsedDocument:
26
+ boundaries = tuple(
27
+ BoundaryEvent(
28
+ id=f"boundary-{index:06d}",
29
+ position=match.start(),
30
+ kind="paragraph",
31
+ origin="plain",
32
+ strength="paragraph",
33
+ )
34
+ for index, match in enumerate(re.finditer(r"\n\s*\n", text))
35
+ )
36
+ return ParsedDocument(text, text, boundaries=boundaries)
37
+
38
+
39
+ class SSMDDocumentParser:
40
+ def parse(self, text: str, config: PlannerConfig) -> ParsedDocument:
41
+ try:
42
+ import ssmd
43
+ except ImportError as exc:
44
+ raise PlanningError("SSMD input requires the ssmd package") from exc
45
+
46
+ warnings: list[str] = []
47
+ if config.ssmd.parse_header:
48
+ self._validate_header(text, config, warnings)
49
+ try:
50
+ parsed = ssmd.parse_structure(
51
+ text,
52
+ default_lang=config.language,
53
+ parse_yaml_header=config.ssmd.parse_header,
54
+ )
55
+ except (ImportError, OSError) as exc:
56
+ raise PlanningError("SSMD parsing failed") from exc
57
+ except (TypeError, ValueError) as exc:
58
+ raise PlanFormatError(str(exc), code="header.invalid") from exc
59
+
60
+ structural = str(parsed.clean_text)
61
+ annotations = tuple(
62
+ AnnotationSpan(
63
+ id=f"annotation-{i:06d}",
64
+ kind=str(getattr(item, "kind", "annotation")),
65
+ attrs={str(k): _plain_value(v) for k, v in item.attrs.items()},
66
+ structural_start=int(item.char_start),
67
+ structural_end=int(item.char_end),
68
+ )
69
+ for i, item in enumerate(parsed.annotations)
70
+ if int(item.char_end) > int(item.char_start)
71
+ )
72
+ boundaries: list[BoundaryEvent] = []
73
+ markers: list[Marker] = []
74
+ warnings.extend(str(x) for x in parsed.warnings)
75
+ for event in parsed.events:
76
+ attrs = {str(k): _plain_value(v) for k, v in event.attrs.items()}
77
+ kind = str(event.kind)
78
+ position = int(getattr(event, "pos", getattr(event, "position", 0)))
79
+ event_anchor = str(getattr(event, "anchor", attrs.get("anchor", "after")))
80
+ if kind == "mark":
81
+ name = attrs.get("name") or attrs.get("marker") or "marker"
82
+ markers.append(Marker(f"marker-{len(markers):06d}", name, position, attrs))
83
+ elif kind == "break":
84
+ seconds = _duration(attrs, config, parsed.header)
85
+ boundaries.append(
86
+ BoundaryEvent(
87
+ id=f"boundary-{len(boundaries):06d}",
88
+ position=position,
89
+ kind="explicit",
90
+ seconds=seconds,
91
+ origin="ssmd",
92
+ strength=attrs.get("strength"),
93
+ attrs={
94
+ **attrs,
95
+ "anchor": event_anchor,
96
+ "pause_origin": _duration_origin(attrs, config, parsed.header),
97
+ },
98
+ )
99
+ )
100
+ elif kind == "paragraph":
101
+ boundaries.append(
102
+ BoundaryEvent(
103
+ id=f"boundary-{len(boundaries):06d}",
104
+ position=position,
105
+ kind="paragraph",
106
+ seconds=_duration(attrs, config, parsed.header),
107
+ origin="ssmd",
108
+ strength=attrs.get("strength", "paragraph"),
109
+ attrs={
110
+ **attrs,
111
+ "anchor": event_anchor,
112
+ "pause_origin": _duration_origin(attrs, config, parsed.header),
113
+ },
114
+ )
115
+ )
116
+ metadata = {"header": dict(parsed.header)}
117
+ if parsed.header.get("voice_bindings") is not None:
118
+ metadata["voice_bindings"] = parsed.header["voice_bindings"]
119
+ return ParsedDocument(
120
+ text,
121
+ structural,
122
+ annotations,
123
+ tuple(boundaries),
124
+ tuple(markers),
125
+ dict(parsed.header),
126
+ metadata,
127
+ tuple(warnings),
128
+ )
129
+
130
+ @staticmethod
131
+ def _validate_header(text: str, config: PlannerConfig, warnings: list[str]) -> None:
132
+ try:
133
+ from ssmd.frontmatter import parse_front_matter
134
+
135
+ front_matter = parse_front_matter(text)
136
+ except (ImportError, OSError) as exc:
137
+ raise PlanningError("SSMD header parser is unavailable") from exc
138
+ except ValueError as exc:
139
+ raise PlanFormatError(str(exc), code="header.yaml_invalid") from exc
140
+ if not front_matter.present:
141
+ return
142
+
143
+ header = front_matter.data
144
+ known = {"pause_defaults", "voice_bindings"}
145
+ unknown = sorted(set(header) - known)
146
+ if unknown:
147
+ message = f"unknown SSMD header key(s): {', '.join(unknown)}"
148
+ if config.ssmd.unknown_header == "error":
149
+ raise PlanFormatError(message, code="header.unknown")
150
+ if config.ssmd.unknown_header == "warn":
151
+ warnings.append(message)
152
+
153
+ pause_defaults = header.get("pause_defaults")
154
+ if pause_defaults is None:
155
+ return
156
+ if not isinstance(pause_defaults, dict):
157
+ _header_invalid(config, warnings, "pause_defaults must be a mapping")
158
+ return
159
+ allowed = {
160
+ "enabled",
161
+ "weak",
162
+ "clause",
163
+ "sentence",
164
+ "paragraph",
165
+ "parenthetical",
166
+ "voice_change",
167
+ }
168
+ for key, value in pause_defaults.items():
169
+ if key not in allowed:
170
+ _header_invalid(config, warnings, f"unsupported pause_defaults key: {key}")
171
+ continue
172
+ try:
173
+ if key == "enabled":
174
+ if not isinstance(value, bool):
175
+ raise ConfigurationError("enabled must be a boolean")
176
+ else:
177
+ parse_duration(value, field_name=f"pause_defaults.{key}")
178
+ except ConfigurationError as exc:
179
+ _header_invalid(config, warnings, str(exc))
180
+
181
+
182
+ def _header_invalid(config: PlannerConfig, warnings: list[str], message: str) -> None:
183
+ if config.ssmd.strict_header:
184
+ raise PlanFormatError(message, code="header.invalid")
185
+ warnings.append(message)
186
+
187
+
188
+ def _plain_value(value: Any) -> Any:
189
+ if isinstance(value, (str, int, float, bool)) or value is None:
190
+ return value
191
+ return str(value)
192
+
193
+
194
+ def _duration(
195
+ attrs: dict[str, Any], config: PlannerConfig, header: dict[str, Any] | None = None
196
+ ) -> float | None:
197
+ value = attrs.get("time")
198
+ if value is not None:
199
+ try:
200
+ return parse_duration(value, field_name="break.time")
201
+ except ConfigurationError as exc:
202
+ raise PlanFormatError(str(exc), code="break.duration") from exc
203
+
204
+ strength = str(attrs.get("strength", "")).lower()
205
+ key = {
206
+ "x-weak": "weak",
207
+ "weak": "weak",
208
+ "medium": "clause",
209
+ "strong": "sentence",
210
+ "x-strong": "paragraph",
211
+ }.get(strength)
212
+ defaults: dict[str, Any] = {
213
+ "weak": config.pauses.weak,
214
+ "clause": config.pauses.clause,
215
+ "sentence": config.pauses.sentence,
216
+ "paragraph": config.pauses.paragraph,
217
+ "parenthetical": config.pauses.parenthetical,
218
+ "voice_change": config.pauses.voice_change,
219
+ }
220
+ if header and isinstance(header.get("pause_defaults"), dict):
221
+ for name, candidate in header["pause_defaults"].items():
222
+ if name == "enabled":
223
+ continue
224
+ try:
225
+ parse_duration(candidate, field_name=f"pause_defaults.{name}")
226
+ except ConfigurationError:
227
+ continue
228
+ defaults[name] = candidate
229
+ if config.ssmd.pause_defaults:
230
+ defaults.update(config.ssmd.pause_defaults)
231
+ if not _pause_enabled(config, header):
232
+ return None
233
+ if key in defaults:
234
+ try:
235
+ return parse_duration(defaults[key], field_name=f"pause_defaults.{key}")
236
+ except ConfigurationError as exc:
237
+ raise PlanFormatError(str(exc), code="pause.duration") from exc
238
+ return None
239
+
240
+
241
+ def _pause_enabled(config: PlannerConfig, header: dict[str, Any] | None) -> bool:
242
+ enabled = config.pauses.enabled
243
+ if header and isinstance(header.get("pause_defaults"), dict):
244
+ value = header["pause_defaults"].get("enabled")
245
+ if isinstance(value, bool):
246
+ enabled = value
247
+ if config.ssmd.pause_defaults and "enabled" in config.ssmd.pause_defaults:
248
+ enabled = bool(config.ssmd.pause_defaults["enabled"])
249
+ return enabled
250
+
251
+
252
+ def _duration_origin(attrs: dict[str, Any], config: PlannerConfig, header: dict[str, Any]) -> str:
253
+ if attrs.get("time") is not None:
254
+ return "explicit"
255
+ strength = str(attrs.get("strength", "")).lower()
256
+ key = {
257
+ "x-weak": "weak",
258
+ "weak": "weak",
259
+ "medium": "clause",
260
+ "strong": "sentence",
261
+ "x-strong": "paragraph",
262
+ }.get(strength)
263
+ if config.ssmd.pause_defaults and key in config.ssmd.pause_defaults:
264
+ return "config_default"
265
+ header_defaults = header.get("pause_defaults")
266
+ if isinstance(header_defaults, dict) and header_defaults.get(key) is not None:
267
+ try:
268
+ parse_duration(header_defaults[key], field_name=f"pause_defaults.{key}")
269
+ except ConfigurationError:
270
+ return "planner_default"
271
+ return "header_default"
272
+ return "planner_default" if key else "none"
utterplan/pauses.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import replace
5
+
6
+ from .config import PauseConfig
7
+ from .model import BoundaryEvent, PlanSegment, ResolvedPause
8
+
9
+ _AUTOMATIC_AUTO_MODE_KINDS = frozenset(
10
+ {
11
+ "clausal_comma",
12
+ "parenthetical",
13
+ "voice_change",
14
+ }
15
+ )
16
+
17
+
18
+ def boundary_is_active(event: BoundaryEvent, config: PauseConfig) -> bool:
19
+ automatic = bool(event.attrs.get("automatic")) or (
20
+ event.kind in {"paragraph", "sentence"}
21
+ and event.seconds is None
22
+ and event.origin in {"plain", "ssmd", "planner"}
23
+ )
24
+ if automatic and not config.enabled:
25
+ return False
26
+ return not (automatic and config.mode != "auto" and event.kind in _AUTOMATIC_AUTO_MODE_KINDS)
27
+
28
+
29
+ def resolve_pauses(
30
+ segments: list[PlanSegment], boundaries: list[BoundaryEvent], config: PauseConfig
31
+ ) -> list[PlanSegment]:
32
+ """Resolve semantic pause events without renderer-specific policy."""
33
+ events_after: dict[int, list[BoundaryEvent]] = defaultdict(list)
34
+ events_before: dict[int, list[BoundaryEvent]] = defaultdict(list)
35
+ for event in boundaries:
36
+ if not boundary_is_active(event, config):
37
+ continue
38
+ duration = event.seconds
39
+ if duration is None:
40
+ duration = {
41
+ "weak": config.weak,
42
+ "clause": config.clause,
43
+ "clausal_comma": config.clause,
44
+ "sentence": config.sentence,
45
+ "paragraph": config.paragraph,
46
+ "parenthetical": config.parenthetical,
47
+ "voice_change": config.voice_change,
48
+ }.get(event.kind, config.weak)
49
+ resolved = replace(event, seconds=max(0.0, float(duration or 0.0)))
50
+ anchor = str(event.attrs.get("anchor", "after"))
51
+ (events_before if anchor == "before" else events_after)[event.position].append(resolved)
52
+ for index, segment in enumerate(segments):
53
+ before = events_before.get(segment.spoken_start, [])
54
+ after = events_after.get(segment.spoken_end, [])
55
+ segments[index] = replace(segment, pause_before=_pause(before), pause_after=_pause(after))
56
+ return segments
57
+
58
+
59
+ def _pause(events: list[BoundaryEvent]) -> ResolvedPause:
60
+ if not events:
61
+ return ResolvedPause()
62
+ winner = max(events, key=lambda event: (float(event.seconds or 0), event.id))
63
+ return ResolvedPause(
64
+ float(winner.seconds or 0),
65
+ tuple(event.id for event in sorted(events, key=lambda event: event.id)),
66
+ )