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.
Files changed (75) hide show
  1. prettyplay/.usages/lifecycle.md +117 -0
  2. prettyplay/.usages/steps.md +169 -0
  3. prettyplay/__init__.py +18 -0
  4. prettyplay/cache/.usages/addressing.md +44 -0
  5. prettyplay/cache/.usages/budgets.md +51 -0
  6. prettyplay/cache/.usages/storage.md +32 -0
  7. prettyplay/cache/__init__.py +8 -0
  8. prettyplay/cache/budgets.py +93 -0
  9. prettyplay/cache/models.py +55 -0
  10. prettyplay/cache/store.py +276 -0
  11. prettyplay/cache/text.py +36 -0
  12. prettyplay/config/.usages/configuration.md +246 -0
  13. prettyplay/config/__init__.py +6 -0
  14. prettyplay/config/loader.py +336 -0
  15. prettyplay/config/models.py +288 -0
  16. prettyplay/driver/.usages/error_kinds.md +26 -0
  17. prettyplay/driver/.usages/plumbing.md +52 -0
  18. prettyplay/driver/__init__.py +11 -0
  19. prettyplay/driver/errors.py +76 -0
  20. prettyplay/driver/page.py +175 -0
  21. prettyplay/driver/session.py +330 -0
  22. prettyplay/engine/.usages/generation.md +157 -0
  23. prettyplay/engine/.usages/healing.md +61 -0
  24. prettyplay/engine/__init__.py +19 -0
  25. prettyplay/engine/attempts.py +80 -0
  26. prettyplay/engine/classification.py +70 -0
  27. prettyplay/engine/compliance.py +125 -0
  28. prettyplay/engine/execution.py +48 -0
  29. prettyplay/engine/generator.py +1110 -0
  30. prettyplay/engine/groups/.usages/recovery.md +71 -0
  31. prettyplay/engine/groups/__init__.py +7 -0
  32. prettyplay/engine/groups/diagnosis.py +128 -0
  33. prettyplay/engine/groups/outcome.py +55 -0
  34. prettyplay/engine/groups/recovery.py +491 -0
  35. prettyplay/engine/healer.py +149 -0
  36. prettyplay/engine/polling/.usages/settle.md +55 -0
  37. prettyplay/engine/polling/__init__.py +6 -0
  38. prettyplay/engine/polling/settle.py +98 -0
  39. prettyplay/engine/polling/window.py +72 -0
  40. prettyplay/engine/renderer/.usages/memory.md +24 -0
  41. prettyplay/engine/renderer/.usages/rendering.md +44 -0
  42. prettyplay/engine/renderer/.usages/validation.md +20 -0
  43. prettyplay/engine/renderer/__init__.py +7 -0
  44. prettyplay/engine/renderer/memory.py +37 -0
  45. prettyplay/engine/renderer/render.py +184 -0
  46. prettyplay/engine/renderer/validation.py +76 -0
  47. prettyplay/engine/steering/.usages/steering.md +95 -0
  48. prettyplay/engine/steering/__init__.py +5 -0
  49. prettyplay/engine/steering/steering.py +765 -0
  50. prettyplay/engine/text.py +41 -0
  51. prettyplay/executor.py +654 -0
  52. prettyplay/failures/.usages/taxonomy.md +131 -0
  53. prettyplay/failures/__init__.py +25 -0
  54. prettyplay/failures/errors.py +359 -0
  55. prettyplay/groups.py +276 -0
  56. prettyplay/llm/.usages/classification.md +31 -0
  57. prettyplay/llm/.usages/providers.md +127 -0
  58. prettyplay/llm/.usages/transport.md +81 -0
  59. prettyplay/llm/__init__.py +38 -0
  60. prettyplay/llm/_request.py +823 -0
  61. prettyplay/llm/anthropic_provider.py +468 -0
  62. prettyplay/llm/models.py +273 -0
  63. prettyplay/llm/openai_provider.py +444 -0
  64. prettyplay/llm/provider.py +279 -0
  65. prettyplay/reporting/.usages/hooks.md +61 -0
  66. prettyplay/reporting/__init__.py +6 -0
  67. prettyplay/reporting/hooks.py +47 -0
  68. prettyplay/reporting/reporter.py +70 -0
  69. prettyplay/runtime.py +81 -0
  70. prettyplay/scenario.py +469 -0
  71. prettyplay-0.0.2.dist-info/METADATA +515 -0
  72. prettyplay-0.0.2.dist-info/RECORD +75 -0
  73. prettyplay-0.0.2.dist-info/WHEEL +5 -0
  74. prettyplay-0.0.2.dist-info/licenses/LICENSE +28 -0
  75. 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)