artest-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ """Domain models, run events and plugin protocols for Artest.
2
+
3
+ Every pipeline stage reads and writes these types, and the TypeScript contract is generated from
4
+ them. This package depends on pydantic only, so plugin authors can depend on it cheaply.
5
+ """
6
+
7
+ from artest.core.artifacts import ArtifactRef
8
+ from artest.core.capture import (
9
+ AxeImpact,
10
+ AxeReport,
11
+ AxeViolation,
12
+ Capture,
13
+ ConsoleEntry,
14
+ ConsoleLevel,
15
+ ElementRef,
16
+ FieldMessage,
17
+ FixedLayer,
18
+ FormProbe,
19
+ NetworkSummary,
20
+ PastEdge,
21
+ ResourceType,
22
+ ResourceWeight,
23
+ ValidityReason,
24
+ Viewport,
25
+ WebVitals,
26
+ )
27
+ from artest.core.events import (
28
+ DomainEvent,
29
+ ExplorerActed,
30
+ FindingMerged,
31
+ FindingRejected,
32
+ FindingReported,
33
+ FunnelModelBuilt,
34
+ PageCaptured,
35
+ StepLeftOut,
36
+ TrainingProvidersAllowed,
37
+ WalkEnded,
38
+ domain_event,
39
+ )
40
+ from artest.core.findings import (
41
+ Claim,
42
+ Evidence,
43
+ Finding,
44
+ FindingCategory,
45
+ FindingSource,
46
+ RejectionReason,
47
+ Report,
48
+ Severity,
49
+ SourceKind,
50
+ )
51
+ from artest.core.funnel import (
52
+ Action,
53
+ ActionKind,
54
+ Cta,
55
+ ExploreOutcome,
56
+ Form,
57
+ FormField,
58
+ FunnelFacts,
59
+ FunnelGraph,
60
+ FunnelStep,
61
+ MessageKind,
62
+ PageMessage,
63
+ PriceKind,
64
+ PriceMention,
65
+ StepKind,
66
+ StepView,
67
+ Transition,
68
+ TrustKind,
69
+ TrustSignal,
70
+ )
71
+ from artest.core.geometry import Box, Size
72
+ from artest.core.ids import CaptureId, ElementId, FindingId, StepId
73
+ from artest.core.links import link_address
74
+ from artest.core.plugins import RULE_API_VERSION, RULES_GROUP, Rule, RuleClaim, RuleContext, StepPage
75
+ from artest.core.product import PRODUCT, Product
76
+ from artest.core.variants import DomPatch
77
+
78
+ CONTRACT_VERSION = "0.18.0"
79
+ """Semver of the data contract shared with the web app and run bundles (ADR-0015).
80
+
81
+ Additive changes bump the minor version. Removing or renaming anything bumps the major version.
82
+ """
83
+
84
+ __all__ = [
85
+ "CONTRACT_VERSION",
86
+ "PRODUCT",
87
+ "RULES_GROUP",
88
+ "RULE_API_VERSION",
89
+ "Action",
90
+ "ActionKind",
91
+ "ArtifactRef",
92
+ "AxeImpact",
93
+ "AxeReport",
94
+ "AxeViolation",
95
+ "Box",
96
+ "Capture",
97
+ "CaptureId",
98
+ "Claim",
99
+ "ConsoleEntry",
100
+ "ConsoleLevel",
101
+ "Cta",
102
+ "DomPatch",
103
+ "DomainEvent",
104
+ "ElementId",
105
+ "ElementRef",
106
+ "Evidence",
107
+ "ExploreOutcome",
108
+ "ExplorerActed",
109
+ "FieldMessage",
110
+ "Finding",
111
+ "FindingCategory",
112
+ "FindingId",
113
+ "FindingMerged",
114
+ "FindingRejected",
115
+ "FindingReported",
116
+ "FindingSource",
117
+ "FixedLayer",
118
+ "Form",
119
+ "FormField",
120
+ "FormProbe",
121
+ "FunnelFacts",
122
+ "FunnelGraph",
123
+ "FunnelModelBuilt",
124
+ "FunnelStep",
125
+ "MessageKind",
126
+ "NetworkSummary",
127
+ "PageCaptured",
128
+ "PageMessage",
129
+ "PastEdge",
130
+ "PriceKind",
131
+ "PriceMention",
132
+ "Product",
133
+ "RejectionReason",
134
+ "Report",
135
+ "ResourceType",
136
+ "ResourceWeight",
137
+ "Rule",
138
+ "RuleClaim",
139
+ "RuleContext",
140
+ "Severity",
141
+ "Size",
142
+ "SourceKind",
143
+ "StepId",
144
+ "StepKind",
145
+ "StepLeftOut",
146
+ "StepPage",
147
+ "StepView",
148
+ "TrainingProvidersAllowed",
149
+ "Transition",
150
+ "TrustKind",
151
+ "TrustSignal",
152
+ "ValidityReason",
153
+ "Viewport",
154
+ "WalkEnded",
155
+ "WebVitals",
156
+ "domain_event",
157
+ "link_address",
158
+ ]
@@ -0,0 +1,19 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import Field, StringConstraints
4
+
5
+ from artest.core.base import CoreModel
6
+
7
+ Sha256 = Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")]
8
+
9
+
10
+ class ArtifactRef(CoreModel):
11
+ """A pointer to immutable content in the artifact store, addressed by its SHA-256.
12
+
13
+ Events carry these references instead of inlining screenshots or DOM snapshots, so the journal stays
14
+ small and the same bytes are stored once however often they're referenced.
15
+ """
16
+
17
+ sha256: Sha256
18
+ media_type: str = Field(pattern=r"^[a-z]+/[a-z0-9.+-]+$")
19
+ size: int = Field(ge=0)
artest/core/base.py ADDED
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class CoreModel(BaseModel):
5
+ """Base for every contract model.
6
+
7
+ Models are immutable, so a value read from the journal can't be changed by accident, and strict
8
+ about unknown fields, so a typo or an invented field fails loudly instead of being dropped. Fields
9
+ with defaults are always serialised, so the generated TypeScript marks them as required too.
10
+ """
11
+
12
+ model_config = ConfigDict(
13
+ frozen=True,
14
+ extra="forbid",
15
+ allow_inf_nan=False,
16
+ json_schema_serialization_defaults_required=True,
17
+ )
artest/core/capture.py ADDED
@@ -0,0 +1,323 @@
1
+ from enum import StrEnum
2
+ from typing import Self
3
+
4
+ from pydantic import AwareDatetime, Field, model_validator
5
+
6
+ from artest.core.artifacts import ArtifactRef
7
+ from artest.core.base import CoreModel
8
+ from artest.core.geometry import Box, Size
9
+ from artest.core.ids import CaptureId, ElementId, capture_of
10
+
11
+
12
+ class Viewport(StrEnum):
13
+ DESKTOP = "desktop"
14
+ MOBILE = "mobile"
15
+
16
+
17
+ class PastEdge(StrEnum):
18
+ """How a shopper gets at what reaches past the screen's right edge."""
19
+
20
+ SCROLLS = "scrolls"
21
+ """A box around it scrolls sideways, with room to."""
22
+ CLIPPED = "clipped"
23
+ """Something cuts it off, so no scrolling brings it on the screen."""
24
+ PAGE = "page"
25
+ """Nothing clips it, so the page itself goes on sideways."""
26
+
27
+
28
+ class ElementRef(CoreModel):
29
+ """An element of a capture that an agent may cite as evidence.
30
+
31
+ The selector and box are measured by the browser. A model only ever refers to the element by ``eid``.
32
+ """
33
+
34
+ eid: ElementId
35
+ role: str
36
+ name: str = Field(max_length=300, description="Accessible name")
37
+ tag: str
38
+ text: str = Field(default="", max_length=500, description="Visible text, truncated")
39
+ selector: str
40
+ box: Box
41
+ visible: bool
42
+ covered: bool = Field(
43
+ default=False,
44
+ description="As the page stands, nothing of it shows at any scroll position a shopper can reach: "
45
+ "something solid is drawn over it wherever it is on the screen, it is clipped away where it is, or "
46
+ "it is see-through, or holds only words a shopper can't read and shows nothing else. A sticky bar it "
47
+ "scrolls clear of doesn't count. Where the browser can't say, and in captures from before this was "
48
+ "measured, it reads False",
49
+ )
50
+ words_shown: str | None = Field(
51
+ default=None,
52
+ max_length=500,
53
+ description="Of its text, the words a shopper can read, where that isn't all of them: others are "
54
+ "see-through, tiny, moved off the page or clipped away, though the browser lays them out. Empty if "
55
+ "none can be read, though it may still show, by a picture or a background. None if all can, and in "
56
+ "captures from before this was measured. Only what agents are told and may quote goes by it",
57
+ )
58
+ overlaid: bool = Field(
59
+ default=False,
60
+ description="Something solid is drawn over most of it in the capture's screenshot, such as a popup "
61
+ "fixed to the screen. Unless it is also covered, a shopper can scroll it clear",
62
+ )
63
+ fixed: bool = Field(
64
+ default=False,
65
+ description="It keeps its place on the screen however far the page scrolls, and the capture's fixed "
66
+ "layer draws it. In captures from before this was measured, it reads False",
67
+ )
68
+ past_edge: PastEdge | None = Field(
69
+ default=None,
70
+ description="For an element reaching past the screen's right edge, how a shopper gets at the rest of "
71
+ "it. None for any other, and in captures from before this was measured, which don't say",
72
+ )
73
+ attributes: dict[str, str] = Field(
74
+ default_factory=dict[str, str],
75
+ description="The attributes rules care about, such as type, autocomplete, required and href",
76
+ )
77
+ form: ElementId | None = Field(default=None, description="The form this control belongs to, if any")
78
+
79
+ @property
80
+ def capture_id(self) -> str:
81
+ return capture_of(self.eid)
82
+
83
+
84
+ class WebVitals(CoreModel):
85
+ """Lab Web Vitals of the page a capture shows, measured by the web-vitals library in the browser.
86
+
87
+ They come from one visit on the explorer's machine and connection, with nothing cached, so they
88
+ show how a page behaves, not what shoppers experience. A page reached by client-side routing is a
89
+ soft navigation: its paints count from the click that routed to it, it made no request of its own,
90
+ and its layout shifts start afresh. A metric the page gave no reading for is None.
91
+ """
92
+
93
+ navigation_url: str = Field(
94
+ pattern=r"^https?://",
95
+ description="Where the measured navigation went; another page than the capture's when the page "
96
+ "changed its address without a navigation the browser recognised",
97
+ )
98
+ soft_navigation: bool = Field(description="Whether the page was reached by client-side routing")
99
+ ttfb_ms: float | None = Field(ge=0, description="Time to first byte; None after a soft navigation")
100
+ fcp_ms: float | None = Field(ge=0, description="First Contentful Paint")
101
+ lcp_ms: float | None = Field(ge=0, description="Largest Contentful Paint")
102
+ cls: float | None = Field(ge=0, description="Cumulative Layout Shift, unitless")
103
+ inp_lab_ms: float | None = Field(
104
+ ge=0,
105
+ description="Lab INP: the slowest response, from input to the next paint, to the explorer's own "
106
+ "clicks on this page since the previous capture. Field INP comes from real shoppers' "
107
+ "interactions and can differ a lot. 16 for a click answered within 16 ms, the shortest time the "
108
+ "browser reports. None when there was no such click. A click that loads another document can't be "
109
+ "timed: its page is gone before the next paint",
110
+ )
111
+
112
+
113
+ class ResourceType(StrEnum):
114
+ DOCUMENT = "document"
115
+ SCRIPT = "script"
116
+ STYLESHEET = "stylesheet"
117
+ IMAGE = "image"
118
+ FONT = "font"
119
+ MEDIA = "media"
120
+ FETCH = "fetch"
121
+ OTHER = "other"
122
+
123
+
124
+ class ResourceWeight(CoreModel):
125
+ requests: int = Field(ge=0)
126
+ transfer_bytes: int = Field(ge=0, description="Response headers and bodies as received, compressed")
127
+
128
+
129
+ class NetworkSummary(CoreModel):
130
+ """What the page loaded over the network since the previous capture, from its own document on.
131
+
132
+ The explorer's browser keeps no HTTP cache, so a page loads everything it needs as on a first
133
+ visit. A request still running at the capture is counted with the next capture of the same document.
134
+ """
135
+
136
+ requests: int = Field(ge=0)
137
+ transfer_bytes: int = Field(ge=0, description="Response headers and bodies as received, compressed")
138
+ failed: int = Field(
139
+ ge=0,
140
+ description="Requests that got an HTTP error status, or no response for a reason other than "
141
+ "the page cancelling them",
142
+ )
143
+ by_type: dict[ResourceType, ResourceWeight] = Field(default_factory=dict[ResourceType, ResourceWeight])
144
+
145
+ @model_validator(mode="after")
146
+ def _totals_add_up(self) -> Self:
147
+ weights = self.by_type.values()
148
+ if (self.requests, self.transfer_bytes) != (
149
+ sum(weight.requests for weight in weights),
150
+ sum(weight.transfer_bytes for weight in weights),
151
+ ):
152
+ msg = "the network totals must add up to the weights by type"
153
+ raise ValueError(msg)
154
+ return self
155
+
156
+
157
+ class ConsoleLevel(StrEnum):
158
+ ERROR = "error"
159
+ WARNING = "warning"
160
+
161
+
162
+ class ConsoleEntry(CoreModel):
163
+ """A console error or warning, or an exception nothing on the page caught."""
164
+
165
+ level: ConsoleLevel
166
+ text: str = Field(max_length=500, description="The message, truncated")
167
+ uncaught: bool = Field(default=False, description="Whether it is an exception nothing caught")
168
+ url: str | None = Field(default=None, description="The script or resource it came from, if known")
169
+
170
+
171
+ class AxeImpact(StrEnum):
172
+ MINOR = "minor"
173
+ MODERATE = "moderate"
174
+ SERIOUS = "serious"
175
+ CRITICAL = "critical"
176
+
177
+
178
+ class AxeViolation(CoreModel):
179
+ rule: str = Field(description="The axe-core rule ID, such as color-contrast")
180
+ impact: AxeImpact | None
181
+ description: str = Field(description="What the rule asks for, in axe-core's words")
182
+ help_url: str
183
+ tags: tuple[str, ...] = Field(description="axe-core's tags, such as wcag2aa and wcag143")
184
+ nodes: int = Field(ge=1, description="How many elements on the page fail the rule")
185
+ elements: tuple[ElementId, ...] = Field(
186
+ default=(),
187
+ description="The failing elements that are in the capture's element list, which leaves out "
188
+ "containers such as a list's",
189
+ )
190
+
191
+
192
+ class AxeReport(CoreModel):
193
+ """What axe-core found wrong on the page, as the capture saw it. Frames on the page aren't checked."""
194
+
195
+ version: str = Field(description="The axe-core version that checked the page")
196
+ violations: tuple[AxeViolation, ...] = ()
197
+
198
+
199
+ class ValidityReason(StrEnum):
200
+ """Why the browser's own validation flagged a field, after the browser's ValidityState."""
201
+
202
+ VALUE_MISSING = "value-missing"
203
+ CUSTOM_ERROR = "custom-error"
204
+ TYPE_MISMATCH = "type-mismatch"
205
+ PATTERN_MISMATCH = "pattern-mismatch"
206
+ TOO_SHORT = "too-short"
207
+ TOO_LONG = "too-long"
208
+ RANGE_UNDERFLOW = "range-underflow"
209
+ RANGE_OVERFLOW = "range-overflow"
210
+ STEP_MISMATCH = "step-mismatch"
211
+ BAD_INPUT = "bad-input"
212
+
213
+
214
+ class FieldMessage(CoreModel):
215
+ field: ElementId
216
+ reason: ValidityReason
217
+ message: str = Field(
218
+ max_length=300, description="What the browser showed: its own words, or the page's if it set some"
219
+ )
220
+
221
+
222
+ class FormProbe(CoreModel):
223
+ """The explorer submitted a checkout form empty just before this capture (ADR-0011).
224
+
225
+ The messages a page writes into itself are among the capture's elements. Those of the browser's own
226
+ validation are not: the browser shows them in a bubble outside the page, one at a time from the
227
+ first, and the screenshot leaves it out, so they are kept here. Only fields the browser flagged at
228
+ the submit have one: a form that turns that validation off, or a script that stops the submit
229
+ first, leaves none.
230
+ """
231
+
232
+ submitted_on: CaptureId = Field(description="The capture the form was submitted from")
233
+ form: ElementId | None = Field(description="The form in this capture; None once the page has none")
234
+ browser_messages: tuple[FieldMessage, ...] = Field(
235
+ default=(), description="The browser's own validation messages, by field in this capture"
236
+ )
237
+
238
+
239
+ class FixedLayer(CoreModel):
240
+ """What it takes to draw the elements fixed to the screen where each screen would show them (ADR-0012).
241
+
242
+ A full-page screenshot draws them once, where the capture's screen had them, so a tile further down the
243
+ page wouldn't show them where a shopper there sees them. Boxes are in the page's terms, as elements' are.
244
+ """
245
+
246
+ page: ArtifactRef = Field(description="The full-page screenshot with those elements left out")
247
+ layer: ArtifactRef = Field(description="One screen with only those elements on it, see-through elsewhere")
248
+ screen: Box = Field(description="The part of the page the capture's screen showed")
249
+ solid: tuple[Box, ...] = Field(default=(), description="What is solid among those elements")
250
+
251
+
252
+ class Capture(CoreModel):
253
+ """One page state in one viewport, as the explorer saw it.
254
+
255
+ The heavy parts (screenshot, DOM, accessibility tree) live in the artifact store; the capture keeps
256
+ references to them plus the element list agents and rules work from, and what the browser measured.
257
+ Captures recorded before Artest measured something hold None for it.
258
+ """
259
+
260
+ id: CaptureId
261
+ url: str = Field(pattern=r"^https?://")
262
+ title: str
263
+ viewport: Viewport
264
+ viewport_size: Size
265
+ page_size: Size
266
+ captured_at: AwareDatetime
267
+ screenshot: ArtifactRef
268
+ dom: ArtifactRef
269
+ aria: ArtifactRef
270
+ elements: tuple[ElementRef, ...] = ()
271
+ vitals: WebVitals | None = None
272
+ network: NetworkSummary | None = None
273
+ console: tuple[ConsoleEntry, ...] | None = Field(
274
+ default=None,
275
+ description="Errors and warnings since the previous capture, from this page's document on",
276
+ )
277
+ axe: AxeReport | None = None
278
+ probe: FormProbe | None = Field(default=None, description="The empty submit that led to this capture")
279
+ settled: bool = Field(
280
+ default=True,
281
+ description="Whether the page had stopped asking its server for things when it was captured. False "
282
+ "when it was taken as it stood, as a request stayed open, such as a chat widget's long poll. "
283
+ "Captures from before this was measured read True",
284
+ )
285
+ fixed_layer: FixedLayer | None = Field(
286
+ default=None,
287
+ description="What draws the elements fixed to the screen on each screen. None on a page with none "
288
+ "that can be drawn alone, and in captures from before this was measured",
289
+ )
290
+
291
+ @property
292
+ def images(self) -> tuple[ArtifactRef, ...]:
293
+ """Every picture of the page in the artifact store, which is what a vision agent may be shown."""
294
+ layer = self.fixed_layer
295
+ return (self.screenshot,) if layer is None else (self.screenshot, layer.page, layer.layer)
296
+
297
+ @model_validator(mode="after")
298
+ def _elements_belong_to_this_capture(self) -> Self:
299
+ seen: set[str] = set()
300
+ for element in self.elements:
301
+ if element.capture_id != self.id:
302
+ msg = f"element {element.eid} does not belong to capture {self.id}"
303
+ raise ValueError(msg)
304
+ if element.eid in seen:
305
+ msg = f"duplicate element id {element.eid}"
306
+ raise ValueError(msg)
307
+ seen.add(element.eid)
308
+ for eid in self._cited():
309
+ if eid not in seen:
310
+ msg = f"{eid} is cited but is not an element of capture {self.id}"
311
+ raise ValueError(msg)
312
+ return self
313
+
314
+ def _cited(self) -> list[str]:
315
+ """The elements that what the browser measured points at."""
316
+ cited = [eid for violation in self.axe.violations for eid in violation.elements] if self.axe else []
317
+ if self.probe is not None:
318
+ cited += [message.field for message in self.probe.browser_messages]
319
+ cited += [self.probe.form] if self.probe.form is not None else []
320
+ return cited
321
+
322
+ def element(self, eid: str) -> ElementRef | None:
323
+ return next((element for element in self.elements if element.eid == eid), None)
artest/core/events.py ADDED
@@ -0,0 +1,125 @@
1
+ """Domain events: what the pipeline tells the journal, the live UI and the exported report.
2
+
3
+ Control events (a step started, a run paused) belong to the runtime, which knows nothing about CRO. The
4
+ engine combines both into the full run event union.
5
+ """
6
+
7
+ from typing import Annotated, Literal
8
+
9
+ from pydantic import Field, TypeAdapter
10
+
11
+ from artest.core.artifacts import ArtifactRef
12
+ from artest.core.base import CoreModel
13
+ from artest.core.capture import Viewport
14
+ from artest.core.findings import Finding, FindingCategory, FindingSource, RejectionReason
15
+ from artest.core.funnel import Action, ExploreOutcome, FunnelFacts
16
+ from artest.core.geometry import Box
17
+ from artest.core.ids import CaptureId, FindingId, StepId
18
+
19
+
20
+ class PageCaptured(CoreModel):
21
+ type: Literal["page_captured"] = "page_captured"
22
+ capture_id: CaptureId
23
+ url: str
24
+ title: str
25
+ viewport: Viewport
26
+ screenshot: ArtifactRef
27
+ capture: ArtifactRef = Field(description="The full Capture as JSON in the artifact store")
28
+
29
+
30
+ class ExplorerActed(CoreModel):
31
+ type: Literal["explorer_acted"] = "explorer_acted"
32
+ viewport: Viewport
33
+ capture_id: CaptureId = Field(description="The page the action was taken on")
34
+ action: Action
35
+ target: Box | None = Field(default=None, description="Where the element was, for the UI's agent cursor")
36
+
37
+
38
+ class WalkEnded(CoreModel):
39
+ """How one viewport's walk ended, journaled before anything reviews what it reached, so that whoever runs
40
+ the audit and whoever reads its report know what the review never saw."""
41
+
42
+ type: Literal["walk_ended"] = "walk_ended"
43
+ viewport: Viewport
44
+ outcome: ExploreOutcome
45
+ reason: str = Field(description="Why it ended there, in a sentence of Artest's own")
46
+ url: str | None = Field(default=None, description="The last page it captured, if it captured any")
47
+ stopped_at: str | None = Field(
48
+ default=None,
49
+ description="The words of the button or field it stopped at, if it did: the page's, not Artest's",
50
+ )
51
+
52
+
53
+ class FunnelModelBuilt(CoreModel):
54
+ type: Literal["funnel_model_built"] = "funnel_model_built"
55
+ graph: ArtifactRef
56
+ step_count: int = Field(ge=1)
57
+ facts: FunnelFacts
58
+
59
+
60
+ class FindingReported(CoreModel):
61
+ type: Literal["finding_reported"] = "finding_reported"
62
+ finding: Finding
63
+
64
+
65
+ class FindingMerged(CoreModel):
66
+ """A finding about a problem already found, merged into the earlier finding instead of reported apart.
67
+
68
+ It carries the earlier finding as it is now: the same ID, with the new source's report, evidence
69
+ and source added, and a higher confidence when a rule and an agent agree (ADR-0012).
70
+ """
71
+
72
+ type: Literal["finding_merged"] = "finding_merged"
73
+ finding: Finding
74
+ merged: FindingId = Field(
75
+ description="The ID the evidence gate gave the finding that merged in, which no finding keeps"
76
+ )
77
+
78
+
79
+ class FindingRejected(CoreModel):
80
+ """A claimed finding the evidence gate dropped, kept on record so the UI can show what and why."""
81
+
82
+ type: Literal["finding_rejected"] = "finding_rejected"
83
+ source: FindingSource
84
+ step_id: str = Field(description="The step the claim was about, which may not exist")
85
+ viewport: Viewport
86
+ category: FindingCategory
87
+ title: str
88
+ reason: RejectionReason
89
+ detail: str = Field(description="What failed, such as which element was not in the capture")
90
+
91
+
92
+ class TrainingProvidersAllowed(CoreModel):
93
+ """The user let this site's pages go to providers that may train on prompts, or whose terms Artest doesn't
94
+ know (ADR-0006). Kept on record so the run and its report say so."""
95
+
96
+ type: Literal["training_providers_allowed"] = "training_providers_allowed"
97
+ url: str = Field(description="The site whose pages may go to them")
98
+
99
+
100
+ class StepLeftOut(CoreModel):
101
+ """A step an agent was to review but left out, kept on record so the UI can show what wasn't reviewed
102
+ and why."""
103
+
104
+ type: Literal["step_left_out"] = "step_left_out"
105
+ source: FindingSource = Field(description="The agent that left the step out")
106
+ step_id: StepId
107
+ viewport: Viewport
108
+ reason: str = Field(description="Why, in a sentence")
109
+
110
+
111
+ DomainEvent = Annotated[
112
+ PageCaptured
113
+ | ExplorerActed
114
+ | WalkEnded
115
+ | FunnelModelBuilt
116
+ | FindingReported
117
+ | FindingMerged
118
+ | FindingRejected
119
+ | StepLeftOut
120
+ | TrainingProvidersAllowed,
121
+ Field(discriminator="type"),
122
+ ]
123
+
124
+ domain_event: TypeAdapter[DomainEvent] = TypeAdapter(DomainEvent)
125
+ """Validates and serialises any domain event, dispatching on its ``type``."""