weakincentives 0.9.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.
Files changed (73) hide show
  1. weakincentives/__init__.py +67 -0
  2. weakincentives/adapters/__init__.py +37 -0
  3. weakincentives/adapters/_names.py +32 -0
  4. weakincentives/adapters/_provider_protocols.py +69 -0
  5. weakincentives/adapters/_tool_messages.py +80 -0
  6. weakincentives/adapters/core.py +102 -0
  7. weakincentives/adapters/litellm.py +254 -0
  8. weakincentives/adapters/openai.py +254 -0
  9. weakincentives/adapters/shared.py +1021 -0
  10. weakincentives/cli/__init__.py +23 -0
  11. weakincentives/cli/wink.py +58 -0
  12. weakincentives/dbc/__init__.py +412 -0
  13. weakincentives/deadlines.py +58 -0
  14. weakincentives/prompt/__init__.py +105 -0
  15. weakincentives/prompt/_generic_params_specializer.py +64 -0
  16. weakincentives/prompt/_normalization.py +48 -0
  17. weakincentives/prompt/_overrides_protocols.py +33 -0
  18. weakincentives/prompt/_types.py +34 -0
  19. weakincentives/prompt/chapter.py +146 -0
  20. weakincentives/prompt/composition.py +281 -0
  21. weakincentives/prompt/errors.py +57 -0
  22. weakincentives/prompt/markdown.py +108 -0
  23. weakincentives/prompt/overrides/__init__.py +59 -0
  24. weakincentives/prompt/overrides/_fs.py +164 -0
  25. weakincentives/prompt/overrides/inspection.py +141 -0
  26. weakincentives/prompt/overrides/local_store.py +275 -0
  27. weakincentives/prompt/overrides/validation.py +534 -0
  28. weakincentives/prompt/overrides/versioning.py +269 -0
  29. weakincentives/prompt/prompt.py +353 -0
  30. weakincentives/prompt/protocols.py +103 -0
  31. weakincentives/prompt/registry.py +375 -0
  32. weakincentives/prompt/rendering.py +288 -0
  33. weakincentives/prompt/response_format.py +60 -0
  34. weakincentives/prompt/section.py +166 -0
  35. weakincentives/prompt/structured_output.py +179 -0
  36. weakincentives/prompt/tool.py +397 -0
  37. weakincentives/prompt/tool_result.py +30 -0
  38. weakincentives/py.typed +0 -0
  39. weakincentives/runtime/__init__.py +82 -0
  40. weakincentives/runtime/events/__init__.py +126 -0
  41. weakincentives/runtime/events/_types.py +110 -0
  42. weakincentives/runtime/logging.py +284 -0
  43. weakincentives/runtime/session/__init__.py +46 -0
  44. weakincentives/runtime/session/_slice_types.py +24 -0
  45. weakincentives/runtime/session/_types.py +55 -0
  46. weakincentives/runtime/session/dataclasses.py +29 -0
  47. weakincentives/runtime/session/protocols.py +34 -0
  48. weakincentives/runtime/session/reducer_context.py +40 -0
  49. weakincentives/runtime/session/reducers.py +82 -0
  50. weakincentives/runtime/session/selectors.py +56 -0
  51. weakincentives/runtime/session/session.py +387 -0
  52. weakincentives/runtime/session/snapshots.py +310 -0
  53. weakincentives/serde/__init__.py +19 -0
  54. weakincentives/serde/_utils.py +240 -0
  55. weakincentives/serde/dataclass_serde.py +55 -0
  56. weakincentives/serde/dump.py +189 -0
  57. weakincentives/serde/parse.py +417 -0
  58. weakincentives/serde/schema.py +260 -0
  59. weakincentives/tools/__init__.py +154 -0
  60. weakincentives/tools/_context.py +38 -0
  61. weakincentives/tools/asteval.py +853 -0
  62. weakincentives/tools/errors.py +26 -0
  63. weakincentives/tools/planning.py +831 -0
  64. weakincentives/tools/podman.py +1655 -0
  65. weakincentives/tools/subagents.py +346 -0
  66. weakincentives/tools/vfs.py +1390 -0
  67. weakincentives/types/__init__.py +35 -0
  68. weakincentives/types/json.py +45 -0
  69. weakincentives-0.9.0.dist-info/METADATA +775 -0
  70. weakincentives-0.9.0.dist-info/RECORD +73 -0
  71. weakincentives-0.9.0.dist-info/WHEEL +4 -0
  72. weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
  73. weakincentives-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,269 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from hashlib import sha256
