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,491 @@
1
+ """The diagnosis-driven recovery engine of step groups: bounded cycles over a group-scoped row."""
2
+
3
+ import logging
4
+ import time
5
+
6
+ from ...cache import CachedStep, RunBudgets, StepCache, StepIdentity
7
+ from ...config import Config
8
+ from ...driver import PageFacade
9
+ from ...failures import FailureVerdict, IncurableStepError, ProductDefectError
10
+ from ...llm import GroupFailureClassification, LLMProvider, ScenarioStep
11
+ from ...reporting import StepReporter
12
+ from ..attempts import OUTCOME_ORIGINAL, StepAttempt
13
+ from ..generator import StepGenerator
14
+ from ..polling import SettleWindow
15
+ from ..renderer import PreparedStep, StepMemory, render_step
16
+ from .diagnosis import classify_group_failure
17
+ from .outcome import GroupStepOutcome
18
+
19
+ logger = logging.getLogger("prettyplay")
20
+
21
+ #: The authored reason of a refused recovery cycle — colon-free; the verdict explanation is the same text.
22
+ _CYCLE_CAP = "the per-group recovery cycle cap is exhausted"
23
+
24
+ #: The authored recommendation of the refused-cycle verdict.
25
+ _CYCLE_CAP_RECOMMENDATION = "raise healing_attempts or rework the group"
26
+
27
+ #: The authored explanation head of an out-of-mandate root — the verbatim step sentence follows the dash.
28
+ _OUTSIDE_ROOT = "the root lives outside the group"
29
+
30
+
31
+ def _scenario_occurrences(
32
+ scenario: list[ScenarioStep], traces: list[GroupStepOutcome], group_prompt: str
33
+ ) -> list[int | None]:
34
+ """Match this group's accepted traces to their latest scenario records by occurrence."""
35
+ occurrences: list[int | None] = [None] * len(traces)
36
+ search_end = len(scenario)
37
+ for occurrence in range(len(traces) - 2, -1, -1):
38
+ trace = traces[occurrence]
39
+ for index in range(search_end - 1, -1, -1):
40
+ record = scenario[index]
41
+ if record.group_prompt == group_prompt and record.sentence == trace.sentence:
42
+ occurrences[occurrence] = index
43
+ search_end = index
44
+ break
45
+ return occurrences
46
+
47
+
48
+ class _ActiveFailure:
49
+ """The failure facts the next diagnosis describes — the failed step on entry, fresh per row failure.
50
+
51
+ Attributes:
52
+ prepared: the render product of the failing step — the executor's
53
+ product on entry, the failing row occurrence's fresh render
54
+ after a row failure.
55
+ step_type: the step kind of the failing step.
56
+ identity: the address of the failing step.
57
+ history: the per-step attempt history grown to the failure.
58
+ occurrence: the group-order index of the failing step's trace in
59
+ the recovery-local view.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ prepared: PreparedStep,
65
+ step_type: str,
66
+ identity: StepIdentity,
67
+ history: list[StepAttempt],
68
+ occurrence: int,
69
+ ) -> None:
70
+ """Keep the active failure facts of the next diagnosis.
71
+
72
+ Args:
73
+ prepared: the render product of the failing step.
74
+ step_type: the step kind of the failing step.
75
+ identity: the address of the failing step.
76
+ history: the per-step attempt history grown to the failure.
77
+ occurrence: the group-order index of the failing step's trace.
78
+ """
79
+ self.prepared = prepared
80
+ self.step_type = step_type
81
+ self.identity = identity
82
+ self.history = history
83
+ self.occurrence = occurrence
84
+
85
+
86
+ class GroupRecovery:
87
+ """The diagnosis-driven recovery engine of step groups.
88
+
89
+ Attributes:
90
+ _config: project settings; the polling settings build every row
91
+ step's window.
92
+ _provider: the LLM port implementation of the diagnosis request.
93
+ _generator: the regeneration engine — every row step regenerates as
94
+ its own per-step unit.
95
+ _cache: the step cache of the test — record 0 of an earlier row step
96
+ anchors its cached code, the write-back happens per step.
97
+ _budgets: the per-test attempt registry — the cycle cap and the
98
+ per-cycle refresh.
99
+ _reporter: the visibility point for the healing events of the row.
100
+ """
101
+
102
+ def __init__( # noqa: PLR0913, PLR0917 — the signature is fixed by the groups cell contract
103
+ self,
104
+ config: Config,
105
+ provider: LLMProvider,
106
+ generator: StepGenerator,
107
+ cache: StepCache,
108
+ budgets: RunBudgets,
109
+ reporter: StepReporter,
110
+ ) -> None:
111
+ """Keep the collaborators of the recovery cycles.
112
+
113
+ Args:
114
+ config: project settings; the polling settings build every row
115
+ step's settle window.
116
+ provider: the LLM port implementation diagnosing the failure.
117
+ generator: the regeneration engine of the row steps.
118
+ cache: the step cache of the test — record 0 anchors and the
119
+ per-step write-backs.
120
+ budgets: the per-test attempt registry — the cycle cap and the
121
+ per-cycle healing refresh.
122
+ reporter: the visibility point for the healing events of the row.
123
+ """
124
+ self._config = config
125
+ self._provider = provider
126
+ self._generator = generator
127
+ self._cache = cache
128
+ self._budgets = budgets
129
+ self._reporter = reporter
130
+
131
+ def recover( # noqa: PLR0913, PLR0917 — the signature is fixed by the groups cell contract
132
+ self,
133
+ group_prompt: str,
134
+ traces: list[GroupStepOutcome],
135
+ prepared: PreparedStep,
136
+ step_type: str,
137
+ previous_steps: list[ScenarioStep],
138
+ identity: StepIdentity,
139
+ attempt_history: list[StepAttempt],
140
+ page: PageFacade,
141
+ window: SettleWindow, # noqa: ARG002 — the contract parameter; every row step builds its own window from its trace
142
+ memory: StepMemory,
143
+ ) -> CachedStep:
144
+ """Diagnose a failed group step and recover the affected row of the group.
145
+
146
+ Args:
147
+ group_prompt: the group prompt of the failed step's group,
148
+ verbatim — reaches the diagnosis and every row request.
149
+ traces: the group's step traces in execution order — the failed
150
+ step's record is the last one; copied into the recovery-local
151
+ view on entry, the caller's list is never mutated.
152
+ prepared: the render product of the failed step, threaded from
153
+ the executor — the active failure's instruction rides the
154
+ diagnosis as STEP; its INPUTS and RESULTS blocks reach the
155
+ requests of the failed step's own row entry through the
156
+ fresh re-render of its trace.
157
+ step_type: action or assertion — the failed step's kind, carried
158
+ into the diagnosis.
159
+ previous_steps: the typed scenario records of the test, in
160
+ execution order — copied into the recovery-local view, every
161
+ row regeneration request carries the view, and the
162
+ outside-group resolution matches the quote against its
163
+ instructions.
164
+ identity: the address of the failed step — the active failure's
165
+ identity on entry; the row resolves every regeneration
166
+ identity, the failed step's included, from the trace records.
167
+ attempt_history: the per-step attempt history of the failed
168
+ step, grown to the failure — the active failure's history;
169
+ the failed step's row entry reuses it, the terminal-failure
170
+ facts derive from its last record.
171
+ page: the live page facade of the test — the row re-executes
172
+ forward on the current page.
173
+ window: the settle window of the failed step's execution — the
174
+ contract parameter; every row step builds its own window
175
+ from its trace.
176
+ memory: the test memory — the render source of every row
177
+ re-render and the publication point of every accepted row
178
+ step's captures.
179
+
180
+ Returns:
181
+ The healed cached step of the failed step — written back to the
182
+ cache by the regeneration of its row entry; the row replays the
183
+ group's own steps from the earliest affected one, each
184
+ re-rendered against the current memory and regenerating under a
185
+ fresh full healing counter; a repeat failure re-enters the
186
+ cap-checked cycle with the failing row step as the active
187
+ failure.
188
+
189
+ Raises:
190
+ ProductDefectError: the diagnosis says the application is
191
+ genuinely broken — the loud test failure, no cycle is spent
192
+ on it.
193
+ IncurableStepError: the per-group cycle cap is exhausted, the
194
+ diagnosis is incurable, or the quoted root lives outside the
195
+ group — every form carries its verdict.
196
+ PrettyplayError: a row step's re-render hit an authoring error —
197
+ that step's failure, loud, never silently skipped.
198
+ LLMUnavailableError: the provider service failed after the
199
+ bounded transport retries; no engine retry, the steering
200
+ dialog never intercepts it.
201
+ """
202
+ local_scenario = [record.model_copy() for record in previous_steps]
203
+ local_traces = [trace.model_copy() for trace in traces]
204
+ scenario_occurrences = _scenario_occurrences(local_scenario, local_traces, group_prompt)
205
+ active = _ActiveFailure(prepared, step_type, identity, attempt_history, len(local_traces) - 1)
206
+
207
+ while True:
208
+ if not self._budgets.open_group_cycle(group_prompt):
209
+ # colon-free authored reason; the verdict authors the same explanation
210
+ raise IncurableStepError(
211
+ active.prepared.instruction,
212
+ _CYCLE_CAP,
213
+ "",
214
+ verdict=FailureVerdict("incurable", _CYCLE_CAP, _CYCLE_CAP_RECOMMENDATION),
215
+ )
216
+
217
+ code, error = _last_facts(active.history) # the active failure's terminal-failure facts
218
+ verdict = classify_group_failure(
219
+ self._config,
220
+ self._provider,
221
+ group_prompt,
222
+ local_traces,
223
+ local_scenario,
224
+ active.prepared.instruction,
225
+ active.step_type,
226
+ active.history,
227
+ page,
228
+ )
229
+
230
+ quoted_index = _quoted_group_index(verdict, local_traces)
231
+ outside = _outside_root(verdict, local_scenario, group_prompt)
232
+
233
+ if verdict.category == "product_defect":
234
+ # anti-masking — the loud test failure, even when the quote points anywhere
235
+ explanation = _mapped_explanation(verdict, quoted_index is not None)
236
+ raise ProductDefectError(
237
+ active.prepared.instruction,
238
+ explanation,
239
+ error,
240
+ FailureVerdict(verdict.category, explanation, verdict.recommendation),
241
+ )
242
+
243
+ if outside is not None and quoted_index is None:
244
+ # the quoted root lives outside the group — recovery is out of mandate; a quote
245
+ # that matched a group step names the row start first, never the outside raise
246
+ explanation = f"{_OUTSIDE_ROOT} — {outside}"
247
+ raise IncurableStepError(
248
+ active.prepared.instruction,
249
+ explanation,
250
+ error,
251
+ code=code,
252
+ verdict=FailureVerdict("incurable", explanation, verdict.recommendation),
253
+ )
254
+
255
+ if verdict.category == "incurable":
256
+ explanation = _mapped_explanation(verdict, quoted_index is not None)
257
+ raise IncurableStepError(
258
+ active.prepared.instruction,
259
+ explanation,
260
+ error,
261
+ code=code,
262
+ verdict=FailureVerdict(verdict.category, explanation, verdict.recommendation),
263
+ )
264
+
265
+ # recoverable — the row runs from the quoted group step, else from the active failure's occurrence
266
+ row_start = quoted_index if quoted_index is not None else active.occurrence
267
+ row = list(enumerate(local_traces))[row_start:]
268
+
269
+ for _occurrence, trace in row: # a fresh full healing counter per row step, every cycle
270
+ self._budgets.refresh_healing(trace.identity)
271
+
272
+ try:
273
+ return self._run_row(
274
+ row, group_prompt, local_scenario, local_traces, scenario_occurrences, active, verdict, memory, page
275
+ )
276
+ except IncurableStepError:
277
+ # the failure state is fresh — the failing row step became the active failure; a
278
+ # new cycle is cap-checked and re-diagnosed with the updated local views
279
+ continue
280
+
281
+ def _run_row( # noqa: PLR0913, PLR0917 — the row threading is fixed by the recovery contract
282
+ self,
283
+ row: list[tuple[int, GroupStepOutcome]],
284
+ group_prompt: str,
285
+ local_scenario: list[ScenarioStep],
286
+ local_traces: list[GroupStepOutcome],
287
+ scenario_occurrences: list[int | None],
288
+ active: _ActiveFailure,
289
+ verdict: GroupFailureClassification,
290
+ memory: StepMemory,
291
+ page: PageFacade,
292
+ ) -> CachedStep:
293
+ """Regenerate the row sequentially and return the failed step's healed step.
294
+
295
+ Args:
296
+ row: the row's occurrence-index and trace-record pairs, the
297
+ failed step's last.
298
+ group_prompt: the group prompt of the recovered group, verbatim
299
+ — the framing of every row request.
300
+ local_scenario: the recovery-local scenario view — carried into
301
+ every row request, its occurrence instructions updated per
302
+ acceptance.
303
+ local_traces: the recovery-local trace view — the occurrence
304
+ records updated per acceptance, marked failed per row
305
+ failure.
306
+ scenario_occurrences: the local_scenario indices of the group's
307
+ own records, in group order.
308
+ active: the active failure — its occurrence's entry reuses its
309
+ grown history; a failing row step replaces its facts before
310
+ the re-raise.
311
+ verdict: the recoverable diagnosis verdict of the cycle — its
312
+ recommendation rides every row request.
313
+ memory: the test memory — the render source of every row
314
+ re-render and the publication point of every acceptance.
315
+ page: the live page facade — the row re-executes forward on the
316
+ current page.
317
+
318
+ Returns:
319
+ The healed cached step of the failed step — every row step
320
+ regenerates as its own per-step unit; the active failure's entry
321
+ reuses its grown history, an earlier one anchors a fresh list by
322
+ record 0.
323
+
324
+ Raises:
325
+ IncurableStepError: a row step's regeneration exhausted its
326
+ fresh healing pool — the active failure already updated to
327
+ that occurrence; caught by the caller for the cycle
328
+ re-entry.
329
+ PrettyplayError: a row step's re-render hit an authoring error —
330
+ that step's failure, loud, never silently skipped.
331
+ LLMUnavailableError: the provider service failed after the
332
+ bounded transport retries; no engine retry.
333
+ ComplianceVerdictError: the compliance verdict of a green row
334
+ candidate did not parse; nothing is cached.
335
+ """
336
+ healed: CachedStep | None = None
337
+
338
+ for occurrence, trace in row:
339
+ if trace.delay is not None:
340
+ time.sleep(trace.delay) # the quiet library-level pause — never slow_mo
341
+
342
+ # the fresh render against the current context: sentence + recorded vars + the current memory
343
+ fresh = render_step(trace.sentence, trace.step_type, memory, trace.vars)
344
+
345
+ # the active failure's own entry reuses its grown history; an earlier step anchors a fresh one
346
+ row_history = active.history if occurrence == active.occurrence else self._anchored_history(trace)
347
+ row_window = SettleWindow(self._config.polling_timeout, self._config.polling_delay, trace.tries)
348
+
349
+ self._reporter.emit("on_healing_started", {"step_text": fresh.instruction, "category": "recoverable"})
350
+ try:
351
+ healed = self._generator.regenerate(
352
+ identity=trace.identity,
353
+ prepared=fresh,
354
+ step_type=trace.step_type,
355
+ previous_steps=local_scenario,
356
+ group_prompt=group_prompt,
357
+ page=page,
358
+ attempt_history=row_history,
359
+ recommendation=verdict.recommendation,
360
+ window=row_window,
361
+ memory=memory,
362
+ )
363
+ except IncurableStepError:
364
+ # the failing occurrence becomes the active failure of the next diagnosis — its
365
+ # fresh prepared step, identity, kind and grown history; the local trace view
366
+ # marks the occurrence failed, preserving the freshly prepared instruction
367
+ active.prepared = fresh
368
+ active.step_type = trace.step_type
369
+ active.identity = trace.identity
370
+ active.history = row_history
371
+ active.occurrence = occurrence
372
+ local_traces[occurrence] = trace.model_copy(
373
+ update={"instruction": fresh.instruction, "outcome": "failed"}
374
+ )
375
+ raise
376
+
377
+ # accepted: the fresh instruction replaces the occurrence in both local views, by index —
378
+ # the failed step's own record was never appended to the scenario, its occurrence finds none
379
+ local_traces[occurrence] = trace.model_copy(update={"instruction": fresh.instruction})
380
+ scenario_index = scenario_occurrences[occurrence]
381
+ if scenario_index is not None:
382
+ local_scenario[scenario_index] = local_scenario[scenario_index].model_copy(
383
+ update={"instruction": fresh.instruction}
384
+ )
385
+
386
+ self._reporter.emit("on_healed", {"step_text": fresh.instruction, "explanation": verdict.root_cause})
387
+ logger.info(
388
+ "group_row_recovered",
389
+ extra={"group": group_prompt, "step": fresh.instruction, "earliest": verdict.earliest_step},
390
+ )
391
+
392
+ return healed # type: ignore[return-value] — the row always ends with the active failure's entry
393
+
394
+ def _anchored_history(self, trace: GroupStepOutcome) -> list[StepAttempt]:
395
+ """Compose the fresh per-step history of an earlier row step, anchored by record 0.
396
+
397
+ Args:
398
+ trace: the trace record of the earlier row step.
399
+
400
+ Returns:
401
+ The one-record history list the regeneration appends into —
402
+ record 0 carries the cached code of the trace identity, empty
403
+ when none is cached.
404
+ """
405
+ cached = self._cache.load(trace.identity)
406
+
407
+ return [
408
+ StepAttempt(
409
+ code=cached.code if cached is not None else "",
410
+ error="",
411
+ outcome=OUTCOME_ORIGINAL,
412
+ url_before=trace.url_before,
413
+ url_after=trace.url_after,
414
+ )
415
+ ]
416
+
417
+
418
+ def _quoted_group_index(verdict: GroupFailureClassification, traces: list[GroupStepOutcome]) -> int | None:
419
+ """Resolve the earliest-step quote against the group's own recorded instructions.
420
+
421
+ Args:
422
+ verdict: the diagnosis verdict of the cycle.
423
+ traces: the recovery-local trace view of the group's steps, in
424
+ execution order.
425
+
426
+ Returns:
427
+ The index of the trace whose instruction equals the quote verbatim,
428
+ or ``None`` when the quote names no group step.
429
+ """
430
+ return next((index for index, trace in enumerate(traces) if trace.instruction == verdict.earliest_step), None)
431
+
432
+
433
+ def _outside_root(
434
+ verdict: GroupFailureClassification,
435
+ previous_steps: list[ScenarioStep],
436
+ group_prompt: str,
437
+ ) -> str | None:
438
+ """Resolve the earliest-step quote against the instructions of the steps outside the group.
439
+
440
+ Args:
441
+ verdict: the diagnosis verdict of the cycle.
442
+ previous_steps: the recovery-local scenario view of the test, in
443
+ execution order.
444
+ group_prompt: the group prompt of the recovered group.
445
+
446
+ Returns:
447
+ The verbatim instruction of the outside step the quote names, or
448
+ ``None`` when the quote names none.
449
+ """
450
+ return next(
451
+ (
452
+ record.instruction
453
+ for record in previous_steps
454
+ if record.instruction == verdict.earliest_step and record.group_prompt != group_prompt
455
+ ),
456
+ None,
457
+ )
458
+
459
+
460
+ def _mapped_explanation(verdict: GroupFailureClassification, quoted_in_group: bool) -> str:
461
+ """Map the diagnosis explanation onto the failure taxonomy.
462
+
463
+ Args:
464
+ verdict: the diagnosis verdict of the cycle.
465
+ quoted_in_group: whether the quote matched a group step sentence.
466
+
467
+ Returns:
468
+ The mapped explanation of the terminal failure — the root cause
469
+ plus the earliest-step quote in parentheses when it named a group
470
+ step.
471
+ """
472
+ if quoted_in_group:
473
+ return f"{verdict.root_cause} (earliest affected step: {verdict.earliest_step})"
474
+
475
+ return verdict.root_cause
476
+
477
+
478
+ def _last_facts(history: list[StepAttempt]) -> tuple[str, str]:
479
+ """Derive the terminal-failure facts from the last record of the failed step's history.
480
+
481
+ Args:
482
+ history: the per-step attempt history of the failed step.
483
+
484
+ Returns:
485
+ The code and the error of the last record; the empty pair when the
486
+ history is empty.
487
+ """
488
+ if not history:
489
+ return "", ""
490
+
491
+ return history[-1].code, history[-1].error
@@ -0,0 +1,149 @@
1
+ """Healing of failed cached steps: classification verdict decides the branch."""
2
+
3
+ from ..cache import CachedStep, RunBudgets, StepCache
4
+ from ..config import Config
5
+ from ..driver import PageFacade
6
+ from ..failures import FailureVerdict, IncurableStepError, ProductDefectError
7
+ from ..llm import LLMProvider, ScenarioStep
8
+ from ..reporting import StepReporter
9
+ from .attempts import StepAttempt
10
+ from .classification import classify_step_failure
11
+ from .generator import StepGenerator
12
+ from .polling import SettleWindow
13
+ from .renderer import PreparedStep, StepMemory
14
+
15
+
16
+ class StepHealer:
17
+ """Heals a failed cached step according to the classification verdict.
18
+
19
+ Attributes:
20
+ _config: project settings; the screenshot flag feeds the requests.
21
+ _provider: the LLM port implementation classifying the failure.
22
+ _generator: the regeneration loop of the rot branch.
23
+ _reporter: the visibility point for healing events.
24
+ """
25
+
26
+ def __init__( # noqa: PLR0913, PLR0917 — the signature is fixed by the engine contract
27
+ self,
28
+ config: Config,
29
+ provider: LLMProvider,
30
+ generator: StepGenerator,
31
+ cache: StepCache, # noqa: ARG002 — written by the generator only; kept for contract symmetry
32
+ budgets: RunBudgets, # noqa: ARG002 — spent by the generator only; kept for contract symmetry
33
+ reporter: StepReporter,
34
+ ) -> None:
35
+ """Keep the collaborators of the healing branch.
36
+
37
+ Args:
38
+ config: project settings; ``send_screenshots`` attaches page images.
39
+ provider: the LLM port implementation classifying the failure.
40
+ generator: the regeneration loop handling the rot verdict.
41
+ cache: the store of the failed step; written by the generator
42
+ only — accepted for contract symmetry, never read here.
43
+ budgets: the per-test attempt registry; spent by the generator —
44
+ accepted for contract symmetry, never read here.
45
+ reporter: the visibility point for engine events.
46
+ """
47
+ self._config = config
48
+ self._provider = provider
49
+ self._generator = generator
50
+ self._reporter = reporter
51
+
52
+ def heal( # noqa: PLR0913, PLR0917 — the signature is fixed by the engine contract
53
+ self,
54
+ step: CachedStep,
55
+ error: str,
56
+ prepared: PreparedStep,
57
+ step_type: str,
58
+ previous_steps: list[ScenarioStep],
59
+ page: PageFacade,
60
+ attempt_history: list[StepAttempt],
61
+ window: SettleWindow,
62
+ memory: StepMemory,
63
+ ) -> CachedStep:
64
+ """Heal a failed cached step according to the classification verdict.
65
+
66
+ Args:
67
+ step: the cached step whose code failed.
68
+ error: the failure description of the cached code.
69
+ prepared: the render product of the failed step — the
70
+ classification and every regeneration request carry the
71
+ prepared instruction with its INPUTS and RESULTS blocks; the
72
+ raw template sentence never reaches a request.
73
+ step_type: action or assertion — forwarded into every regeneration
74
+ request.
75
+ previous_steps: the typed scenario records of the previous steps
76
+ of the test, in execution order — each the raw sentence plus
77
+ its permanent group membership; scenario context for
78
+ regeneration, never the normalized addressing forms.
79
+ page: the live page facade the healed code runs against.
80
+ attempt_history: the anchored per-step attempt history — record 0
81
+ carries the original cached code seeded by the executor;
82
+ threaded by reference into the regeneration loop, which
83
+ appends every further attempt of the healing.
84
+ window: the settle window of the current step execution — the
85
+ regenerated candidates absorb transient failures inside it.
86
+ memory: the test memory of the executor — the accepted
87
+ regeneration's captures publish inside the regeneration loop
88
+ at the acceptance point, after validation and the gate.
89
+
90
+ Returns:
91
+ The healed step with proven code, already cached by the
92
+ generator — the healer serves ordinary steps only: a group step
93
+ never reaches it, the regeneration request always carries
94
+ ``group_prompt=None``.
95
+
96
+ Raises:
97
+ ProductDefectError: the expectation of the step is genuinely
98
+ broken in the product; the cache stays untouched.
99
+ IncurableStepError: the verdict says regeneration cannot help,
100
+ or the regeneration attempt budget is exhausted — the
101
+ exhaustion reuses the verdict of this classification, no
102
+ second LLM request is made; the code field carries the
103
+ cached step code.
104
+ LLMUnavailableError: the provider service failed after the
105
+ bounded transport retries; no engine retry.
106
+ """
107
+ classification = classify_step_failure(
108
+ self._config, self._provider, prepared.instruction, step.code, error, page
109
+ )
110
+
111
+ verdict = FailureVerdict(
112
+ category=classification.category,
113
+ explanation=classification.explanation,
114
+ recommendation=classification.recommendation,
115
+ )
116
+
117
+ self._reporter.emit(
118
+ "on_healing_started", {"step_text": prepared.instruction, "category": classification.category}
119
+ )
120
+
121
+ if classification.category == "product_defect":
122
+ raise ProductDefectError(prepared.instruction, classification.explanation, error, verdict)
123
+ if classification.category == "incurable":
124
+ raise IncurableStepError(
125
+ prepared.instruction, classification.explanation, error, code=step.code, verdict=verdict
126
+ )
127
+
128
+ # rot | fixable — regeneration carrying the recommendation, the render product and the memory
129
+ try:
130
+ healed = self._generator.regenerate(
131
+ identity=step.identity,
132
+ prepared=prepared,
133
+ step_type=step_type,
134
+ previous_steps=previous_steps,
135
+ group_prompt=None, # a group step never reaches the healer — no framing on this path
136
+ page=page,
137
+ attempt_history=attempt_history,
138
+ recommendation=classification.recommendation,
139
+ window=window,
140
+ memory=memory,
141
+ )
142
+ except IncurableStepError as inner: # regeneration exhausted — the verdict stays None inside
143
+ # the entry verdict of this classification, never a second LLM request; raise … from inner
144
+ raise IncurableStepError(
145
+ prepared.instruction, inner.reason, inner.error, code=step.code, verdict=verdict
146
+ ) from inner
147
+
148
+ self._reporter.emit("on_healed", {"step_text": prepared.instruction, "explanation": classification.explanation})
149
+ return healed