prettyplay 0.0.2__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.
- prettyplay/.usages/lifecycle.md +117 -0
- prettyplay/.usages/steps.md +169 -0
- prettyplay/__init__.py +18 -0
- prettyplay/cache/.usages/addressing.md +44 -0
- prettyplay/cache/.usages/budgets.md +51 -0
- prettyplay/cache/.usages/storage.md +32 -0
- prettyplay/cache/__init__.py +8 -0
- prettyplay/cache/budgets.py +93 -0
- prettyplay/cache/models.py +55 -0
- prettyplay/cache/store.py +276 -0
- prettyplay/cache/text.py +36 -0
- prettyplay/config/.usages/configuration.md +246 -0
- prettyplay/config/__init__.py +6 -0
- prettyplay/config/loader.py +336 -0
- prettyplay/config/models.py +288 -0
- prettyplay/driver/.usages/error_kinds.md +26 -0
- prettyplay/driver/.usages/plumbing.md +52 -0
- prettyplay/driver/__init__.py +11 -0
- prettyplay/driver/errors.py +76 -0
- prettyplay/driver/page.py +175 -0
- prettyplay/driver/session.py +330 -0
- prettyplay/engine/.usages/generation.md +157 -0
- prettyplay/engine/.usages/healing.md +61 -0
- prettyplay/engine/__init__.py +19 -0
- prettyplay/engine/attempts.py +80 -0
- prettyplay/engine/classification.py +70 -0
- prettyplay/engine/compliance.py +125 -0
- prettyplay/engine/execution.py +48 -0
- prettyplay/engine/generator.py +1110 -0
- prettyplay/engine/groups/.usages/recovery.md +71 -0
- prettyplay/engine/groups/__init__.py +7 -0
- prettyplay/engine/groups/diagnosis.py +128 -0
- prettyplay/engine/groups/outcome.py +55 -0
- prettyplay/engine/groups/recovery.py +491 -0
- prettyplay/engine/healer.py +149 -0
- prettyplay/engine/polling/.usages/settle.md +55 -0
- prettyplay/engine/polling/__init__.py +6 -0
- prettyplay/engine/polling/settle.py +98 -0
- prettyplay/engine/polling/window.py +72 -0
- prettyplay/engine/renderer/.usages/memory.md +24 -0
- prettyplay/engine/renderer/.usages/rendering.md +44 -0
- prettyplay/engine/renderer/.usages/validation.md +20 -0
- prettyplay/engine/renderer/__init__.py +7 -0
- prettyplay/engine/renderer/memory.py +37 -0
- prettyplay/engine/renderer/render.py +184 -0
- prettyplay/engine/renderer/validation.py +76 -0
- prettyplay/engine/steering/.usages/steering.md +95 -0
- prettyplay/engine/steering/__init__.py +5 -0
- prettyplay/engine/steering/steering.py +765 -0
- prettyplay/engine/text.py +41 -0
- prettyplay/executor.py +654 -0
- prettyplay/failures/.usages/taxonomy.md +131 -0
- prettyplay/failures/__init__.py +25 -0
- prettyplay/failures/errors.py +359 -0
- prettyplay/groups.py +276 -0
- prettyplay/llm/.usages/classification.md +31 -0
- prettyplay/llm/.usages/providers.md +127 -0
- prettyplay/llm/.usages/transport.md +81 -0
- prettyplay/llm/__init__.py +38 -0
- prettyplay/llm/_request.py +823 -0
- prettyplay/llm/anthropic_provider.py +468 -0
- prettyplay/llm/models.py +273 -0
- prettyplay/llm/openai_provider.py +444 -0
- prettyplay/llm/provider.py +279 -0
- prettyplay/reporting/.usages/hooks.md +61 -0
- prettyplay/reporting/__init__.py +6 -0
- prettyplay/reporting/hooks.py +47 -0
- prettyplay/reporting/reporter.py +70 -0
- prettyplay/runtime.py +81 -0
- prettyplay/scenario.py +469 -0
- prettyplay-0.0.2.dist-info/METADATA +515 -0
- prettyplay-0.0.2.dist-info/RECORD +75 -0
- prettyplay-0.0.2.dist-info/WHEEL +5 -0
- prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
- prettyplay-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""Shared request-field building and transport failure classification for the LLM providers."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import NamedTuple, TypeVar
|
|
11
|
+
|
|
12
|
+
import anthropic
|
|
13
|
+
import openai
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
from ..failures import LLMUnavailableError
|
|
17
|
+
from .models import ScenarioStep
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("prettyplay")
|
|
20
|
+
|
|
21
|
+
#: The response type of one SDK request send — the generic pass-through type of the retry loop.
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
#: The labels a classification category may take.
|
|
25
|
+
CATEGORY_ROT = "rot"
|
|
26
|
+
CATEGORY_PRODUCT_DEFECT = "product_defect"
|
|
27
|
+
CATEGORY_FIXABLE = "fixable"
|
|
28
|
+
CATEGORY_INCURABLE = "incurable"
|
|
29
|
+
|
|
30
|
+
#: The frozen set of the four classification labels.
|
|
31
|
+
CATEGORIES = frozenset({CATEGORY_ROT, CATEGORY_PRODUCT_DEFECT, CATEGORY_FIXABLE, CATEGORY_INCURABLE})
|
|
32
|
+
|
|
33
|
+
#: The transport failure labels of the retryable family.
|
|
34
|
+
TRANSPORT_CONNECTION = "connection"
|
|
35
|
+
TRANSPORT_TIMEOUT = "timeout"
|
|
36
|
+
TRANSPORT_RATE_LIMIT = "rate_limit"
|
|
37
|
+
TRANSPORT_SERVER_ERROR = "server_error"
|
|
38
|
+
|
|
39
|
+
#: The transport failure labels of the permanent family.
|
|
40
|
+
TRANSPORT_AUTHENTICATION = "authentication"
|
|
41
|
+
TRANSPORT_PERMISSION_DENIED = "permission_denied"
|
|
42
|
+
TRANSPORT_INVALID_REQUEST = "invalid_request"
|
|
43
|
+
TRANSPORT_NOT_FOUND = "not_found"
|
|
44
|
+
TRANSPORT_QUOTA_EXHAUSTED = "quota_exhausted"
|
|
45
|
+
|
|
46
|
+
#: The frozen set of the four retryable transport labels.
|
|
47
|
+
RETRYABLE_TRANSPORT_CATEGORIES = frozenset(
|
|
48
|
+
{TRANSPORT_CONNECTION, TRANSPORT_TIMEOUT, TRANSPORT_RATE_LIMIT, TRANSPORT_SERVER_ERROR}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: The frozen set of the five permanent transport labels.
|
|
52
|
+
PERMANENT_TRANSPORT_CATEGORIES = frozenset(
|
|
53
|
+
{
|
|
54
|
+
TRANSPORT_AUTHENTICATION,
|
|
55
|
+
TRANSPORT_PERMISSION_DENIED,
|
|
56
|
+
TRANSPORT_INVALID_REQUEST,
|
|
57
|
+
TRANSPORT_NOT_FOUND,
|
|
58
|
+
TRANSPORT_QUOTA_EXHAUSTED,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: The HTTP status of a request timeout — neither SDK carries a 408 subclass.
|
|
63
|
+
_STATUS_REQUEST_TIMEOUT = 408
|
|
64
|
+
|
|
65
|
+
#: The HTTP status of a rate-limited request.
|
|
66
|
+
_STATUS_RATE_LIMIT = 429
|
|
67
|
+
|
|
68
|
+
#: The inclusive bounds of the HTTP server-error family.
|
|
69
|
+
_STATUS_SERVER_ERROR_FLOOR = 500
|
|
70
|
+
_STATUS_SERVER_ERROR_CEILING = 599
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _TransportLadder(NamedTuple):
|
|
74
|
+
"""The per-SDK exception classes and quota labels of one classification ladder."""
|
|
75
|
+
|
|
76
|
+
status_error: type[Exception]
|
|
77
|
+
timeout_error: type[Exception]
|
|
78
|
+
connection_error: type[Exception]
|
|
79
|
+
rate_limit_error: type[Exception]
|
|
80
|
+
permanent: tuple[tuple[type[Exception], str], ...]
|
|
81
|
+
quota_labels: frozenset[str]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
#: The openai evidence ladder — the quota body labels and the permanent classes checked first.
|
|
85
|
+
_OPENAI_LADDER = _TransportLadder(
|
|
86
|
+
status_error=openai.APIStatusError,
|
|
87
|
+
timeout_error=openai.APITimeoutError,
|
|
88
|
+
connection_error=openai.APIConnectionError,
|
|
89
|
+
rate_limit_error=openai.RateLimitError,
|
|
90
|
+
permanent=(
|
|
91
|
+
(openai.AuthenticationError, TRANSPORT_AUTHENTICATION),
|
|
92
|
+
(openai.PermissionDeniedError, TRANSPORT_PERMISSION_DENIED),
|
|
93
|
+
(openai.BadRequestError, TRANSPORT_INVALID_REQUEST),
|
|
94
|
+
(openai.NotFoundError, TRANSPORT_NOT_FOUND),
|
|
95
|
+
),
|
|
96
|
+
quota_labels=frozenset({"insufficient_quota"}),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
#: The anthropic evidence ladder — billing_error is the explicit quota signal of the SDK.
|
|
100
|
+
_ANTHROPIC_LADDER = _TransportLadder(
|
|
101
|
+
status_error=anthropic.APIStatusError,
|
|
102
|
+
timeout_error=anthropic.APITimeoutError,
|
|
103
|
+
connection_error=anthropic.APIConnectionError,
|
|
104
|
+
rate_limit_error=anthropic.RateLimitError,
|
|
105
|
+
permanent=(
|
|
106
|
+
(anthropic.AuthenticationError, TRANSPORT_AUTHENTICATION),
|
|
107
|
+
(anthropic.PermissionDeniedError, TRANSPORT_PERMISSION_DENIED),
|
|
108
|
+
(anthropic.BadRequestError, TRANSPORT_INVALID_REQUEST),
|
|
109
|
+
(anthropic.NotFoundError, TRANSPORT_NOT_FOUND),
|
|
110
|
+
),
|
|
111
|
+
quota_labels=frozenset({"billing_error"}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TransportFailureClassification(BaseModel):
|
|
116
|
+
"""The verdict of one transport failure classification of an LLM request.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
category: the transport failure label — one of the closed nine-label
|
|
120
|
+
set; the retryable family: connection, timeout, rate_limit and
|
|
121
|
+
server_error; the permanent family: authentication,
|
|
122
|
+
permission_denied, invalid_request, not_found and
|
|
123
|
+
quota_exhausted; the classifiers are the only producers, so the
|
|
124
|
+
label is always one of the nine — an unrecognized failure
|
|
125
|
+
classifies permanent before reaching this type.
|
|
126
|
+
retry_after: the parsed Retry-After seconds of the failure response;
|
|
127
|
+
None — the header is absent or malformed.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
model_config = ConfigDict(kw_only=True)
|
|
131
|
+
|
|
132
|
+
category: str = ""
|
|
133
|
+
retry_after: float | None = None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def retryable(self) -> bool:
|
|
137
|
+
"""Return whether the category belongs to the retryable family.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True for the retryable labels — the retry-or-raise decision of
|
|
141
|
+
the retry loop; False for the permanent labels and the empty
|
|
142
|
+
default category.
|
|
143
|
+
"""
|
|
144
|
+
return self.category in RETRYABLE_TRANSPORT_CATEGORIES
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def classify_openai_failure(error: Exception) -> TransportFailureClassification:
|
|
148
|
+
"""Classify one failed openai request send into a transport verdict.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
error: the exception raised by one openai SDK request send; any
|
|
152
|
+
exception classifies — a never-recognized failure is never
|
|
153
|
+
blindly retried.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The transport verdict. The permanent evidence matches first — an
|
|
157
|
+
explicitly exhausted quota (the insufficient_quota body evidence,
|
|
158
|
+
even under a retryable status such as 429), authentication failure,
|
|
159
|
+
permission denial, invalid request and not found; a permanent cause
|
|
160
|
+
always wins over a retryable status. Otherwise the retryable
|
|
161
|
+
evidence matches — timeout, connection failure, rate limit, HTTP
|
|
162
|
+
408 and any HTTP status 500 through 599. An exception matching no
|
|
163
|
+
known signal classifies invalid_request. The verdict carries the
|
|
164
|
+
parsed decimal Retry-After seconds of the failure response — None
|
|
165
|
+
when the header is absent or malformed.
|
|
166
|
+
"""
|
|
167
|
+
return TransportFailureClassification(
|
|
168
|
+
category=_transport_category(error, _OPENAI_LADDER),
|
|
169
|
+
retry_after=_extract_retry_after(error),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def classify_anthropic_failure(error: Exception) -> TransportFailureClassification:
|
|
174
|
+
"""Classify one failed anthropic request send into a transport verdict.
|
|
175
|
+
|
|
176
|
+
The ladder mirrors ``classify_openai_failure`` rule by rule onto the
|
|
177
|
+
anthropic exception hierarchy — the same nine-label set, the same
|
|
178
|
+
permanent-precedence rule, the same Retry-After handling; the quota
|
|
179
|
+
evidence of this SDK is the billing_error body label.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
error: the exception raised by one anthropic SDK request send; any
|
|
183
|
+
exception classifies — a never-recognized failure is never
|
|
184
|
+
blindly retried.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The transport verdict. The permanent evidence matches first — an
|
|
188
|
+
explicitly exhausted quota (the billing_error body evidence, even
|
|
189
|
+
under a retryable status such as 429), authentication failure,
|
|
190
|
+
permission denial, invalid request and not found; a permanent cause
|
|
191
|
+
always wins over a retryable status. Otherwise the retryable
|
|
192
|
+
evidence matches — timeout, connection failure, rate limit, HTTP
|
|
193
|
+
408 and any HTTP status 500 through 599 (the anthropic 529/503/504
|
|
194
|
+
responses included). An exception matching no known signal
|
|
195
|
+
classifies invalid_request. The verdict carries the parsed decimal
|
|
196
|
+
Retry-After seconds of the failure response — None when the header
|
|
197
|
+
is absent or malformed (the non-standard retry-after-ms variant
|
|
198
|
+
stays unparsed).
|
|
199
|
+
"""
|
|
200
|
+
return TransportFailureClassification(
|
|
201
|
+
category=_transport_category(error, _ANTHROPIC_LADDER),
|
|
202
|
+
retry_after=_extract_retry_after(error),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _transport_category(error: Exception, ladder: _TransportLadder) -> str:
|
|
207
|
+
"""Decide the transport category of one exception through the evidence ladder.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
error: the exception raised by one SDK request send.
|
|
211
|
+
ladder: the per-SDK evidence classes and quota labels.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The permanent label when permanent evidence matches — always
|
|
215
|
+
first; otherwise the retryable label when retryable evidence
|
|
216
|
+
matches; otherwise invalid_request — the permanent fallback.
|
|
217
|
+
"""
|
|
218
|
+
permanent = _permanent_transport_category(error, ladder)
|
|
219
|
+
|
|
220
|
+
if permanent is not None:
|
|
221
|
+
return permanent
|
|
222
|
+
|
|
223
|
+
retryable = _retryable_transport_category(error, ladder)
|
|
224
|
+
|
|
225
|
+
return retryable if retryable is not None else TRANSPORT_INVALID_REQUEST
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _permanent_transport_category(error: Exception, ladder: _TransportLadder) -> str | None:
|
|
229
|
+
"""Match the permanent evidence first; a permanent cause always wins over a retryable status."""
|
|
230
|
+
if not isinstance(error, ladder.status_error):
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
body_evidence = [getattr(error, "code", None), getattr(error, "type", None)]
|
|
234
|
+
body = getattr(error, "body", None)
|
|
235
|
+
|
|
236
|
+
if isinstance(body, dict):
|
|
237
|
+
body_evidence.extend((body.get("code"), body.get("type")))
|
|
238
|
+
nested_error = body.get("error")
|
|
239
|
+
|
|
240
|
+
if isinstance(nested_error, dict):
|
|
241
|
+
body_evidence.extend((nested_error.get("code"), nested_error.get("type")))
|
|
242
|
+
|
|
243
|
+
if any(isinstance(label, str) and label in ladder.quota_labels for label in body_evidence):
|
|
244
|
+
return TRANSPORT_QUOTA_EXHAUSTED
|
|
245
|
+
|
|
246
|
+
for error_class, category in ladder.permanent:
|
|
247
|
+
if isinstance(error, error_class):
|
|
248
|
+
return category
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _retryable_transport_category(error: Exception, ladder: _TransportLadder) -> str | None:
|
|
254
|
+
"""Match the retryable evidence; None — the exception carries no retryable signal."""
|
|
255
|
+
if isinstance(error, ladder.status_error):
|
|
256
|
+
if isinstance(error, ladder.rate_limit_error) or getattr(error, "status_code", None) == _STATUS_RATE_LIMIT:
|
|
257
|
+
return TRANSPORT_RATE_LIMIT
|
|
258
|
+
|
|
259
|
+
status = getattr(error, "status_code", None)
|
|
260
|
+
|
|
261
|
+
if status == _STATUS_REQUEST_TIMEOUT:
|
|
262
|
+
return TRANSPORT_TIMEOUT
|
|
263
|
+
if isinstance(status, int) and _STATUS_SERVER_ERROR_FLOOR <= status <= _STATUS_SERVER_ERROR_CEILING:
|
|
264
|
+
return TRANSPORT_SERVER_ERROR
|
|
265
|
+
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
transport_errors = ((ladder.timeout_error, TRANSPORT_TIMEOUT), (ladder.connection_error, TRANSPORT_CONNECTION))
|
|
269
|
+
|
|
270
|
+
for error_class, category in transport_errors:
|
|
271
|
+
if isinstance(error, error_class):
|
|
272
|
+
return category
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _extract_retry_after(error: Exception) -> float | None:
|
|
278
|
+
"""Extract the decimal Retry-After seconds of the failure response.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
error: the exception raised by one SDK request send.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The parsed header seconds, or None when the failure response, the
|
|
285
|
+
header or the decimal value is absent — HTTP-dates and the
|
|
286
|
+
anthropic retry-after-ms variant stay unparsed; the extraction is
|
|
287
|
+
category-independent.
|
|
288
|
+
"""
|
|
289
|
+
response = getattr(error, "response", None)
|
|
290
|
+
headers = getattr(response, "headers", None)
|
|
291
|
+
get_header = getattr(headers, "get", None)
|
|
292
|
+
|
|
293
|
+
if get_header is None:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
seconds = float(get_header("retry-after"))
|
|
298
|
+
except (TypeError, ValueError):
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
return seconds if math.isfinite(seconds) else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
#: The inclusive cap of one transport retry pause, in seconds.
|
|
305
|
+
_TRANSPORT_PAUSE_CAP = 10.0
|
|
306
|
+
|
|
307
|
+
#: Attempt five is the first whose uncapped base delay exceeds the pause cap.
|
|
308
|
+
_TRANSPORT_BASE_CAP_ATTEMPT = 5
|
|
309
|
+
|
|
310
|
+
#: The jitter fraction of the base delay — random.uniform draws up to this share on top.
|
|
311
|
+
_TRANSPORT_JITTER_FRACTION = 0.25
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def compute_transport_pause(failed_attempt: int, retry_after: float | None) -> float:
|
|
315
|
+
"""Compute the wait before the next transport retry of one LLM request.
|
|
316
|
+
|
|
317
|
+
The base delay is one second doubled per prior failure, capped at ten —
|
|
318
|
+
the sequence 1, 2, 4, 8, 10, 10… seconds; a random jitter of up to a
|
|
319
|
+
quarter of the base is added and the result is capped back at ten
|
|
320
|
+
seconds. A valid Retry-After — positive and at most ten seconds —
|
|
321
|
+
raises the pause to the indicated wait when it exceeds the computed
|
|
322
|
+
delay; a value above ten never reaches this routine as a wait — the
|
|
323
|
+
retry loop terminates before computing a pause.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
failed_attempt: the one-based number of the send that just failed.
|
|
327
|
+
retry_after: the parsed Retry-After seconds of the failure
|
|
328
|
+
response; None or a non-positive value — no indicated wait.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The pause in seconds — a float in the interval (0, 10].
|
|
332
|
+
"""
|
|
333
|
+
base = _TRANSPORT_PAUSE_CAP if failed_attempt >= _TRANSPORT_BASE_CAP_ATTEMPT else 2.0 ** (failed_attempt - 1)
|
|
334
|
+
|
|
335
|
+
pause = min(base + random.uniform(0.0, base * _TRANSPORT_JITTER_FRACTION), _TRANSPORT_PAUSE_CAP)
|
|
336
|
+
|
|
337
|
+
if retry_after is not None and 0.0 < retry_after <= _TRANSPORT_PAUSE_CAP:
|
|
338
|
+
pause = max(pause, retry_after)
|
|
339
|
+
|
|
340
|
+
return pause
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def send_with_retries(
|
|
344
|
+
provider: str,
|
|
345
|
+
operation: str,
|
|
346
|
+
attempts: int,
|
|
347
|
+
classify: Callable[[Exception], TransportFailureClassification],
|
|
348
|
+
send: Callable[[], T],
|
|
349
|
+
) -> T:
|
|
350
|
+
"""Send one LLM request through the bounded transport retry loop.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
provider: the provider label for logs and errors — openai or
|
|
354
|
+
anthropic.
|
|
355
|
+
operation: the operation label for logs — generation,
|
|
356
|
+
classification, group diagnosis or compliance verdict.
|
|
357
|
+
attempts: the total send budget, the initial send included;
|
|
358
|
+
1 disables retries.
|
|
359
|
+
classify: the provider-specific transport failure classifier.
|
|
360
|
+
send: the closure performing exactly one SDK request.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The successful response of ``send``, passed through untouched.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
LLMUnavailableError: a permanent provider rejection, a
|
|
367
|
+
Retry-After above the ten second cap, or the exhaustion of
|
|
368
|
+
the send budget — always chained to the original SDK error.
|
|
369
|
+
"""
|
|
370
|
+
for attempt in range(1, attempts + 1):
|
|
371
|
+
try:
|
|
372
|
+
return send()
|
|
373
|
+
except Exception as error:
|
|
374
|
+
failure = classify(error)
|
|
375
|
+
|
|
376
|
+
if not failure.retryable:
|
|
377
|
+
raise LLMUnavailableError(
|
|
378
|
+
f"llm unavailable: {provider} request failed permanently: {failure.category}"
|
|
379
|
+
) from error
|
|
380
|
+
|
|
381
|
+
if failure.retry_after is not None and failure.retry_after > _TRANSPORT_PAUSE_CAP:
|
|
382
|
+
raise LLMUnavailableError(
|
|
383
|
+
f"llm unavailable: {provider} asked to wait {failure.retry_after:g} seconds"
|
|
384
|
+
" — above the 10 second retry cap, not retrying"
|
|
385
|
+
) from error
|
|
386
|
+
|
|
387
|
+
if attempt == attempts:
|
|
388
|
+
raise LLMUnavailableError(
|
|
389
|
+
f"llm unavailable: {provider} request failed after {attempts} attempts"
|
|
390
|
+
) from error
|
|
391
|
+
|
|
392
|
+
pause = compute_transport_pause(attempt, failure.retry_after)
|
|
393
|
+
|
|
394
|
+
logger.warning(
|
|
395
|
+
"llm request retry",
|
|
396
|
+
extra={
|
|
397
|
+
"provider": provider,
|
|
398
|
+
"operation": operation,
|
|
399
|
+
"attempt": attempt,
|
|
400
|
+
"category": failure.category,
|
|
401
|
+
"delay": pause,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
time.sleep(pause)
|
|
406
|
+
|
|
407
|
+
raise AssertionError(f"the retry loop of {provider} {operation} always returns or raises")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
#: Field count of the one-line classification verdict.
|
|
411
|
+
VERDICT_FIELD_COUNT = 3
|
|
412
|
+
|
|
413
|
+
#: A fenced completion block: three backticks, an optional language tag, the body, the closing fence.
|
|
414
|
+
_FENCED_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*[ \t]*\r?\n(.*?)```", re.DOTALL)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def extract_code_block(answer: str) -> str:
|
|
418
|
+
"""Return the step code of a completion, unwrapping the markdown fence.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
answer: the non-empty completion text of a generation request.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
The code of the fixed form: the body of the first fenced block, or
|
|
425
|
+
the answer itself when it carries no closed fence — an unfenced
|
|
426
|
+
code answer stays executable.
|
|
427
|
+
"""
|
|
428
|
+
match = _FENCED_BLOCK.search(answer)
|
|
429
|
+
|
|
430
|
+
if match is None:
|
|
431
|
+
return answer
|
|
432
|
+
|
|
433
|
+
return match.group(1)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def require_completion_text(text: str | None, provider: str) -> str:
|
|
437
|
+
"""Return the completion text, refusing an empty answer as a service failure.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
text: the raw text extracted from the provider response; ``None`` or an
|
|
441
|
+
empty string means the service returned no completion body.
|
|
442
|
+
provider: the provider name for the failure message.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
The non-empty completion text.
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
LLMUnavailableError: the completion body is missing — a null/empty
|
|
449
|
+
content is an infrastructure shape, not a step verdict, so it maps
|
|
450
|
+
to the same taxonomy as any other service failure.
|
|
451
|
+
"""
|
|
452
|
+
if not text:
|
|
453
|
+
raise LLMUnavailableError(f"llm unavailable: {provider} returned empty completion")
|
|
454
|
+
|
|
455
|
+
return text
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_fields_text( # noqa: PLR0913, PLR0917 — the parameters mirror the fixed port signature
|
|
459
|
+
user_instructions: str,
|
|
460
|
+
instruction: str,
|
|
461
|
+
step_type: str,
|
|
462
|
+
previous_steps: list[ScenarioStep],
|
|
463
|
+
group_prompt: str | None,
|
|
464
|
+
inputs: dict[str, str],
|
|
465
|
+
declarations: list[str],
|
|
466
|
+
snapshot: str,
|
|
467
|
+
page_url: str | None,
|
|
468
|
+
cheat_sheet: str,
|
|
469
|
+
attempt_history: list[str],
|
|
470
|
+
recommendation: str | None,
|
|
471
|
+
guidance: str | None,
|
|
472
|
+
) -> str:
|
|
473
|
+
"""Build the plain-text generation request fields shared by both providers.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
user_instructions: the project's code style instructions from the
|
|
477
|
+
generation_prompt setting; empty — the request carries no
|
|
478
|
+
instructions block, non-empty — rendered verbatim as a separate
|
|
479
|
+
USER INSTRUCTIONS block after the CHEAT SHEET block.
|
|
480
|
+
instruction: the prepared instruction of the step — the rendered
|
|
481
|
+
plain-text sentence with actual values embedded; rendered as
|
|
482
|
+
the STEP block; the raw template sentence never reaches the
|
|
483
|
+
request.
|
|
484
|
+
step_type: the type of the step; rendered as a STEP TYPE line
|
|
485
|
+
immediately before the STEP line, inside the scenario section.
|
|
486
|
+
previous_steps: the typed scenario records of the previous steps of
|
|
487
|
+
the test, in execution order — each entry renders its prepared
|
|
488
|
+
instruction (recorded at execution) plus its permanent group
|
|
489
|
+
membership; an entry carrying a group prompt renders marked as
|
|
490
|
+
a group step, an ordinary entry renders its instruction alone,
|
|
491
|
+
identically whether or not this request carries a group
|
|
492
|
+
framing.
|
|
493
|
+
group_prompt: the group prompt of the current step's group; None —
|
|
494
|
+
an ordinary step, no GROUP PROMPT block; non-empty — rendered
|
|
495
|
+
verbatim as a separate GROUP PROMPT block immediately before
|
|
496
|
+
the PREVIOUS STEPS block.
|
|
497
|
+
inputs: the call-local input bindings of the step; non-empty —
|
|
498
|
+
rendered as the INPUTS block immediately after the STEP line,
|
|
499
|
+
one ``name = value`` line per binding; empty — no block.
|
|
500
|
+
declarations: the declared result names of the step; non-empty —
|
|
501
|
+
rendered as the RESULTS block immediately after the INPUTS
|
|
502
|
+
block stating the result contract — the code returns a
|
|
503
|
+
dictionary of exactly the declared names to non-blank strings
|
|
504
|
+
observed on the page; empty — no block, the
|
|
505
|
+
success-without-result code form.
|
|
506
|
+
snapshot: the accessibility snapshot of the current page.
|
|
507
|
+
page_url: the current URL of the page; non-empty — rendered as its
|
|
508
|
+
own PAGE URL line immediately after the PAGE SNAPSHOT section;
|
|
509
|
+
None or empty — no line.
|
|
510
|
+
cheat_sheet: the compact standard Playwright sync API reference
|
|
511
|
+
supplied by the calling engine — guidance, not an allowlist.
|
|
512
|
+
attempt_history: the rendered per-step attempt records — every
|
|
513
|
+
record a complete multi-line verbatim record composed by the
|
|
514
|
+
calling engine, the original cached code anchored as record 0
|
|
515
|
+
when it exists, the last record the code being fixed;
|
|
516
|
+
non-empty — rendered as a separate HISTORY block after the
|
|
517
|
+
USER INSTRUCTIONS block with the records joined by newlines,
|
|
518
|
+
every record verbatim, no collapsing, no size limits; empty —
|
|
519
|
+
no block; the list takes the place of the former
|
|
520
|
+
existing_code, error and guidance_history inputs.
|
|
521
|
+
recommendation: the diagnosis of the classification that preceded
|
|
522
|
+
the regeneration; non-empty — rendered as a separate
|
|
523
|
+
RECOMMENDATION block after the HISTORY block, None — no block.
|
|
524
|
+
guidance: the engineer guidance message of the interactive steering;
|
|
525
|
+
non-empty — rendered as a separate USER GUIDANCE block, None — no
|
|
526
|
+
block.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
The request fields as one text with the STEP TYPE line, the STEP
|
|
530
|
+
section, the optional INPUTS and RESULTS sections immediately after
|
|
531
|
+
it, the optional GROUP PROMPT section, the PREVIOUS STEPS /
|
|
532
|
+
PAGE SNAPSHOT sections, the optional PAGE URL line, the CHEAT SHEET
|
|
533
|
+
section and the optional USER INSTRUCTIONS / HISTORY /
|
|
534
|
+
RECOMMENDATION / USER GUIDANCE sections — a non-empty input renders
|
|
535
|
+
its named block.
|
|
536
|
+
"""
|
|
537
|
+
previous = _format_previous_steps(previous_steps)
|
|
538
|
+
|
|
539
|
+
sections = [
|
|
540
|
+
f"STEP TYPE: {step_type}\nSTEP:\n{instruction}",
|
|
541
|
+
*([_format_inputs(inputs)] if inputs else []),
|
|
542
|
+
*([_format_results(declarations)] if declarations else []),
|
|
543
|
+
*([f"GROUP PROMPT:\n{group_prompt}"] if group_prompt else []),
|
|
544
|
+
previous if previous is not None else "PREVIOUS STEPS:\n(none)",
|
|
545
|
+
f"PAGE SNAPSHOT:\n{snapshot}",
|
|
546
|
+
*([f"PAGE URL: {page_url}"] if page_url else []),
|
|
547
|
+
f"CHEAT SHEET:\n{cheat_sheet}",
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
if user_instructions:
|
|
551
|
+
sections.append(f"USER INSTRUCTIONS:\n{user_instructions}")
|
|
552
|
+
if attempt_history:
|
|
553
|
+
sections.append("HISTORY:\n" + "\n".join(attempt_history))
|
|
554
|
+
if recommendation:
|
|
555
|
+
sections.append(f"RECOMMENDATION:\n{recommendation}")
|
|
556
|
+
if guidance:
|
|
557
|
+
sections.append(f"USER GUIDANCE:\n{guidance}")
|
|
558
|
+
|
|
559
|
+
return "\n\n".join(sections)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def build_classification_fields(user_instructions: str, step_text: str, code: str, error: str, snapshot: str) -> str:
|
|
563
|
+
"""Build the plain-text classification request fields shared by both providers.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
user_instructions: the project's classification guidance from the
|
|
567
|
+
classification_prompt setting; empty — the request carries no
|
|
568
|
+
instructions block, non-empty — rendered verbatim as a separate
|
|
569
|
+
USER INSTRUCTIONS block placed last of the user content,
|
|
570
|
+
identically in both implementations.
|
|
571
|
+
step_text: the sentence of the failed step.
|
|
572
|
+
code: the existing step code that failed.
|
|
573
|
+
error: the human-readable description of the failure.
|
|
574
|
+
snapshot: the accessibility snapshot of the current page.
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
The request fields as one text with STEP / CODE / ERROR / PAGE
|
|
578
|
+
SNAPSHOT sections and the optional USER INSTRUCTIONS section last.
|
|
579
|
+
"""
|
|
580
|
+
sections = [
|
|
581
|
+
f"STEP:\n{step_text}",
|
|
582
|
+
f"CODE:\n{code}",
|
|
583
|
+
f"ERROR:\n{error}",
|
|
584
|
+
f"PAGE SNAPSHOT:\n{snapshot}",
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
if user_instructions:
|
|
588
|
+
sections.append(f"USER INSTRUCTIONS:\n{user_instructions}") # last
|
|
589
|
+
|
|
590
|
+
return "\n\n".join(sections)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def build_group_diagnosis_fields( # noqa: PLR0913, PLR0917 — the parameters mirror the fixed port signature
|
|
594
|
+
user_instructions: str,
|
|
595
|
+
group_prompt: str,
|
|
596
|
+
group_steps: list[str],
|
|
597
|
+
previous_steps: list[ScenarioStep],
|
|
598
|
+
step_text: str,
|
|
599
|
+
attempt_history: list[str],
|
|
600
|
+
snapshot: str,
|
|
601
|
+
) -> str:
|
|
602
|
+
"""Build the plain-text group diagnosis request fields shared by both providers.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
user_instructions: the project's classification guidance from the
|
|
606
|
+
classification_prompt setting; empty — the request carries no
|
|
607
|
+
instructions block, non-empty — rendered verbatim as a separate
|
|
608
|
+
USER INSTRUCTIONS block placed last of the user content,
|
|
609
|
+
identically in both implementations.
|
|
610
|
+
group_prompt: the group prompt of the diagnosed group, verbatim.
|
|
611
|
+
group_steps: the composed verbatim trace records of the group's
|
|
612
|
+
steps in execution order — each trace renders its recorded
|
|
613
|
+
prepared instruction with the outcome and the URL before ->
|
|
614
|
+
after transition, supplied by the calling engine.
|
|
615
|
+
previous_steps: the typed scenario records of the previous steps of
|
|
616
|
+
the test, in execution order — each entry renders its prepared
|
|
617
|
+
instruction with the group entries marked; non-empty — rendered
|
|
618
|
+
as the PREVIOUS STEPS block immediately before the GROUP STEPS
|
|
619
|
+
block; empty — no block.
|
|
620
|
+
step_text: the prepared instruction of the failed step; never the
|
|
621
|
+
raw template sentence.
|
|
622
|
+
attempt_history: the rendered per-step attempt records of the
|
|
623
|
+
failed step — every record a complete multi-line verbatim
|
|
624
|
+
record composed by the calling engine; non-empty — rendered as
|
|
625
|
+
a separate HISTORY block with the records joined by newlines,
|
|
626
|
+
every record verbatim, no collapsing, no size limits; empty —
|
|
627
|
+
no block.
|
|
628
|
+
snapshot: the accessibility snapshot of the current page.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
The request fields as one text with the GROUP PROMPT, optional
|
|
632
|
+
PREVIOUS STEPS, GROUP STEPS, STEP, HISTORY (when non-empty) and
|
|
633
|
+
PAGE SNAPSHOT sections and the optional USER INSTRUCTIONS section
|
|
634
|
+
last — the screenshot rides the SDK image part of the request,
|
|
635
|
+
never this text.
|
|
636
|
+
"""
|
|
637
|
+
previous = _format_previous_steps(previous_steps)
|
|
638
|
+
|
|
639
|
+
sections = [
|
|
640
|
+
f"GROUP PROMPT:\n{group_prompt}",
|
|
641
|
+
*([previous] if previous is not None else []),
|
|
642
|
+
_format_group_steps(group_steps),
|
|
643
|
+
f"STEP:\n{step_text}",
|
|
644
|
+
*(["HISTORY:\n" + "\n".join(attempt_history)] if attempt_history else []),
|
|
645
|
+
f"PAGE SNAPSHOT:\n{snapshot}",
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
if user_instructions:
|
|
649
|
+
sections.append(f"USER INSTRUCTIONS:\n{user_instructions}") # last
|
|
650
|
+
|
|
651
|
+
return "\n\n".join(sections)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def build_compliance_fields( # noqa: PLR0913, PLR0917 — the parameters mirror the fixed port signature
|
|
655
|
+
user_instructions: str,
|
|
656
|
+
instruction: str,
|
|
657
|
+
step_type: str,
|
|
658
|
+
inputs: dict[str, str],
|
|
659
|
+
declarations: list[str],
|
|
660
|
+
attempt_history: list[str],
|
|
661
|
+
code: str,
|
|
662
|
+
) -> str:
|
|
663
|
+
"""Build the plain-text compliance verdict request fields shared by both providers.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
user_instructions: the project's generation instructions from the
|
|
667
|
+
generation_prompt setting; the calling engine guarantees
|
|
668
|
+
non-empty — the gate never runs on empty instructions, so the
|
|
669
|
+
block always renders.
|
|
670
|
+
instruction: the prepared plain-text instruction of the generated
|
|
671
|
+
step — rendered as the STEP block; never the raw template
|
|
672
|
+
sentence.
|
|
673
|
+
step_type: the type of the step; rendered as a STEP TYPE line
|
|
674
|
+
immediately before the STEP line, inside the STEP block.
|
|
675
|
+
inputs: the call-local input bindings of the step; non-empty —
|
|
676
|
+
rendered as the INPUTS block immediately after the STEP block,
|
|
677
|
+
one ``name = value`` line per binding; empty — no block.
|
|
678
|
+
declarations: the reached capture names of the step; non-empty —
|
|
679
|
+
rendered as the RESULTS block immediately after the INPUTS
|
|
680
|
+
block stating the exact-key, observed non-blank string return
|
|
681
|
+
contract; empty — no block, the success-without-result form.
|
|
682
|
+
attempt_history: the rendered per-step attempt records — the ground
|
|
683
|
+
truth of what was already tried; non-empty — rendered as a
|
|
684
|
+
separate ATTEMPT HISTORY block with the records joined by
|
|
685
|
+
newlines, every record verbatim; empty — no block.
|
|
686
|
+
code: the successfully executed candidate code.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
The request fields as one text with INSTRUCTIONS, STEP (with its
|
|
690
|
+
STEP TYPE line), the optional INPUTS and RESULTS sections,
|
|
691
|
+
ATTEMPT HISTORY and CODE sections in this fixed order — the
|
|
692
|
+
ATTEMPT HISTORY block omitted when the history is empty,
|
|
693
|
+
identically in both implementations.
|
|
694
|
+
"""
|
|
695
|
+
sections = [
|
|
696
|
+
f"INSTRUCTIONS:\n{user_instructions}",
|
|
697
|
+
f"STEP TYPE: {step_type}\nSTEP:\n{instruction}",
|
|
698
|
+
*([_format_inputs(inputs)] if inputs else []),
|
|
699
|
+
*([_format_results(declarations)] if declarations else []),
|
|
700
|
+
]
|
|
701
|
+
|
|
702
|
+
if attempt_history:
|
|
703
|
+
sections.append("ATTEMPT HISTORY:\n" + "\n".join(attempt_history))
|
|
704
|
+
sections.append(f"CODE:\n{code}")
|
|
705
|
+
|
|
706
|
+
return "\n\n".join(sections)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def encode_screenshot(screenshot: bytes) -> str:
|
|
710
|
+
"""Encode a PNG screenshot as the base64 payload of a content block.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
screenshot: the raw PNG image bytes of the page.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
The base64 text of the image.
|
|
717
|
+
"""
|
|
718
|
+
return base64.b64encode(screenshot).decode("ascii")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def openai_user_content(text: str, screenshot: bytes | None) -> str | list[dict]:
|
|
722
|
+
"""Wrap the request fields as an openai user content payload.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
text: the plain-text request fields shared with the anthropic provider.
|
|
726
|
+
screenshot: an optional PNG image of the page; passed only when the
|
|
727
|
+
project enables screenshots.
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
The plain text when no image is attached, otherwise the openai
|
|
731
|
+
content block list with the data-URI image block.
|
|
732
|
+
"""
|
|
733
|
+
if screenshot is None:
|
|
734
|
+
return text
|
|
735
|
+
|
|
736
|
+
return [
|
|
737
|
+
{"type": "text", "text": text},
|
|
738
|
+
{
|
|
739
|
+
"type": "image_url",
|
|
740
|
+
"image_url": {"url": f"data:image/png;base64,{encode_screenshot(screenshot)}"},
|
|
741
|
+
},
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def unparsable_classification() -> dict[str, str]:
|
|
746
|
+
"""Return the protective verdict fields for an unparsable provider answer.
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
The incurable fallback verdict fields; both providers construct the
|
|
750
|
+
same defaults, so the field set is shared.
|
|
751
|
+
"""
|
|
752
|
+
return {
|
|
753
|
+
"category": CATEGORY_INCURABLE,
|
|
754
|
+
"explanation": "classification verdict unparsable",
|
|
755
|
+
"recommendation": "re-run the step or check the provider answer",
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def parse_classification_line(answer: str) -> tuple[str, str, str] | None:
|
|
760
|
+
"""Parse the one-line classification answer of the form ``category | explanation | recommendation``.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
answer: the raw completion text.
|
|
764
|
+
|
|
765
|
+
Returns:
|
|
766
|
+
The stripped (category, explanation, recommendation) triple, or None
|
|
767
|
+
when the answer is not a line of the expected shape or names no
|
|
768
|
+
known category — the caller applies the protective default.
|
|
769
|
+
"""
|
|
770
|
+
line = next((stripped for stripped in (line.strip() for line in answer.splitlines()) if stripped), "")
|
|
771
|
+
parts = [part.strip() for part in line.split("|")]
|
|
772
|
+
|
|
773
|
+
if len(parts) == VERDICT_FIELD_COUNT and parts[0] in CATEGORIES:
|
|
774
|
+
return parts[0], parts[1], parts[2]
|
|
775
|
+
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
#: The result contract sentence rendered at the end of every RESULTS block —
|
|
780
|
+
#: the exact-key, observed non-blank string return the step code must satisfy.
|
|
781
|
+
_RESULTS_CONTRACT = (
|
|
782
|
+
"the step code returns a dictionary of exactly these names to non-blank strings observed on the page"
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _format_previous_steps(previous_steps: list[ScenarioStep]) -> str | None:
|
|
787
|
+
"""Render the scenario context section; None — an empty history omits the block."""
|
|
788
|
+
if not previous_steps:
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
listed = "\n".join(_previous_step_line(record) for record in previous_steps)
|
|
792
|
+
|
|
793
|
+
return f"PREVIOUS STEPS:\n{listed}"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _previous_step_line(record: ScenarioStep) -> str:
|
|
797
|
+
"""Render one scenario record — the prepared instruction, marked when it carries group membership."""
|
|
798
|
+
if record.group_prompt:
|
|
799
|
+
return f"- {record.instruction} [group step — {record.group_prompt}]"
|
|
800
|
+
|
|
801
|
+
return f"- {record.instruction}"
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _format_inputs(inputs: dict[str, str]) -> str:
|
|
805
|
+
"""Render the INPUTS block — one ``name = value`` line per binding, in the given order."""
|
|
806
|
+
bindings = "\n".join(f"{name} = {value}" for name, value in inputs.items())
|
|
807
|
+
|
|
808
|
+
return f"INPUTS:\n{bindings}"
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _format_results(declarations: list[str]) -> str:
|
|
812
|
+
"""Render the RESULTS block — the declared names plus the result contract sentence."""
|
|
813
|
+
names = "\n".join(f"- {name}" for name in declarations)
|
|
814
|
+
|
|
815
|
+
return f"RESULTS:\n{names}\n{_RESULTS_CONTRACT}"
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _format_group_steps(group_steps: list[str]) -> str:
|
|
819
|
+
"""Render the group trace records section; an empty trace list stays explicit."""
|
|
820
|
+
if not group_steps:
|
|
821
|
+
return "GROUP STEPS:\n(none)"
|
|
822
|
+
|
|
823
|
+
return "GROUP STEPS:\n" + "\n".join(group_steps)
|