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,765 @@
1
+ """Interactive steering of a terminally stuck step: the engineer-in-the-loop REPL."""
2
+
3
+ import contextlib
4
+ import logging
5
+ import tempfile
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from ...cache import CachedStep, StepCache, StepIdentity
10
+ from ...config import Config
11
+ from ...driver import PageFacade
12
+ from ...failures import ComplianceVerdictError, IncurableStepError, LLMUnavailableError
13
+ from ...llm import ComplianceFinding, LLMProvider, ScenarioStep
14
+ from ...reporting import StepReporter
15
+ from ..attempts import (
16
+ OUTCOME_COMPLIANCE_BLOCKED,
17
+ OUTCOME_EXECUTION_FAILED,
18
+ OUTCOME_FAILED_CHECK,
19
+ OUTCOME_REJECTED,
20
+ StepAttempt,
21
+ )
22
+ from ..compliance import check_step_compliance
23
+ from ..execution import bind_step_inputs, run_step_code
24
+ from ..renderer import PreparedStep, StepMemory, validate_step_result
25
+ from ..text import format_step_error
26
+
27
+ logger = logging.getLogger("prettyplay")
28
+
29
+ #: System prompt of every guided regeneration request of the steering dialog.
30
+ #: Frozen mirror of ``.goga/usages/prompts/step_generation.md`` (the section after
31
+ #: the ``---`` separator) — the single source of the prompt; the constant changes
32
+ #: only together with the file. A local copy of the engine constant, not an
33
+ #: import: frozen mirrors stay cell-owned, no runtime read of ``.goga/`` ever
34
+ #: happens.
35
+ SYSTEM_PROMPT = """You generate executable Python code for one step of a web UI test.
36
+
37
+ Input you receive:
38
+ - STEP TYPE: action or assertion — the kind of the step
39
+ - STEP: the prepared instruction of the step — the plain-text sentence with actual values embedded
40
+ - INPUTS: the call input bindings, one name = value line each — present when the step carries inputs
41
+ - RESULTS: the declared result names — present when the step declares captures; the code must return a dictionary of exactly these names to non-blank strings observed on the page
42
+ - PREVIOUS STEPS: the sentences of the previous steps of the test, in order
43
+ - PAGE SNAPSHOT: the accessibility snapshot of the current page
44
+ - PAGE URL: the current URL of the page, when present
45
+ - SCREENSHOT: an image of the page, when attached
46
+ - CHEAT SHEET: a compact reference of useful Playwright sync API idioms — guidance, not an allowlist; everything standard stays allowed
47
+ - USER INSTRUCTIONS: the project's binding code style guidance, when configured
48
+ - HISTORY: the verbatim record of every attempt of this step so far, when present — the original cached code first when it exists; each record carries the attempt outcome, the URL before -> after line, the complete candidate code and the complete error
49
+ - RECOMMENDATION: the diagnosis of the classification that preceded this regeneration, when present
50
+ - USER GUIDANCE: the engineer guidance message of the interactive steering, when present
51
+
52
+ Output exactly one Python code block with one function of the fixed form:
53
+
54
+ - without RESULTS: def step(page) -> None: ...
55
+ - with RESULTS: def step(page) -> dict[str, str] | None: ... — return the dictionary of exactly the declared names to values actually read from the current page
56
+
57
+ Rules:
58
+ - The function receives exactly one argument: the page — the genuine Playwright sync Page; the whole step runs inside the driver worker thread
59
+ - The global `step_inputs` is supplied fresh on every execution: captured values are available by name and call-local INPUTS under `step_inputs["vars"]`. Read changing values from it instead of hardcoding the current STEP or INPUTS values into cached code
60
+ - Import from playwright.sync_api and the Python standard library only — no third-party
61
+ libraries; imports are global only: at the top level of the code block, before `def
62
+ step`, never inside the function body
63
+ - Work through the standard Playwright sync API: locator factories, actions, waits, expect chains, plain asserts on immediate reads — everything standard is allowed; the CHEAT SHEET is guidance, never a boundary
64
+ - Assertions: for an assertion sentence end with a check — a waiting expect(...) chain for dynamic content, or an immediate read with a plain Python assert (assert locator.count() > 1)
65
+ - When the request carries a RESULTS block: read every declared value from the current page — never fabricate, never reuse values from HISTORY; return all declared names in one dictionary; every value is a non-blank string; a missing observation fails the step rather than fabricating a value
66
+ - No fixed delays, no sleeps, no wait_for_timeout — locators and expect chains auto-wait
67
+ - The runtime owns the page lifecycle: never call page.close() or context.close()
68
+ - No stateful actions that outlive the step on the page shared by the whole test: page.route, page.clock, add_init_script, tracing, HAR, CDP — excluded from generated code; a cached step would poison every later step far from the cause
69
+ - Dialogs: capture with the stock means — with page.expect_event("dialog") as info: — perform the triggering action inside the block, read info.value.type, info.value.message, info.value.default_value, then info.value.accept() or info.value.dismiss()
70
+ - Popups and new tabs: capture with with page.expect_popup() as popup_info: — trigger the opening action inside the block, work through popup_info.value; page.bring_to_front() raises a page above the others
71
+ - Content inside an iframe goes through page.frame_locator(selector) — locate elements within the returned scope; nested frames chain
72
+ - Scrolling: locator.scroll_into_view_if_needed() and page.mouse.wheel(dx, dy) are the standard means
73
+ - The page state may already include the effects of prior attempts or manual intervention — the HISTORY records and their URL before -> after lines show what already happened. Regeneration after failed attempts never rides the leftover state of those attempts: an action step repeats its action through the page even when the page looks done. A first attempt — no HISTORY — works on the page the previous steps produced: the PAGE URL line and the snapshot show where they landed
74
+ - An action step performs its action and ends there — no trailing check that confirms the action's own completion; the following steps of the test carry their own expectations
75
+ - An assertion step only observes the current page — never navigate, click, type or fill; passive observation such as scroll_into_view_if_needed() stays allowed
76
+ - RECOMMENDATION and USER GUIDANCE carry the diagnosis and the engineer's intent — follow them when they conflict with your first instinct
77
+ - USER INSTRUCTIONS are binding for everything below the safety core of these Rules: follow them when configured; silently ignoring an instruction is a violation
78
+ - The safety core of these Rules always outranks the instructions: the fixed function form, the import rule, the lifecycle rule, the stateful-action exclusions, no fixed delays. An instruction conflicting with a Rule or demanding a stateful action is unfollowable: never implement it silently — raise in the step code with the message "instruction conflicts with rule Y" naming the conflict, so the failure surfaces loudly
79
+ - Prefer-type instructions are conditional by their own wording: follow them when the page offers the option — best-effort with a graceful fallback is compliance
80
+ - The step must complete exactly what STEP says — nothing more, nothing less
81
+ - Output only the code block, no explanations"""
82
+
83
+ #: The compact standard Playwright sync API reference of every guided request;
84
+ #: guidance, not an allowlist — everything standard stays allowed.
85
+ #: Frozen mirror of ``.goga/usages/prompts/step_cheatsheet.md`` — the whole file, verbatim
86
+ #: (the practice has no ``---`` separator, so the whole-file rule is the only mirror
87
+ #: rule with no extraction logic to drift); the constant changes only together with
88
+ #: the file. A local copy of the engine constant, not an import — the same
89
+ #: frozen-mirror rule; no runtime read of ``.goga/`` ever happens.
90
+ CHEAT_SHEET = """# Playwright cheat sheet
91
+
92
+ The compact standard Playwright sync API reference carried by every step-code generation and regeneration request
93
+ of prettyplay — referenced by the engine and steering cells as the `cheat_sheet` practice; rendered by the provider
94
+ implementations as the leading CHEAT SHEET block of the user content. Guidance, not an allowlist: everything
95
+ standard stays allowed — the error-driven regeneration loop is the second line of defense against hallucinated
96
+ calls. Target audience: the generation model — a model that knows Playwright weakly writes a correct step from
97
+ this reference alone.
98
+
99
+ ## Locator factories
100
+
101
+ page.get_by_role("button", name="Sign in")
102
+ page.get_by_label("Username")
103
+ page.get_by_text("Welcome back")
104
+ page.get_by_placeholder("Search")
105
+ page.get_by_alt_text("Logo")
106
+ page.get_by_title("Close")
107
+ page.get_by_test_id("submit")
108
+ page.locator("css selector | //xpath | [data-qa=row]")
109
+
110
+ ## Narrowing
111
+
112
+ locator.first / locator.last / locator.nth(2)
113
+ locator.filter(has_text="Product X")
114
+ locator.and_(other) / locator.or_(other)
115
+
116
+ ## Actions
117
+
118
+ .click() / .dblclick() / .click(button="right")
119
+ .fill("text") / .clear() / .press("Enter") / .press("Control+A")
120
+ .check() / .uncheck() / .hover() / .select_option("v")
121
+ .drag_to(target) / .set_input_files("path.png")
122
+
123
+ ## Navigation and waits
124
+
125
+ page.goto(url) / page.go_back() / page.go_forward() / page.reload()
126
+ page.wait_for_url("**/dashboard") / page.wait_for_load_state("networkidle")
127
+ page.url # the current URL — an immediate read beside the waiting forms
128
+
129
+ ## Waiting assertions — expect chains
130
+
131
+ from playwright.sync_api import expect
132
+
133
+ expect(locator).to_be_visible() / to_be_hidden()
134
+ expect(locator).to_be_enabled() / to_be_checked()
135
+ expect(locator).to_have_text("...") / to_contain_text("...")
136
+ expect(locator).to_have_value("v") / to_have_attribute("href", "/docs")
137
+ expect(page).to_have_url("**/dashboard") / to_have_title("Dashboard")
138
+
139
+ ## Count forms — "the page shows a list of X"
140
+
141
+ items = page.get_by_role("listitem")
142
+ assert items.count() > 1
143
+
144
+ An exact count: `expect(items).to_have_count(3)`. One check per meaning — a visibility expect
145
+ plus a count assert on the same locator verifies one fact twice.
146
+
147
+ ## Immediate reads with plain asserts
148
+
149
+ assert locator.count() >= 1
150
+ assert "Dashboard" in page.title()
151
+ assert "/dashboard" in page.url
152
+
153
+ ## Dialogs
154
+
155
+ with page.expect_event("dialog") as info:
156
+ page.get_by_role("button", name="Delete").click()
157
+ dialog = info.value
158
+ dialog.type / dialog.message / dialog.default_value
159
+ dialog.accept() / dialog.dismiss() / dialog.accept("the answer")
160
+
161
+ ## Popups and new tabs
162
+
163
+ with page.expect_popup() as popup_info:
164
+ page.get_by_role("link", name="Open docs").click()
165
+ popup = popup_info.value
166
+ popup.bring_to_front()
167
+
168
+ ## Frames
169
+
170
+ frame = page.frame_locator("#checkout")
171
+ frame.get_by_role("button", name="Pay").click()
172
+
173
+ ## Scrolling
174
+
175
+ locator.scroll_into_view_if_needed()
176
+ page.mouse.wheel(0, 600)
177
+ """
178
+
179
+ #: The local commands of the dialog — context services without an LLM request.
180
+ _LOCAL_COMMANDS = frozenset({"snapshot", "screenshot", "error", "code"})
181
+
182
+ #: The banner label column — every banner label aligns to the widest one,
183
+ #: ``commands:``. The banner's own presentation padding; the render text
184
+ #: inside stays unpadded.
185
+ _BANNER_LABEL_WIDTH = len("commands:")
186
+
187
+ #: Prefix of the temporary screenshot file of a dialog.
188
+ _SCREENSHOT_PREFIX = "prettyplay-steering-"
189
+
190
+ #: The explanation of the ``on_healed`` event of an interactively healed step.
191
+ _HEALED_EXPLANATION = "healed interactively by engineer guidance"
192
+
193
+
194
+ class StepSteering:
195
+ """Steers a terminally stuck step back to green through engineer-approved turns; no budget is ever consumed.
196
+
197
+ Attributes:
198
+ _config: project settings; the generation instructions and the
199
+ screenshot flag feed the guided requests.
200
+ _provider: the LLM port implementation of the guided requests.
201
+ _cache: the store the healed step is written back to.
202
+ _reporter: the visibility point of the healed step.
203
+ _screenshot_path: the path of the one temporary screenshot file of
204
+ the current dialog; ``None`` — the dialog took no screenshot
205
+ yet.
206
+ """
207
+
208
+ def __init__(
209
+ self,
210
+ config: Config,
211
+ provider: LLMProvider,
212
+ cache: StepCache,
213
+ reporter: StepReporter | None,
214
+ ) -> None:
215
+ """Keep the collaborators of the steering dialog.
216
+
217
+ Args:
218
+ config: project settings; ``generation_prompt`` renders into every
219
+ guided request, ``send_screenshots`` attaches page images.
220
+ provider: the LLM port implementation of the guided requests.
221
+ cache: the store the healed step is written back to.
222
+ reporter: the visibility point of the healed step; ``None`` — the
223
+ hook-less default reporter, so an omitted reporter never
224
+ breaks the healed-step report.
225
+ """
226
+ self._config = config
227
+ self._provider = provider
228
+ self._cache = cache
229
+ self._reporter = reporter if reporter is not None else StepReporter([])
230
+ self._screenshot_path: str | None = None
231
+
232
+ def steer( # noqa: PLR0913, PLR0917 — the signature is fixed by the steering contract
233
+ self,
234
+ failure: IncurableStepError,
235
+ identity: StepIdentity,
236
+ prepared: PreparedStep,
237
+ step_type: str,
238
+ previous_steps: list[ScenarioStep],
239
+ group_prompt: str | None,
240
+ page: PageFacade,
241
+ attempt_history: list[StepAttempt],
242
+ memory: StepMemory,
243
+ ) -> CachedStep | None:
244
+ """Run the steering dialog over a terminal failure.
245
+
246
+ Args:
247
+ failure: the terminal failure about to propagate — the source of
248
+ the failed code, the underlying error and the verdict.
249
+ identity: the address of the stuck step — the healed step is
250
+ written back under it.
251
+ prepared: the render product of the stuck step — threaded from
252
+ the executor; the banner and every guided request carry the
253
+ prepared instruction with its INPUTS and RESULTS blocks; the
254
+ declared names gate the result of an approved turn.
255
+ step_type: action or assertion — carried into every guided
256
+ request and the gate verdict.
257
+ previous_steps: the typed scenario records of the previous steps
258
+ of the test — each the prepared instruction plus its
259
+ permanent group membership; carried into every guided
260
+ request unchanged, the provider renders the marked entries.
261
+ group_prompt: the group prompt of the stuck step's group;
262
+ ``None`` — an ordinary step, no group framing; non-empty —
263
+ every guided request of this dialog carries the group framing
264
+ (the GROUP PROMPT block before the scenario context, rendered
265
+ by the provider).
266
+ page: the live page facade of the test.
267
+ attempt_history: the shared per-step attempt history grown by
268
+ the engine loops and anchored by record 0 — rendered into
269
+ every guided request; the dialog appends every completed
270
+ turn to it.
271
+ memory: the test memory — the publication point of the accepted
272
+ turn; the validated captures of a green turn publish with
273
+ its write-back, after the compliance gate.
274
+
275
+ Returns:
276
+ The healed cached step on a successful approved turn; ``None`` —
277
+ the dialog was declined or died: the caller propagates the
278
+ original failure. Every completed turn appends one record into
279
+ the shared history; a green turn validates its result and passes
280
+ the compliance gate before the publication and the write-back —
281
+ a high finding never reaches the cache.
282
+
283
+ Raises:
284
+ KeyboardInterrupt: a SIGINT outside the prompts and the approval
285
+ gate escapes directly — never swallowed into a turn or a
286
+ heal.
287
+ """
288
+ self._screenshot_path = None # a fresh dialog owns no screenshot file yet
289
+ self._render_banner(failure, prepared.instruction, page)
290
+ logger.info("steering_opened", extra={"step_text": prepared.instruction})
291
+
292
+ while True:
293
+ message = self._await_guidance(failure, prepared.instruction, page)
294
+ if message is None: # quit, EOF, SIGINT or an unreadable stdin — declined
295
+ return None
296
+
297
+ logger.info("steering_guidance", extra={"guidance": message})
298
+ print("regenerating with USER GUIDANCE")
299
+
300
+ try:
301
+ code = self._guided_request(
302
+ prepared, step_type, previous_steps, group_prompt, page, message, attempt_history
303
+ )
304
+ except LLMUnavailableError as outcome:
305
+ print(f"provider unavailable: {outcome}")
306
+ return None
307
+
308
+ try:
309
+ approved = self._confirm_run(code)
310
+ except (EOFError, KeyboardInterrupt, OSError): # a dead approval prompt declines the dialog
311
+ logger.info("steering_declined", extra={"step_text": prepared.instruction})
312
+ return None
313
+
314
+ if not approved: # the code never runs — one read, the same URL on both sides
315
+ url = self._guarded_url(page) or ""
316
+ attempt_history.append(_record(code, "", OUTCOME_REJECTED, url, url))
317
+ continue
318
+
319
+ url_before = self._guarded_url(page) or ""
320
+ try:
321
+ with bind_step_inputs(memory.snapshot(), prepared.inputs):
322
+ result = run_step_code(code, page) # bare — the settle window never re-arms inside the dialog
323
+ except Exception as outcome: # a red turn returns to the prompt, never escapes
324
+ print(f"turn failed: {outcome}")
325
+ url_after = self._guarded_url(page) or ""
326
+ attempt_history.append(
327
+ _record(code, format_step_error(outcome), _outcome_label(outcome), url_before, url_after)
328
+ )
329
+ continue
330
+ url_after = self._guarded_url(page) or ""
331
+
332
+ # the deterministic result gate: an approved green turn is accepted only by a
333
+ # validated result — a violation is a red turn of the identical failed-check channel
334
+ captures, violation_text = _validated_captures(prepared, result)
335
+
336
+ if violation_text is not None:
337
+ print(f"turn failed: {violation_text}")
338
+ attempt_history.append(_record(code, violation_text, OUTCOME_FAILED_CHECK, url_before, url_after))
339
+ continue
340
+
341
+ # the gate sits outside the execution try/except: its hard failures end the
342
+ # dialog, they never degrade into a red turn of the executed candidate; the
343
+ # history it judges holds every prior turn — the candidate rides the CODE block
344
+ try:
345
+ findings = check_step_compliance(
346
+ self._config,
347
+ self._provider,
348
+ prepared,
349
+ step_type,
350
+ code,
351
+ attempt_history,
352
+ )
353
+ except (LLMUnavailableError, ComplianceVerdictError) as gate_failure:
354
+ print(f"compliance gate failed: {gate_failure}")
355
+ logger.warning(
356
+ "compliance gate failed",
357
+ extra={"step_text": prepared.instruction, "gate_failure": str(gate_failure)},
358
+ )
359
+ return None # the green candidate stays unchecked — the original failure propagates
360
+
361
+ high = _high_finding(findings)
362
+ if high is not None: # the violation never reaches the cache — steer the fix
363
+ violation = f"{high.dimension} violation: {high.instruction} — {high.explanation}"
364
+ print(f"compliance violation — not written back: {violation}")
365
+ attempt_history.append(_record(code, violation, OUTCOME_COMPLIANCE_BLOCKED, url_before, url_after))
366
+ continue
367
+
368
+ _medium_warning(prepared.instruction, findings) # medium and low findings are visible, never blocking
369
+
370
+ memory.publish(captures) # publication rides the acceptance — after validation and the gate
371
+ return self._write_back(prepared.instruction, identity, code)
372
+
373
+ def _render_banner(self, failure: IncurableStepError, instruction: str, page: PageFacade) -> None:
374
+ """Render the context banner of the dialog.
375
+
376
+ Args:
377
+ failure: the terminal failure the dialog opens over — the source
378
+ of the failed code and the terminal render.
379
+ instruction: the prepared instruction of the stuck step — the
380
+ banner header names it; the raw template sentence never
381
+ reaches the dialog surface.
382
+ page: the live page facade of the test.
383
+ """
384
+ print(f'── step "{instruction}" — about to raise IncurableStepError ──')
385
+ print(_banner_line("code:", failure.code))
386
+ print(_banner_line("error:", str(failure)))
387
+
388
+ url = self._guarded_url(page)
389
+
390
+ if url is not None:
391
+ print(_banner_line("url:", url))
392
+
393
+ path = self._screenshot_file(page)
394
+
395
+ if path is not None:
396
+ print(_banner_line("shot:", path))
397
+
398
+ print(_banner_line("commands:", "snapshot | screenshot | error | code | quit"))
399
+
400
+ def _confirm_run(self, code: str) -> bool:
401
+ """Show the complete generated code and read the strict approval answer.
402
+
403
+ Args:
404
+ code: the complete generated step code of this turn.
405
+
406
+ Returns:
407
+ ``True`` — a bare ``y`` approved the execution; ``False`` — any
408
+ other answer: the candidate reaches the live page only through
409
+ this gate.
410
+
411
+ Raises:
412
+ EOFError: the approval prompt hit the end of the input.
413
+ KeyboardInterrupt: a SIGINT arrived at the approval prompt.
414
+ OSError: the approval prompt cannot read stdin at all.
415
+ """
416
+ print("generated code:")
417
+ print(code)
418
+
419
+ return input("run? [y/N] ").strip() == "y"
420
+
421
+ def _guarded_url(self, page: PageFacade) -> str | None:
422
+ """Read the current URL; a failed interaction degrades to None.
423
+
424
+ Args:
425
+ page: the live page facade of the test.
426
+
427
+ Returns:
428
+ The current URL of the page; ``None`` when the read failed —
429
+ its failure text was printed.
430
+ """
431
+ try:
432
+ return page.url
433
+ except Exception as failure: # a dead page never kills the dialog
434
+ print(f"url unavailable: {failure}")
435
+ return None
436
+
437
+ def _await_guidance(self, failure: IncurableStepError, instruction: str, page: PageFacade) -> str | None:
438
+ """Read guidance lines until one is a real guidance message.
439
+
440
+ Args:
441
+ failure: the terminal failure the dialog opens over.
442
+ instruction: the prepared instruction of the stuck step — the
443
+ decline log names it.
444
+ page: the live page facade of the test.
445
+
446
+ Returns:
447
+ The raw guidance message; ``None`` — quit, EOF, SIGINT or an
448
+ unreadable stdin ended the dialog declined. Blank lines
449
+ re-prompt; local commands serve the context.
450
+ """
451
+ while True:
452
+ message = self._read_guidance(instruction)
453
+ if message is None:
454
+ return None
455
+
456
+ command = message.strip()
457
+ if not command: # blank line — re-prompt, no LLM request, no history
458
+ continue
459
+ if command in _LOCAL_COMMANDS:
460
+ self._serve_local_command(command, failure, page)
461
+ continue
462
+
463
+ return message
464
+
465
+ def _read_guidance(self, instruction: str) -> str | None:
466
+ """Read one guidance line from the prompt.
467
+
468
+ Args:
469
+ instruction: the prepared instruction of the stuck step — the
470
+ decline log names it.
471
+
472
+ Returns:
473
+ The raw guidance message; ``None`` — quit, EOF, SIGINT or an
474
+ unreadable stdin ended the dialog declined.
475
+ """
476
+ try:
477
+ message = input("guidance> ")
478
+ except (EOFError, KeyboardInterrupt, OSError): # an unreadable stdin (captured CI) declines like EOF
479
+ logger.info("steering_declined", extra={"step_text": instruction})
480
+ return None
481
+
482
+ if message.strip() == "quit":
483
+ logger.info("steering_declined", extra={"step_text": instruction})
484
+ return None
485
+
486
+ return message
487
+
488
+ def _serve_local_command(self, command: str, failure: IncurableStepError, page: PageFacade) -> None:
489
+ """Serve one local context command without an LLM request.
490
+
491
+ Args:
492
+ command: the command name — one of snapshot, screenshot, error,
493
+ code.
494
+ failure: the terminal failure the dialog opens over.
495
+ page: the live page facade of the test.
496
+ """
497
+ if command == "snapshot":
498
+ print("snapshot:")
499
+ print(self._guarded_snapshot(page))
500
+ return
501
+ if command == "screenshot":
502
+ path = self._screenshot_file(page)
503
+ if path is not None:
504
+ print(path)
505
+ return
506
+ if command == "error":
507
+ print(f"error: {failure.error}")
508
+ return
509
+
510
+ print("code:") # the code command — the last of the fixed set
511
+ print(failure.code)
512
+
513
+ def _guided_request( # noqa: PLR0913, PLR0917 — the fixed request inputs of the port signature
514
+ self,
515
+ prepared: PreparedStep,
516
+ step_type: str,
517
+ previous_steps: list[ScenarioStep],
518
+ group_prompt: str | None,
519
+ page: PageFacade,
520
+ guidance: str,
521
+ attempt_history: list[StepAttempt],
522
+ ) -> str:
523
+ """Run one guided regeneration request against the provider.
524
+
525
+ Args:
526
+ prepared: the render product of the stuck step — the prepared
527
+ instruction rides the request with its INPUTS and RESULTS
528
+ blocks; the raw template sentence never reaches the request.
529
+ step_type: action or assertion — carried into the request.
530
+ previous_steps: the typed scenario records of the previous steps
531
+ of the test — carried unchanged.
532
+ group_prompt: the group prompt of the stuck step's group;
533
+ ``None`` — an ordinary step, no group framing.
534
+ page: the live page facade of the test.
535
+ guidance: the engineer guidance message of this turn.
536
+ attempt_history: the shared per-step attempt history — rendered
537
+ record by record into the HISTORY block of the request;
538
+ record 0 anchors the original failure.
539
+
540
+ Returns:
541
+ The generated step code of the fixed form — the fresh page state
542
+ plus the render product and the step type compose the request;
543
+ the guidance message rides the USER GUIDANCE block.
544
+
545
+ Raises:
546
+ LLMUnavailableError: the provider request failed; the caller ends
547
+ the dialog.
548
+ """
549
+ screenshot = self._guarded_screenshot_bytes(page) if self._config.send_screenshots else None
550
+
551
+ return self._provider.generate_step_code(
552
+ prompt=SYSTEM_PROMPT,
553
+ user_instructions=self._config.generation_prompt,
554
+ instruction=prepared.instruction,
555
+ step_type=step_type,
556
+ previous_steps=previous_steps,
557
+ group_prompt=group_prompt,
558
+ inputs=prepared.inputs,
559
+ declarations=prepared.declarations,
560
+ snapshot=self._guarded_snapshot(page),
561
+ page_url=self._guarded_url(page),
562
+ screenshot=screenshot,
563
+ cheat_sheet=CHEAT_SHEET,
564
+ attempt_history=[record.render() for record in attempt_history],
565
+ recommendation=None, # the verdict diagnosis is banner-only — the live guidance replaces it
566
+ guidance=guidance,
567
+ )
568
+
569
+ def _write_back(self, instruction: str, identity: StepIdentity, code: str) -> CachedStep:
570
+ """Write the proven guided step back to the cache and report it healed.
571
+
572
+ Args:
573
+ instruction: the prepared instruction of the stuck step — the
574
+ healed event names it.
575
+ identity: the address of the stuck step.
576
+ code: the step code that just worked on the live page.
577
+
578
+ Returns:
579
+ The healed cached step, already stored and reported.
580
+ """
581
+ step = CachedStep(
582
+ identity=identity,
583
+ code=code,
584
+ created_at=date.today().isoformat(), # noqa: DTZ011 — calendar date of the healed step
585
+ )
586
+ stored = self._cache.save(step)
587
+
588
+ self._reporter.emit("on_healed", {"step_text": instruction, "explanation": _HEALED_EXPLANATION})
589
+
590
+ if stored:
591
+ print("step green — healed step written to the cache")
592
+ else: # the skip reason rides the on_cache_skipped event — the console line never claims a write that failed
593
+ print("step green — cache write skipped (best-effort cache)")
594
+
595
+ return step
596
+
597
+ def _guarded_snapshot(self, page: PageFacade) -> str:
598
+ """Read the accessibility snapshot; a failed interaction degrades to empty.
599
+
600
+ Args:
601
+ page: the live page facade of the test.
602
+
603
+ Returns:
604
+ The full accessibility snapshot; empty when the interaction
605
+ failed — its failure text was printed.
606
+ """
607
+ try:
608
+ return page.aria_snapshot()
609
+ except Exception as failure: # a dead page never kills the dialog
610
+ print(f"snapshot unavailable: {failure}")
611
+ return ""
612
+
613
+ def _screenshot_file(self, page: PageFacade) -> str | None:
614
+ """Write a full PNG to the one temporary file of the dialog; return its path.
615
+
616
+ Args:
617
+ page: the live page facade of the test.
618
+
619
+ Returns:
620
+ The path of the written file — one file per dialog, overwritten
621
+ by every later screenshot; ``None`` when the interaction failed.
622
+ """
623
+ path = self._screenshot_path
624
+ fresh = path is None # the first screenshot of the dialog allocates the one file
625
+
626
+ try:
627
+ png = page.screenshot()
628
+
629
+ if fresh:
630
+ with tempfile.NamedTemporaryFile(prefix=_SCREENSHOT_PREFIX, suffix=".png", delete=False) as target:
631
+ path = target.name
632
+ target.write(png)
633
+ else: # every later screenshot of the dialog overwrites the one file
634
+ Path(path).write_bytes(png)
635
+ except Exception as failure: # a dead page never kills the dialog
636
+ if fresh and path is not None:
637
+ with contextlib.suppress(OSError):
638
+ Path(path).unlink() # a partial write leaves nothing worth inspecting
639
+ print(f"screenshot unavailable: {failure}")
640
+ return None
641
+
642
+ self._screenshot_path = path
643
+ return path
644
+
645
+ def _guarded_screenshot_bytes(self, page: PageFacade) -> bytes | None:
646
+ """Read the screenshot bytes of a guided request; a failed interaction degrades to None.
647
+
648
+ Args:
649
+ page: the live page facade of the test.
650
+
651
+ Returns:
652
+ The full-page PNG bytes; ``None`` when the interaction failed —
653
+ its failure text was printed.
654
+ """
655
+ try:
656
+ return page.screenshot()
657
+ except Exception as failure: # a dead page never kills the dialog
658
+ print(f"screenshot unavailable: {failure}")
659
+ return None
660
+
661
+
662
+ def _high_finding(findings: list[ComplianceFinding]) -> ComplianceFinding | None:
663
+ """Return the first high finding of a gate verdict — either dimension blocks.
664
+
665
+ Args:
666
+ findings: the findings of the verdict, in the verdict's own ordering.
667
+
668
+ Returns:
669
+ The first finding of priority high, or ``None`` when the verdict
670
+ holds none — the guidance prompt reopens and the next candidate is
671
+ gated anew.
672
+ """
673
+ return next((finding for finding in findings if finding.priority == "high"), None)
674
+
675
+
676
+ def _medium_warning(instruction: str, findings: list[ComplianceFinding]) -> None:
677
+ """Log the medium and low findings a green turn passed with.
678
+
679
+ A verdict without findings warns nothing — the clean pass stays quiet.
680
+
681
+ Args:
682
+ instruction: the prepared instruction of the gated step.
683
+ findings: the non-blocking findings of both dimensions of the verdict.
684
+ """
685
+ if not findings:
686
+ return
687
+
688
+ logger.warning(
689
+ "compliance findings passed",
690
+ extra={
691
+ "step_text": instruction,
692
+ "findings": [
693
+ f"{finding.priority} {finding.dimension}: {finding.instruction} — {finding.explanation}"
694
+ for finding in findings
695
+ ],
696
+ },
697
+ )
698
+
699
+
700
+ def _outcome_label(outcome: BaseException) -> str:
701
+ """Pick the outcome label of a red turn by its exception kind.
702
+
703
+ Args:
704
+ outcome: the exception the executed candidate raised.
705
+
706
+ Returns:
707
+ The failed-check label for an ``AssertionError``, the
708
+ execution-failed label for every other failure.
709
+ """
710
+ return OUTCOME_FAILED_CHECK if isinstance(outcome, AssertionError) else OUTCOME_EXECUTION_FAILED
711
+
712
+
713
+ def _record(code: str, error: str, outcome: str, url_before: str, url_after: str) -> StepAttempt:
714
+ """Compose one verbatim attempt record — the shape every turn-append site shares.
715
+
716
+ Args:
717
+ code: the complete candidate code of the turn.
718
+ error: the complete failure text of the turn; empty on no error.
719
+ outcome: the outcome label constant of the turn.
720
+ url_before: the page URL read immediately before the turn's execution.
721
+ url_after: the page URL read immediately after the turn's execution.
722
+
723
+ Returns:
724
+ The immutable record appended to the shared per-step attempt history.
725
+ """
726
+ return StepAttempt(code=code, error=error, outcome=outcome, url_before=url_before, url_after=url_after)
727
+
728
+
729
+ def _validated_captures(prepared: PreparedStep, result: object) -> tuple[dict[str, str], str | None]:
730
+ """Validate the returned result of an approved green turn against the render product.
731
+
732
+ Args:
733
+ prepared: the render product of the stuck step — the declaration set
734
+ the returned result must satisfy.
735
+ result: the step function's return, passed through by the run
736
+ primitive.
737
+
738
+ Returns:
739
+ The validated captures with no violation; an empty captures with the
740
+ formatted deterministic violation text when the result contract
741
+ failed — the violation joins the failed-check channel of the dialog.
742
+ """
743
+ try:
744
+ return validate_step_result(prepared, result), None
745
+ except AssertionError as violation:
746
+ return {}, format_step_error(violation)
747
+
748
+
749
+ def _banner_line(label: str, text: str) -> str:
750
+ """Align one banner label with its value text.
751
+
752
+ Args:
753
+ label: the banner label including its colon.
754
+ text: the value text of the line; its continuation lines indent to
755
+ the value column.
756
+
757
+ Returns:
758
+ The aligned banner line with its continuation lines — the label pads
759
+ to the label column, multi-line values indent to the value column.
760
+ """
761
+ value_column = " " * (_BANNER_LABEL_WIDTH + 1)
762
+ head, *tail = text.splitlines() or [""] # an empty value renders its label alone
763
+ lines = [f"{label.ljust(_BANNER_LABEL_WIDTH)} {head}", *(value_column + line for line in tail)]
764
+
765
+ return "\n".join(line.rstrip() for line in lines)