formwork 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.
- formwork/__init__.py +62 -0
- formwork/engine.py +367 -0
- formwork/errors.py +52 -0
- formwork/fields.py +128 -0
- formwork/prompt.py +182 -0
- formwork/providers/__init__.py +55 -0
- formwork/providers/base.py +62 -0
- formwork/providers/fake.py +187 -0
- formwork/providers/gemini.py +289 -0
- formwork/providers/record.py +92 -0
- formwork/py.typed +0 -0
- formwork/repair.py +183 -0
- formwork/report.py +102 -0
- formwork/rules.py +216 -0
- formwork/spec.py +170 -0
- formwork-0.1.0.dist-info/METADATA +1208 -0
- formwork-0.1.0.dist-info/RECORD +19 -0
- formwork-0.1.0.dist-info/WHEEL +4 -0
- formwork-0.1.0.dist-info/licenses/LICENSE +21 -0
formwork/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""formwork — declare who owns which field, then guarantee the domain rules.
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from formwork import Spec, computed, chosen, generated, rule, generate
|
|
5
|
+
|
|
6
|
+
class Plan(Spec):
|
|
7
|
+
"A weekly training plan."
|
|
8
|
+
split: Annotated[str, computed(rules.split_for)]
|
|
9
|
+
exercises: Annotated[list[Exercise], chosen(source="library", key="id")]
|
|
10
|
+
rationale: Annotated[str, generated(describe="Two sentences, plain language.")]
|
|
11
|
+
|
|
12
|
+
@rule("Weekly sets per muscle must stay inside the prescribed range.",
|
|
13
|
+
fields=["exercises"], repair=repair.truncate("exercises", 10))
|
|
14
|
+
def volume(self, ctx): ...
|
|
15
|
+
|
|
16
|
+
plan, report = generate(Plan, ctx, model)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from formwork.engine import Session, agenerate, generate
|
|
20
|
+
from formwork.errors import ConstraintError, FormworkError, StructuralError
|
|
21
|
+
from formwork.fields import FieldSpec, Role, chosen, computed, generated
|
|
22
|
+
from formwork.prompt import PromptBuilder
|
|
23
|
+
from formwork.providers.base import AsyncModel, Model, ModelRequest
|
|
24
|
+
from formwork.report import Attempt, Report, Usage
|
|
25
|
+
from formwork.rules import Objective, Rule, Violation, rule, soft
|
|
26
|
+
from formwork.spec import Spec
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# spec
|
|
32
|
+
"Spec",
|
|
33
|
+
"computed",
|
|
34
|
+
"chosen",
|
|
35
|
+
"generated",
|
|
36
|
+
"FieldSpec",
|
|
37
|
+
"Role",
|
|
38
|
+
# rules
|
|
39
|
+
"rule",
|
|
40
|
+
"soft",
|
|
41
|
+
"Rule",
|
|
42
|
+
"Objective",
|
|
43
|
+
"Violation",
|
|
44
|
+
# running
|
|
45
|
+
"generate",
|
|
46
|
+
"agenerate",
|
|
47
|
+
"Session",
|
|
48
|
+
"PromptBuilder",
|
|
49
|
+
# providers
|
|
50
|
+
"Model",
|
|
51
|
+
"AsyncModel",
|
|
52
|
+
"ModelRequest",
|
|
53
|
+
# results
|
|
54
|
+
"Report",
|
|
55
|
+
"Attempt",
|
|
56
|
+
"Usage",
|
|
57
|
+
# errors
|
|
58
|
+
"FormworkError",
|
|
59
|
+
"ConstraintError",
|
|
60
|
+
"StructuralError",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
formwork/engine.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""The generation loop, written sans-IO.
|
|
2
|
+
|
|
3
|
+
``Session`` is a state machine: it hands you a request, you get it answered
|
|
4
|
+
however you like, you feed the answer back. The synchronous and asynchronous
|
|
5
|
+
drivers below are each about six lines on top of it, the test doubles drive it
|
|
6
|
+
without a network, and anyone with an unusual setup — a queue, a batch API, a
|
|
7
|
+
human in the loop — can drive it themselves. One copy of the logic.
|
|
8
|
+
|
|
9
|
+
The loop itself:
|
|
10
|
+
|
|
11
|
+
computed fields → prompt → model → schema check → rule check
|
|
12
|
+
↓ fails
|
|
13
|
+
deterministic repair, if a rule declared one
|
|
14
|
+
↓ still fails
|
|
15
|
+
targeted repair: re-ask for the implicated
|
|
16
|
+
fields only, freeze the rest
|
|
17
|
+
↓ out of attempts
|
|
18
|
+
ConstraintError
|
|
19
|
+
|
|
20
|
+
The step worth defending is the targeted one. The obvious implementation is to
|
|
21
|
+
regenerate everything with the errors appended to the prompt, which is what
|
|
22
|
+
most hand-rolled versions do; it throws away correct work, costs a full
|
|
23
|
+
completion, and gives the model fresh opportunities to break a rule it had
|
|
24
|
+
satisfied. Re-asking for two fields out of nine is cheaper and converges more
|
|
25
|
+
often, and the price is that rules have to say which fields they implicate.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from typing import Any, Generic, TypeVar
|
|
32
|
+
|
|
33
|
+
from pydantic import ValidationError
|
|
34
|
+
|
|
35
|
+
from formwork import repair as repair_module
|
|
36
|
+
from formwork.errors import ConstraintError, FormworkError, StructuralError
|
|
37
|
+
from formwork.prompt import PromptBuilder
|
|
38
|
+
from formwork.providers.base import AsyncModel, Model, ModelRequest
|
|
39
|
+
from formwork.report import Attempt, Report, Usage
|
|
40
|
+
from formwork.rules import Violation
|
|
41
|
+
from formwork.spec import Spec
|
|
42
|
+
|
|
43
|
+
__all__ = ["Session", "generate", "agenerate"]
|
|
44
|
+
|
|
45
|
+
S = TypeVar("S", bound=Spec)
|
|
46
|
+
|
|
47
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Session(Generic[S]):
|
|
51
|
+
"""One candidate's worth of generation, driven by the caller."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
spec: type[S],
|
|
56
|
+
ctx: Any,
|
|
57
|
+
*,
|
|
58
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
59
|
+
prompt_builder: PromptBuilder | None = None,
|
|
60
|
+
system: str | None = None,
|
|
61
|
+
targeted_repair: bool = True,
|
|
62
|
+
use_declared_repairs: bool = True,
|
|
63
|
+
) -> None:
|
|
64
|
+
if max_attempts < 1:
|
|
65
|
+
raise ValueError("max_attempts must be at least 1")
|
|
66
|
+
|
|
67
|
+
self.spec = spec
|
|
68
|
+
self.ctx = ctx
|
|
69
|
+
self.max_attempts = max_attempts
|
|
70
|
+
# Both default on. They exist to be switched off by the benchmark, so
|
|
71
|
+
# that a measured gain can be attributed to one mechanism rather than
|
|
72
|
+
# to "the library"; turning them off in production only makes the loop
|
|
73
|
+
# more expensive.
|
|
74
|
+
self.targeted_repair = targeted_repair
|
|
75
|
+
self.use_declared_repairs = use_declared_repairs
|
|
76
|
+
self.prompts = prompt_builder or PromptBuilder()
|
|
77
|
+
self.system = system if system is not None else self.prompts.system(spec)
|
|
78
|
+
|
|
79
|
+
self.computed_values = spec.resolve_computed(ctx)
|
|
80
|
+
self.report = Report()
|
|
81
|
+
|
|
82
|
+
self._instance: S | None = None
|
|
83
|
+
self._violations: list[Violation] = []
|
|
84
|
+
self._structural: list[str] = []
|
|
85
|
+
self._pending: ModelRequest | None = None
|
|
86
|
+
self._result: S | None = None
|
|
87
|
+
|
|
88
|
+
# ── driving ──────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def next_request(self) -> ModelRequest | None:
|
|
91
|
+
"""The next call to make, or None when there is nothing left to try."""
|
|
92
|
+
if self._result is not None:
|
|
93
|
+
return None
|
|
94
|
+
if len(self.report.attempts) >= self.max_attempts:
|
|
95
|
+
return None
|
|
96
|
+
if self._pending is not None:
|
|
97
|
+
return self._pending
|
|
98
|
+
|
|
99
|
+
self._pending = self._build_request()
|
|
100
|
+
return self._pending
|
|
101
|
+
|
|
102
|
+
def feed(self, raw: dict[str, Any], usage: Usage | None = None) -> None:
|
|
103
|
+
"""Hand back what the model returned for the outstanding request."""
|
|
104
|
+
request = self._pending
|
|
105
|
+
if request is None:
|
|
106
|
+
raise RuntimeError("feed() called with no outstanding request")
|
|
107
|
+
self._pending = None
|
|
108
|
+
|
|
109
|
+
attempt = Attempt(
|
|
110
|
+
index=len(self.report.attempts),
|
|
111
|
+
kind=request.kind,
|
|
112
|
+
usage=usage or Usage(),
|
|
113
|
+
targeted_fields=request.fields,
|
|
114
|
+
)
|
|
115
|
+
self.report.attempts.append(attempt)
|
|
116
|
+
|
|
117
|
+
candidate = self._parse(request, raw, attempt)
|
|
118
|
+
if candidate is None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self._structural = []
|
|
122
|
+
self._evaluate(candidate, attempt)
|
|
123
|
+
|
|
124
|
+
def finish(self) -> tuple[S, Report]:
|
|
125
|
+
"""The object, or the reason there isn't one."""
|
|
126
|
+
if self._result is not None:
|
|
127
|
+
total, breakdown = self._result.score(self.ctx)
|
|
128
|
+
self.report.soft_scores = breakdown
|
|
129
|
+
return self._result, self.report
|
|
130
|
+
if self._violations:
|
|
131
|
+
raise ConstraintError(self._violations, self.report)
|
|
132
|
+
raise StructuralError(self._structural or ["model was never called"], self.report)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def done(self) -> bool:
|
|
136
|
+
return self._result is not None
|
|
137
|
+
|
|
138
|
+
# ── internals ────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _build_request(self) -> ModelRequest:
|
|
141
|
+
if self._instance is None:
|
|
142
|
+
schema = self.spec.model_facing_schema()
|
|
143
|
+
if self._structural:
|
|
144
|
+
prompt = self.prompts.structural_retry(
|
|
145
|
+
self.spec, self.ctx, self.computed_values, self._structural
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
prompt = self.prompts.initial(self.spec, self.ctx, self.computed_values)
|
|
149
|
+
return ModelRequest(
|
|
150
|
+
prompt=prompt,
|
|
151
|
+
schema=schema,
|
|
152
|
+
system=self.system,
|
|
153
|
+
kind="initial",
|
|
154
|
+
fields=self.spec.model_owned_fields(),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
targeted = self._targeted_fields()
|
|
158
|
+
return ModelRequest(
|
|
159
|
+
prompt=self.prompts.repair(
|
|
160
|
+
self.spec, self.ctx, self._instance, self._violations, targeted
|
|
161
|
+
),
|
|
162
|
+
schema=self.spec.model_facing_schema(only=targeted),
|
|
163
|
+
system=self.system,
|
|
164
|
+
kind="repair",
|
|
165
|
+
fields=targeted,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _targeted_fields(self) -> tuple[str, ...]:
|
|
169
|
+
"""Fields the violated rules pointed at, falling back to everything.
|
|
170
|
+
|
|
171
|
+
The fallback is deliberate rather than an error: a rule that does not
|
|
172
|
+
declare its fields still works, it just costs a full regeneration. The
|
|
173
|
+
library should not refuse to run because someone was in a hurry.
|
|
174
|
+
"""
|
|
175
|
+
if not self.targeted_repair:
|
|
176
|
+
return self.spec.model_owned_fields()
|
|
177
|
+
|
|
178
|
+
owned = set(self.spec.model_owned_fields())
|
|
179
|
+
implicated = tuple(
|
|
180
|
+
dict.fromkeys(
|
|
181
|
+
name
|
|
182
|
+
for violation in self._violations
|
|
183
|
+
for name in violation.fields
|
|
184
|
+
if name in owned
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return implicated or self.spec.model_owned_fields()
|
|
188
|
+
|
|
189
|
+
def _parse(
|
|
190
|
+
self, request: ModelRequest, raw: dict[str, Any], attempt: Attempt
|
|
191
|
+
) -> S | None:
|
|
192
|
+
try:
|
|
193
|
+
parsed = request.schema.model_validate(raw)
|
|
194
|
+
except ValidationError as error:
|
|
195
|
+
attempt.structural_errors = tuple(_format(error))
|
|
196
|
+
self._structural = list(attempt.structural_errors)
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
if request.is_repair:
|
|
201
|
+
assert self._instance is not None
|
|
202
|
+
return self._instance.patched(**parsed.model_dump())
|
|
203
|
+
return self.spec.assemble(self.computed_values, parsed)
|
|
204
|
+
except ValidationError as error:
|
|
205
|
+
# The partial matched its own schema but the whole object does not
|
|
206
|
+
# hold together — a cross-field constraint expressed in Pydantic.
|
|
207
|
+
attempt.structural_errors = tuple(_format(error))
|
|
208
|
+
self._structural = list(attempt.structural_errors)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def _evaluate(self, candidate: S, attempt: Attempt) -> None:
|
|
212
|
+
result = candidate.check(self.ctx)
|
|
213
|
+
if result.ok:
|
|
214
|
+
self._settle(candidate)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
strategies = (
|
|
218
|
+
{
|
|
219
|
+
rule.name: rule.repair
|
|
220
|
+
for rule in type(candidate).__formwork_rules__
|
|
221
|
+
if rule.repair is not None
|
|
222
|
+
}
|
|
223
|
+
if self.use_declared_repairs
|
|
224
|
+
else {}
|
|
225
|
+
)
|
|
226
|
+
patched, fired = repair_module.apply(candidate, result.violations, self.ctx, strategies)
|
|
227
|
+
|
|
228
|
+
if fired:
|
|
229
|
+
attempt.repaired_by = tuple(fired)
|
|
230
|
+
candidate = patched
|
|
231
|
+
result = candidate.check(self.ctx)
|
|
232
|
+
if result.ok:
|
|
233
|
+
self._settle(candidate)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
attempt.violations = tuple(result.violations)
|
|
237
|
+
self._instance = candidate
|
|
238
|
+
self._violations = list(result.violations)
|
|
239
|
+
|
|
240
|
+
def _settle(self, candidate: S) -> None:
|
|
241
|
+
self._instance = candidate
|
|
242
|
+
self._result = candidate
|
|
243
|
+
self._violations = []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _format(error: ValidationError) -> list[str]:
|
|
247
|
+
"""Pydantic errors as one readable line each, for the retry prompt."""
|
|
248
|
+
lines = []
|
|
249
|
+
for item in error.errors():
|
|
250
|
+
location = ".".join(str(part) for part in item["loc"]) or "<root>"
|
|
251
|
+
lines.append(f"{location}: {item['msg']}")
|
|
252
|
+
return lines
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── drivers ──────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def generate(
|
|
259
|
+
spec: type[S],
|
|
260
|
+
ctx: Any,
|
|
261
|
+
model: Model,
|
|
262
|
+
*,
|
|
263
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
264
|
+
candidates: int = 1,
|
|
265
|
+
prompt_builder: PromptBuilder | None = None,
|
|
266
|
+
system: str | None = None,
|
|
267
|
+
targeted_repair: bool = True,
|
|
268
|
+
use_declared_repairs: bool = True,
|
|
269
|
+
) -> tuple[S, Report]:
|
|
270
|
+
"""Produce an object satisfying every rule, or raise.
|
|
271
|
+
|
|
272
|
+
``candidates`` above 1 runs the loop that many times and keeps the
|
|
273
|
+
rule-valid result with the lowest soft score. It multiplies cost, so it is
|
|
274
|
+
off by default and only earns its keep when the spec declares objectives.
|
|
275
|
+
"""
|
|
276
|
+
picker: _Picker[S] = _Picker(ctx, candidates)
|
|
277
|
+
|
|
278
|
+
for _ in range(candidates):
|
|
279
|
+
session = Session(
|
|
280
|
+
spec,
|
|
281
|
+
ctx,
|
|
282
|
+
max_attempts=max_attempts,
|
|
283
|
+
prompt_builder=prompt_builder,
|
|
284
|
+
system=system,
|
|
285
|
+
targeted_repair=targeted_repair,
|
|
286
|
+
use_declared_repairs=use_declared_repairs,
|
|
287
|
+
)
|
|
288
|
+
while (request := session.next_request()) is not None:
|
|
289
|
+
raw, usage = model.generate_structured(request)
|
|
290
|
+
session.feed(raw, usage)
|
|
291
|
+
picker.offer(session)
|
|
292
|
+
|
|
293
|
+
return picker.best()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def agenerate(
|
|
297
|
+
spec: type[S],
|
|
298
|
+
ctx: Any,
|
|
299
|
+
model: AsyncModel,
|
|
300
|
+
*,
|
|
301
|
+
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
|
|
302
|
+
candidates: int = 1,
|
|
303
|
+
prompt_builder: PromptBuilder | None = None,
|
|
304
|
+
system: str | None = None,
|
|
305
|
+
targeted_repair: bool = True,
|
|
306
|
+
use_declared_repairs: bool = True,
|
|
307
|
+
) -> tuple[S, Report]:
|
|
308
|
+
"""``generate``, awaited. Candidates run sequentially, not concurrently:
|
|
309
|
+
a later candidate is only worth paying for if the earlier ones were poor,
|
|
310
|
+
and firing them all at once would remove that option."""
|
|
311
|
+
picker: _Picker[S] = _Picker(ctx, candidates)
|
|
312
|
+
|
|
313
|
+
for _ in range(candidates):
|
|
314
|
+
session = Session(
|
|
315
|
+
spec,
|
|
316
|
+
ctx,
|
|
317
|
+
max_attempts=max_attempts,
|
|
318
|
+
prompt_builder=prompt_builder,
|
|
319
|
+
system=system,
|
|
320
|
+
targeted_repair=targeted_repair,
|
|
321
|
+
use_declared_repairs=use_declared_repairs,
|
|
322
|
+
)
|
|
323
|
+
while (request := session.next_request()) is not None:
|
|
324
|
+
raw, usage = await model.generate_structured(request)
|
|
325
|
+
session.feed(raw, usage)
|
|
326
|
+
picker.offer(session)
|
|
327
|
+
|
|
328
|
+
return picker.best()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class _Picker(Generic[S]):
|
|
332
|
+
"""Keeps the best candidate and the full cost of finding it."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, ctx: Any, candidates: int) -> None:
|
|
335
|
+
self.ctx = ctx
|
|
336
|
+
self.candidates = candidates
|
|
337
|
+
self.attempts: list[Attempt] = []
|
|
338
|
+
self.best_result: tuple[S, Report] | None = None
|
|
339
|
+
self.best_score = math.inf
|
|
340
|
+
self.last_error: FormworkError | None = None
|
|
341
|
+
|
|
342
|
+
def offer(self, session: Session[S]) -> None:
|
|
343
|
+
try:
|
|
344
|
+
instance, report = session.finish()
|
|
345
|
+
except FormworkError as error:
|
|
346
|
+
self.last_error = error
|
|
347
|
+
self.attempts.extend(session.report.attempts)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
score, _ = instance.score(self.ctx)
|
|
351
|
+
self.attempts.extend(report.attempts)
|
|
352
|
+
if score < self.best_score:
|
|
353
|
+
self.best_score = score
|
|
354
|
+
self.best_result = (instance, report)
|
|
355
|
+
|
|
356
|
+
def best(self) -> tuple[S, Report]:
|
|
357
|
+
if self.best_result is None:
|
|
358
|
+
assert self.last_error is not None
|
|
359
|
+
# Re-raise with the cost of every candidate, not just the last.
|
|
360
|
+
self.last_error.report.attempts = self.attempts
|
|
361
|
+
self.last_error.report.candidates_considered = self.candidates
|
|
362
|
+
raise self.last_error
|
|
363
|
+
|
|
364
|
+
instance, report = self.best_result
|
|
365
|
+
report.attempts = self.attempts
|
|
366
|
+
report.candidates_considered = self.candidates
|
|
367
|
+
return instance, report
|
formwork/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Failures the caller is expected to handle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from formwork.rules import Violation
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from formwork.report import Report
|
|
11
|
+
|
|
12
|
+
__all__ = ["FormworkError", "ConstraintError", "StructuralError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FormworkError(Exception):
|
|
16
|
+
"""Base class.
|
|
17
|
+
|
|
18
|
+
Every failure carries the report, so a caller that only catches the base
|
|
19
|
+
class can still bill the tokens the attempt cost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
report: Report
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConstraintError(FormworkError):
|
|
26
|
+
"""The engine could not produce an object satisfying every rule.
|
|
27
|
+
|
|
28
|
+
This is the load-bearing guarantee: the alternative to raising here would
|
|
29
|
+
be returning something that breaks a rule you declared, and a library that
|
|
30
|
+
does that is worse than no library, because you would have stopped
|
|
31
|
+
checking.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, violations: list[Violation], report: Report) -> None:
|
|
35
|
+
self.violations = violations
|
|
36
|
+
self.report = report
|
|
37
|
+
detail = "; ".join(str(v) for v in violations) or "unknown"
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"gave up after {report.model_calls} model call(s): {detail}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StructuralError(FormworkError):
|
|
44
|
+
"""The model never produced output matching the schema."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, errors: list[str], report: Report) -> None:
|
|
47
|
+
self.errors = errors
|
|
48
|
+
self.report = report
|
|
49
|
+
detail = "; ".join(errors) or "unknown"
|
|
50
|
+
super().__init__(
|
|
51
|
+
f"schema never satisfied after {report.model_calls} model call(s): {detail}"
|
|
52
|
+
)
|
formwork/fields.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Field roles — who owns which part of the output.
|
|
2
|
+
|
|
3
|
+
This is the idea the rest of the library is built around. In a generation task
|
|
4
|
+
with real domain logic, some fields are *decisions your code already knows how
|
|
5
|
+
to make* and some are *the reason you reached for a model in the first place*.
|
|
6
|
+
Mixing them into one prompt and hoping the model respects the first kind is the
|
|
7
|
+
default approach, and it is why these features are flaky.
|
|
8
|
+
|
|
9
|
+
So each field declares a role:
|
|
10
|
+
|
|
11
|
+
``computed`` your code fills it, before the model is called; the model never
|
|
12
|
+
sees it as a choice, only as a stated fact.
|
|
13
|
+
``chosen`` the model picks, but from a closed set you supply at runtime.
|
|
14
|
+
``generated`` the model is free.
|
|
15
|
+
|
|
16
|
+
The practical consequence is that the schema sent to the model contains only
|
|
17
|
+
the ``chosen`` and ``generated`` fields. A field the model cannot see is a
|
|
18
|
+
field it cannot get wrong.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = ["Role", "FieldSpec", "computed", "chosen", "generated"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Role(StrEnum):
|
|
32
|
+
COMPUTED = "computed"
|
|
33
|
+
CHOSEN = "chosen"
|
|
34
|
+
GENERATED = "generated"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class FieldSpec:
|
|
39
|
+
"""Metadata attached to a field through ``Annotated``."""
|
|
40
|
+
|
|
41
|
+
role: Role
|
|
42
|
+
resolver: Callable[[Any], Any] | None = None
|
|
43
|
+
source: str | None = None
|
|
44
|
+
key: str | None = None
|
|
45
|
+
describe: str | None = None
|
|
46
|
+
max_items: int | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def model_facing(self) -> bool:
|
|
50
|
+
"""Does this field appear in the schema handed to the model?"""
|
|
51
|
+
return self.role is not Role.COMPUTED
|
|
52
|
+
|
|
53
|
+
def allowed_values(self, ctx: Any) -> tuple[Any, ...] | None:
|
|
54
|
+
"""The closed set for a ``chosen`` field, read off the context.
|
|
55
|
+
|
|
56
|
+
Returned as a tuple so callers can put it in a prompt, hand it to a
|
|
57
|
+
grammar backend, or check membership — the three things anyone wants to
|
|
58
|
+
do with a closed set.
|
|
59
|
+
"""
|
|
60
|
+
if self.role is not Role.CHOSEN or self.source is None:
|
|
61
|
+
return None
|
|
62
|
+
pool = _read_source(ctx, self.source)
|
|
63
|
+
if self.key is None:
|
|
64
|
+
return tuple(pool)
|
|
65
|
+
return tuple(_read_source(item, self.key) for item in pool)
|
|
66
|
+
|
|
67
|
+
def resolve(self, ctx: Any) -> Any:
|
|
68
|
+
if self.resolver is None:
|
|
69
|
+
raise ValueError("computed field has no resolver")
|
|
70
|
+
return self.resolver(ctx)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_source(obj: Any, path: str) -> Any:
|
|
74
|
+
"""Read ``path`` off a context object, tolerating dicts and attributes.
|
|
75
|
+
|
|
76
|
+
Contexts in the wild are ORM rows, dataclasses, Pydantic models and plain
|
|
77
|
+
dicts in roughly equal measure; refusing three of those would be a silly
|
|
78
|
+
reason for a library to be unusable.
|
|
79
|
+
"""
|
|
80
|
+
current = obj
|
|
81
|
+
for part in path.split("."):
|
|
82
|
+
if isinstance(current, dict):
|
|
83
|
+
if part not in current:
|
|
84
|
+
raise KeyError(f"context has no key {path!r}")
|
|
85
|
+
current = current[part]
|
|
86
|
+
else:
|
|
87
|
+
if not hasattr(current, part):
|
|
88
|
+
raise AttributeError(f"context has no attribute {path!r}")
|
|
89
|
+
current = getattr(current, part)
|
|
90
|
+
return current
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def computed(resolver: Callable[[Any], Any], *, describe: str | None = None) -> FieldSpec:
|
|
94
|
+
"""Your code owns this field.
|
|
95
|
+
|
|
96
|
+
``resolver`` is called with the context before the model runs. The value is
|
|
97
|
+
stated to the model as a fact so it can write around it, but the model is
|
|
98
|
+
never asked to produce it.
|
|
99
|
+
"""
|
|
100
|
+
return FieldSpec(role=Role.COMPUTED, resolver=resolver, describe=describe)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def chosen(
|
|
104
|
+
*,
|
|
105
|
+
source: str,
|
|
106
|
+
key: str | None = None,
|
|
107
|
+
describe: str | None = None,
|
|
108
|
+
max_items: int | None = None,
|
|
109
|
+
) -> FieldSpec:
|
|
110
|
+
"""The model picks from a closed set.
|
|
111
|
+
|
|
112
|
+
``source`` is a dotted path into the context holding the allowed pool.
|
|
113
|
+
``key`` is for the common case where the field is a list of objects and it
|
|
114
|
+
is one attribute of each — an id — that must come from the pool, while the
|
|
115
|
+
rest of the object is generated.
|
|
116
|
+
"""
|
|
117
|
+
return FieldSpec(
|
|
118
|
+
role=Role.CHOSEN,
|
|
119
|
+
source=source,
|
|
120
|
+
key=key,
|
|
121
|
+
describe=describe,
|
|
122
|
+
max_items=max_items,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generated(*, describe: str | None = None, max_items: int | None = None) -> FieldSpec:
|
|
127
|
+
"""The model is free within the type."""
|
|
128
|
+
return FieldSpec(role=Role.GENERATED, describe=describe, max_items=max_items)
|