19
+ from typing import Literal, Protocol, TypeVar, overload
20
+
21
+ from ...serde.schema import schema
22
+ from .._types import SupportsDataclass
23
+
24
+
25
+ def _section_override_mapping_factory() -> dict[tuple[str, ...], SectionOverride]:
26
+ return {}
27
+
28
+
29
+ def _tool_override_mapping_factory() -> dict[str, ToolOverride]:
30
+ return {}
31
+
32
+
33
+ def _param_description_mapping_factory() -> dict[str, str]:
34
+ return {}
35
+
36
+
37
+ class ToolContractProtocol(Protocol):
38
+ name: str
39
+ description: str
40
+ params_type: type[SupportsDataclass]
41
+ result_type: type[SupportsDataclass]
42
+ result_container: Literal["object", "array"]
43
+ accepts_overrides: bool
44
+
45
+
46
+ class SectionLike(Protocol):
47
+ def original_body_template(self) -> str | None: ...
48
+
49
+ def tools(self) -> tuple[ToolContractProtocol, ...]: ...
50
+
51
+ accepts_overrides: bool
52
+
53
+
54
+ class SectionNodeLike(Protocol):
55
+ path: tuple[str, ...]
56
+ section: SectionLike
57
+
58
+
59
+ class PromptLike(Protocol):
60
+ ns: str
61
+ key: str
62
+
63
+ @property
64
+ def sections(self) -> tuple[SectionNodeLike, ...]: ...
65
+
66
+
67
+ _HEX_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
68
+
69
+ _HexDigestT = TypeVar("_HexDigestT", bound="HexDigest")
70
+
71
+
72
+ class HexDigest(str):
73
+ """A validated lowercase hexadecimal SHA-256 digest."""
74
+
75
+ __slots__ = ()
76
+
77
+ def __new__(cls: type[_HexDigestT], value: object) -> _HexDigestT:
78
+ if not isinstance(value, str):
79
+ msg = "HexDigest value must be a string."
80
+ raise TypeError(msg)
81
+ if not _HEX_DIGEST_RE.fullmatch(value):
82
+ msg = f"Invalid hex digest value: {value!r}"
83
+ raise ValueError(msg)
84
+ return str.__new__(cls, value)
85
+
86
+
87
+ @overload
88
+ def ensure_hex_digest(value: HexDigest, *, field_name: str) -> HexDigest: ...
89
+
90
+
91
+ @overload
92
+ def ensure_hex_digest(value: str, *, field_name: str) -> HexDigest: ...
93
+
94
+
95
+ def ensure_hex_digest(value: object, *, field_name: str) -> HexDigest:
96
+ """Normalize an object to a :class:`HexDigest` with helpful errors."""
97
+
98
+ if isinstance(value, HexDigest):
99
+ return value
100
+ if isinstance(value, str):
101
+ try:
102
+ return HexDigest(value)
103
+ except ValueError as error:
104
+ msg = f"{field_name} must be a 64 character lowercase hex digest."
105
+ raise PromptOverridesError(msg) from error
106
+ msg = f"{field_name} must be a string."
107
+ raise PromptOverridesError(msg)
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class SectionDescriptor:
112
+ """Hash metadata for a single section within a prompt."""
113
+
114
+ path: tuple[str, ...]
115
+ content_hash: HexDigest
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class ToolDescriptor:
120
+ """Stable metadata describing a tool exposed by a prompt."""
121
+
122
+ path: tuple[str, ...]
123
+ name: str
124
+ contract_hash: HexDigest
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class PromptDescriptor:
129
+ """Stable metadata describing a prompt and its hash-aware sections."""
130
+
131
+ ns: str
132
+ key: str
133
+ sections: list[SectionDescriptor]
134
+ tools: list[ToolDescriptor]
135
+
136
+ @classmethod
137
+ def from_prompt(cls, prompt: PromptLike) -> PromptDescriptor:
138
+ sections: list[SectionDescriptor] = []
139
+ tools: list[ToolDescriptor] = []
140
+ for node in prompt.sections:
141
+ if getattr(node.section, "accepts_overrides", True):
142
+ template = node.section.original_body_template()
143
+ if template is not None:
144
+ content_hash = hash_text(template)
145
+ sections.append(SectionDescriptor(node.path, content_hash))
146
+ tool_descriptors = [
147
+ ToolDescriptor(
148
+ path=node.path,
149
+ name=tool.name,
150
+ contract_hash=_tool_contract_hash(tool),
151
+ )
152
+ for tool in node.section.tools()
153
+ if tool.accepts_overrides
154
+ ]
155
+ tools.extend(tool_descriptors)
156
+ return cls(prompt.ns, prompt.key, sections, tools)
157
+
158
+
159
+ @dataclass(slots=True)
160
+ class SectionOverride:
161
+ """Override payload for a prompt section validated by hash."""
162
+
163
+ expected_hash: HexDigest
164
+ body: str
165
+
166
+
167
+ @dataclass(slots=True)
168
+ class ToolOverride:
169
+ """Description overrides validated against a tool contract hash."""
170
+
171
+ name: str
172
+ expected_contract_hash: HexDigest
173
+ description: str | None = None
174
+ param_descriptions: dict[str, str] = field(
175
+ default_factory=_param_description_mapping_factory
176
+ )
177
+
178
+
179
+ @dataclass(slots=True)
180
+ class PromptOverride:
181
+ """Runtime replacements for prompt sections validated by an overrides store."""
182
+
183
+ ns: str
184
+ prompt_key: str
185
+ tag: str
186
+ sections: dict[tuple[str, ...], SectionOverride] = field(
187
+ default_factory=_section_override_mapping_factory
188
+ )
189
+ tool_overrides: dict[str, ToolOverride] = field(
190
+ default_factory=_tool_override_mapping_factory
191
+ )
192
+
193
+
194
+ class PromptOverridesError(Exception):
195
+ """Raised when prompt overrides fail validation or persistence."""
196
+
197
+
198
+ class PromptOverridesStore(Protocol):
199
+ """Lookup interface for resolving prompt overrides at render time."""
200
+
201
+ def resolve(
202
+ self,
203
+ descriptor: PromptDescriptor,
204
+ tag: str = "latest",
205
+ ) -> PromptOverride | None: ...
206
+
207
+ def upsert(
208
+ self,
209
+ descriptor: PromptDescriptor,
210
+ override: PromptOverride,
211
+ ) -> PromptOverride: ...
212
+
213
+ def delete(
214
+ self,
215
+ *,
216
+ ns: str,
217
+ prompt_key: str,
218
+ tag: str,
219
+ ) -> None: ...
220
+
221
+ def seed_if_necessary(
222
+ self,
223
+ prompt: PromptLike,
224
+ *,
225
+ tag: str = "latest",
226
+ ) -> PromptOverride: ...
227
+
228
+
229
+ __all__ = [
230
+ "HexDigest",
231
+ "PromptDescriptor",
232
+ "PromptOverride",
233
+ "PromptOverridesError",
234
+ "PromptOverridesStore",
235
+ "SectionDescriptor",
236
+ "SectionOverride",
237
+ "ToolDescriptor",
238
+ "ToolOverride",
239
+ "ensure_hex_digest",
240
+ ]
241
+
242
+
243
+ def _tool_contract_hash(tool: ToolContractProtocol) -> HexDigest:
244
+ description_hash = hash_text(tool.description)
245
+ params_schema_hash = hash_json(schema(tool.params_type, extra="forbid"))
246
+ if getattr(tool, "result_container", "object") == "array":
247
+ item_schema = schema(tool.result_type, extra="ignore")
248
+ result_schema = {
249
+ "title": f"{tool.result_type.__name__}List",
250
+ "type": "array",
251
+ "items": item_schema,
252
+ }
253
+ else:
254
+ result_schema = schema(tool.result_type, extra="ignore")
255
+ result_schema_hash = hash_json(result_schema)
256
+ return hash_text(
257
+ "::".join((description_hash, params_schema_hash, result_schema_hash))
258
+ )
259
+
260
+
261
+ def hash_text(value: str) -> HexDigest:
262
+ return HexDigest(sha256(value.encode("utf-8")).hexdigest())
263
+
264
+
265
+ def hash_json(value: object) -> HexDigest:
266
+ canonical = json.dumps(
267
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
268
+ )
269
+ return hash_text(canonical)
@@ -0,0 +1,353 @@
1
+ # Licensed under the Apache License, Version 2.0 (the "License");
2
+ # you may not use this file except in compliance with the License.
3
+ # You may obtain a copy of the License at
4
+ #
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ #
7
+ # Unless required by applicable law or agreed to in writing, software
8
+ # distributed under the License is distributed on an "AS IS" BASIS,
9
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ # See the License for the specific language governing permissions and
11
+ # limitations under the License.
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import is_dataclass
17
+ from typing import (
18
+ TYPE_CHECKING,
19
+ Any,
20
+ ClassVar,
21
+ Literal,
22
+ cast,
23
+ get_args,
24
+ get_origin,
25
+ )
26
+
27
+ from ._overrides_protocols import PromptOverridesStoreProtocol
28
+ from ._types import SupportsDataclass
29
+ from .chapter import Chapter, ChaptersExpansionPolicy
30
+ from .errors import PromptValidationError, SectionPath
31
+ from .registry import PromptRegistry, RegistrySnapshot, SectionNode, clone_dataclass
32
+ from .rendering import PromptRenderer, RenderedPrompt
33
+ from .response_format import ResponseFormatParams, ResponseFormatSection
34
+ from .section import Section
35
+ from .structured_output import StructuredOutputConfig
36
+
37
+ if TYPE_CHECKING:
38
+ from .overrides import PromptLike, ToolOverride
39
+
40
+
41
+ def _format_specialization_argument(argument: object | None) -> str:
42
+ if argument is None:
43
+ return "?"
44
+ if isinstance(argument, type):
45
+ return argument.__name__
46
+ return repr(argument)
47
+
48
+
49
+ class Prompt[OutputT]:
50
+ """Coordinate prompt sections and their parameter bindings."""
51
+
52
+ _output_container_spec: ClassVar[Literal["object", "array"] | None] = None
53
+ _output_dataclass_candidate: ClassVar[Any] = None
54
+
55
+ def __class_getitem__(cls, item: object) -> type[Prompt[Any]]:
56
+ origin = get_origin(item)
57
+ candidate = item
58
+ container: Literal["object", "array"] | None = "object"
59
+
60
+ if origin is list:
61
+ args = get_args(item)
62
+ candidate = args[0] if len(args) == 1 else None
63
+ container = "array"
64
+ label = f"list[{_format_specialization_argument(candidate)}]"
65
+ else:
66
+ container = "object"
67
+ label = _format_specialization_argument(candidate)
68
+
69
+ name = f"{cls.__name__}[{label}]"
70
+ namespace = {
71
+ "__module__": cls.__module__,
72
+ "_output_container_spec": container if candidate is not None else None,
73
+ "_output_dataclass_candidate": candidate,
74
+ }
75
+ return type(name, (cls,), namespace)
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ ns: str,
81
+ key: str,
82
+ name: str | None = None,
83
+ sections: Sequence[Section[SupportsDataclass]] | None = None,
84
+ chapters: Sequence[Chapter[SupportsDataclass]] | None = None,
85
+ inject_output_instructions: bool = True,
86
+ allow_extra_keys: bool = False,
87
+ ) -> None:
88
+ super().__init__()
89
+ stripped_ns = ns.strip()
90
+ if not stripped_ns:
91
+ raise PromptValidationError("Prompt namespace must be a non-empty string.")
92
+ stripped_key = key.strip()
93
+ if not stripped_key:
94
+ raise PromptValidationError("Prompt key must be a non-empty string.")
95
+ self.ns = stripped_ns
96
+ self.key = stripped_key
97
+ self.name = name
98
+ base_sections = tuple(sections or ())
99
+ self._base_sections: tuple[Section[SupportsDataclass], ...] = base_sections
100
+ self._sections: tuple[Section[SupportsDataclass], ...] = base_sections
101
+ self._registry = PromptRegistry()
102
+ self.placeholders: dict[SectionPath, set[str]] = {}
103
+ self._allow_extra_keys_requested = allow_extra_keys
104
+
105
+ seen_chapter_keys: set[str] = set()
106
+ provided_chapters = tuple(chapters or ())
107
+ for chapter in provided_chapters:
108
+ if chapter.key in seen_chapter_keys:
109
+ raise PromptValidationError(
110
+ "Prompt chapters must use unique keys.",
111
+ section_path=(chapter.key,),
112
+ )
113
+ seen_chapter_keys.add(chapter.key)
114
+ self._chapters: tuple[Chapter[SupportsDataclass], ...] = provided_chapters
115
+ self._chapter_key_registry: dict[str, Chapter[SupportsDataclass]] = {
116
+ chapter.key: chapter for chapter in self._chapters
117
+ }
118
+
119
+ self._structured_output: StructuredOutputConfig[SupportsDataclass] | None
120
+ self._structured_output = self._resolve_output_spec(allow_extra_keys)
121
+
122
+ self.inject_output_instructions = inject_output_instructions
123
+
124
+ self._registry.register_sections(self._sections)
125
+
126
+ self._response_section: ResponseFormatSection | None = None
127
+ if self._structured_output is not None:
128
+ response_params = self._build_response_format_params()
129
+ response_section = ResponseFormatSection(
130
+ params=response_params,
131
+ enabled=lambda _params, prompt=self: prompt.inject_output_instructions,
132
+ )
133
+ self._response_section = response_section
134
+ section_for_registry = cast(Section[SupportsDataclass], response_section)
135
+ self._sections += (section_for_registry,)
136
+ self._registry.register_section(
137
+ section_for_registry, path=(response_section.key,), depth=0
138
+ )
139
+
140
+ snapshot = self._registry.snapshot()
141
+ self._registry_snapshot: RegistrySnapshot = snapshot
142
+ self.placeholders = {
143
+ path: set(names) for path, names in snapshot.placeholders.items()
144
+ }
145
+
146
+ self._renderer: PromptRenderer[OutputT] = PromptRenderer(
147
+ registry=snapshot,
148
+ structured_output=self._structured_output,
149
+ response_section=self._response_section,
150
+ )
151
+
152
+ def render(
153
+ self,
154
+ *params: SupportsDataclass,
155
+ overrides_store: PromptOverridesStoreProtocol | None = None,
156
+ tag: str = "latest",
157
+ inject_output_instructions: bool | None = None,
158
+ ) -> RenderedPrompt[OutputT]:
159
+ """Render the prompt and apply overrides when an overrides store is supplied."""
160
+
161
+ overrides: dict[SectionPath, str] | None = None
162
+ tool_overrides: dict[str, ToolOverride] | None = None
163
+
164
+ if overrides_store is not None:
165
+ from .overrides import PromptDescriptor
166
+
167
+ descriptor = PromptDescriptor.from_prompt(cast("PromptLike", self))
168
+ override = overrides_store.resolve(descriptor=descriptor, tag=tag)
169
+
170
+ if override is not None:
171
+ overrides = {
172
+ path: section_override.body
173
+ for path, section_override in override.sections.items()
174
+ }
175
+ tool_overrides = dict(override.tool_overrides)
176
+
177
+ param_lookup = self._renderer.build_param_lookup(params)
178
+ return self._renderer.render(
179
+ param_lookup,
180
+ overrides,
181
+ tool_overrides,
182
+ inject_output_instructions=inject_output_instructions,
183
+ )
184
+
185
+ @property
186
+ def sections(self) -> tuple[SectionNode[SupportsDataclass], ...]:
187
+ return self._registry_snapshot.sections
188
+
189
+ @property
190
+ def param_types(self) -> set[type[SupportsDataclass]]:
191
+ return self._registry_snapshot.param_types
192
+
193
+ @property
194
+ def structured_output(self) -> StructuredOutputConfig[SupportsDataclass] | None:
195
+ """Resolved structured output declaration, when present."""
196
+
197
+ return self._structured_output
198
+
199
+ @property
200
+ def chapters(self) -> tuple[Chapter[SupportsDataclass], ...]:
201
+ return self._chapters
202
+
203
+ def expand_chapters(
204
+ self,
205
+ policy: ChaptersExpansionPolicy,
206
+ *,
207
+ chapter_params: Mapping[str, SupportsDataclass | None] | None = None,
208
+ ) -> Prompt[OutputT]:
209
+ """Return a prompt snapshot with chapters opened per the supplied policy."""
210
+
211
+ if not self._chapters:
212
+ return self
213
+
214
+ if policy is ChaptersExpansionPolicy.ALL_INCLUDED:
215
+ return self._expand_chapters_all_included(
216
+ chapter_params=chapter_params or {}
217
+ )
218
+
219
+ raise NotImplementedError(
220
+ f"Chapters expansion policy '{policy.value}' is not supported."
221
+ )
222
+
223
+ def _expand_chapters_all_included(
224
+ self,
225
+ *,
226
+ chapter_params: Mapping[str, SupportsDataclass | None],
227
+ ) -> Prompt[OutputT]:
228
+ provided_lookup = dict(chapter_params)
229
+ unknown_keys = set(provided_lookup) - set(self._chapter_key_registry.keys())
230
+ if unknown_keys:
231
+ unknown_key = sorted(unknown_keys)[0]
232
+ raise PromptValidationError(
233
+ "Chapter parameters reference unknown chapter key.",
234
+ section_path=(unknown_key,),
235
+ )
236
+
237
+ open_sections: list[Section[SupportsDataclass]] = list(self._base_sections)
238
+
239
+ for chapter in self._chapters:
240
+ key_present = chapter.key in provided_lookup
241
+ params = provided_lookup.get(chapter.key)
242
+ if key_present:
243
+ params = self._normalize_chapter_params(chapter, params)
244
+ elif chapter.default_params is not None:
245
+ params = clone_dataclass(chapter.default_params)
246
+
247
+ if chapter.enabled is not None:
248
+ if params is None and chapter.param_type is not None:
249
+ raise PromptValidationError(
250
+ "Chapter requires parameters for enabled predicate.",
251
+ section_path=(chapter.key,),
252
+ dataclass_type=chapter.param_type,
253
+ )
254
+ try:
255
+ enabled = chapter.is_enabled(params)
256
+ except Exception as error:
257
+ raise PromptValidationError(
258
+ "Chapter enabled predicate failed.",
259
+ section_path=(chapter.key,),
260
+ dataclass_type=chapter.param_type,
261
+ ) from error
262
+ if not enabled:
263
+ continue
264
+
265
+ open_sections.extend(chapter.sections)
266
+
267
+ prompt_cls = type(self)
268
+
269
+ return prompt_cls(
270
+ ns=self.ns,
271
+ key=self.key,
272
+ name=self.name,
273
+ sections=open_sections,
274
+ chapters=(),
275
+ inject_output_instructions=self.inject_output_instructions,
276
+ allow_extra_keys=self._allow_extra_keys_requested,
277
+ )
278
+
279
+ def _normalize_chapter_params(
280
+ self,
281
+ chapter: Chapter[SupportsDataclass],
282
+ params: SupportsDataclass | None,
283
+ ) -> SupportsDataclass | None:
284
+ params_type = chapter.param_type
285
+ if params_type is None:
286
+ if params is not None:
287
+ raise PromptValidationError(
288
+ "Chapter does not accept parameters.",
289
+ section_path=(chapter.key,),
290
+ )
291
+ return None
292
+
293
+ if params is None:
294
+ raise PromptValidationError(
295
+ "Chapter requires parameters.",
296
+ section_path=(chapter.key,),
297
+ dataclass_type=params_type,
298
+ )
299
+ return params
300
+
301
+ def _resolve_output_spec(
302
+ self, allow_extra_keys: bool
303
+ ) -> StructuredOutputConfig[SupportsDataclass] | None:
304
+ candidate = getattr(type(self), "_output_dataclass_candidate", None)
305
+ container = cast(
306
+ Literal["object", "array"] | None,
307
+ getattr(type(self), "_output_container_spec", None),
308
+ )
309
+
310
+ if candidate is None or container is None:
311
+ return None
312
+
313
+ if not isinstance(candidate, type):
314
+ candidate_type = cast(type[Any], type(candidate))
315
+ raise PromptValidationError(
316
+ "Prompt output type must be a dataclass.",
317
+ dataclass_type=candidate_type,
318
+ )
319
+
320
+ if not is_dataclass(candidate):
321
+ bad_dataclass = cast(type[Any], candidate)
322
+ raise PromptValidationError(
323
+ "Prompt output type must be a dataclass.",
324
+ dataclass_type=bad_dataclass,
325
+ )
326
+
327
+ dataclass_type = cast(type[SupportsDataclass], candidate)
328
+ return StructuredOutputConfig(
329
+ dataclass_type=dataclass_type,
330
+ container=container,
331
+ allow_extra_keys=allow_extra_keys,
332
+ )
333
+
334
+ def _build_response_format_params(self) -> ResponseFormatParams:
335
+ spec = self._structured_output
336
+ if spec is None:
337
+ raise RuntimeError(
338
+ "Output container missing during response format construction."
339
+ )
340
+ container = spec.container
341
+
342
+ article: Literal["a", "an"] = (
343
+ "an" if container.startswith(("a", "e", "i", "o", "u")) else "a"
344
+ )
345
+ extra_clause = "." if spec.allow_extra_keys else ". Do not add extra keys."
346
+ return ResponseFormatParams(
347
+ article=article,
348
+ container=container,
349
+ extra_clause=extra_clause,
350
+ )
351
+
352
+
353
+ __all__ = ["Prompt", "RenderedPrompt", "SectionNode"]