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