artest-engine 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.
- artest/engine/__init__.py +5 -0
- artest/engine/agents/__init__.py +74 -0
- artest/engine/agents/checkout_ux.py +270 -0
- artest/engine/agents/copy_persuasion.py +138 -0
- artest/engine/agents/mobile_layout.py +332 -0
- artest/engine/agents/prompting.py +216 -0
- artest/engine/agents/review.py +159 -0
- artest/engine/agents/trust_pricing.py +182 -0
- artest/engine/audit.py +542 -0
- artest/engine/bundle.py +794 -0
- artest/engine/config.py +318 -0
- artest/engine/data.py +470 -0
- artest/engine/events.py +74 -0
- artest/engine/evidence.py +357 -0
- artest/engine/fixtures.py +504 -0
- artest/engine/funnel/__init__.py +14 -0
- artest/engine/funnel/build.py +205 -0
- artest/engine/funnel/classify.py +297 -0
- artest/engine/funnel/extract.py +828 -0
- artest/engine/funnel/prices.py +301 -0
- artest/engine/py.typed +0 -0
- artest/engine/report.py +200 -0
- artest/engine/rules/__init__.py +143 -0
- artest/engine/rules/builtin/__init__.py +6 -0
- artest/engine/rules/builtin/_page.py +22 -0
- artest/engine/rules/builtin/accessibility.py +100 -0
- artest/engine/rules/builtin/account.py +67 -0
- artest/engine/rules/builtin/delivery.py +179 -0
- artest/engine/rules/builtin/distraction.py +163 -0
- artest/engine/rules/builtin/errors.py +135 -0
- artest/engine/rules/builtin/forms.py +187 -0
- artest/engine/rules/builtin/overflow.py +285 -0
- artest/engine/rules/builtin/shipping.py +114 -0
- artest/engine/rules/builtin/tap_targets.py +162 -0
- artest/engine/rules/builtin/vitals.py +144 -0
- artest/engine/rules/testing.py +24 -0
- artest/engine/workspace.py +212 -0
- artest_engine-0.2.0.dist-info/METADATA +24 -0
- artest_engine-0.2.0.dist-info/RECORD +42 -0
- artest_engine-0.2.0.dist-info/WHEEL +4 -0
- artest_engine-0.2.0.dist-info/entry_points.txt +13 -0
- artest_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Agents: small prompts with one output schema each, whose claims all pass the evidence gate.
|
|
2
|
+
|
|
3
|
+
An agent names no model. It declares what it needs from one, and the run's routing profile gives it the
|
|
4
|
+
model (ADR-0008).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from artest.core import Capture, Finding, FindingRejected, FunnelGraph, StepLeftOut, Viewport
|
|
12
|
+
from artest.engine.agents import checkout_ux, copy_persuasion, mobile_layout, trust_pricing
|
|
13
|
+
from artest.engine.agents.review import FailedReviews, Screenshots
|
|
14
|
+
from artest.llm import CallObserver, Gateway, ModelSpec, Needs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReviewFunnel(Protocol):
|
|
18
|
+
async def __call__(
|
|
19
|
+
self,
|
|
20
|
+
gateway: Gateway,
|
|
21
|
+
model: ModelSpec,
|
|
22
|
+
graph: FunnelGraph,
|
|
23
|
+
captures: Mapping[str, Capture],
|
|
24
|
+
*,
|
|
25
|
+
ids: Iterator[str],
|
|
26
|
+
viewport: Viewport = Viewport.DESKTOP,
|
|
27
|
+
observer: CallObserver | None = None,
|
|
28
|
+
screenshots: Screenshots | None = None,
|
|
29
|
+
failed: FailedReviews | None = None,
|
|
30
|
+
) -> Sequence[Finding | FindingRejected | StepLeftOut]:
|
|
31
|
+
"""Every claim the agent made, through the evidence gate, in order, after the steps it left out."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Agent:
|
|
37
|
+
title: str
|
|
38
|
+
"""What people call it, as the benchmark table does."""
|
|
39
|
+
name: str
|
|
40
|
+
"""What its model calls, findings and routing name it."""
|
|
41
|
+
review: ReviewFunnel
|
|
42
|
+
needs: Needs
|
|
43
|
+
"""What the model that does its work must do. The router picks that model from the run's profile."""
|
|
44
|
+
looks_at: Callable[[FunnelGraph], frozenset[str]] | None = None
|
|
45
|
+
"""For an agent that looks at screenshots, the captures of a funnel whose screenshots it reads. A
|
|
46
|
+
recording keeps them, since its replay draws the agent's images from them again. One that only reads
|
|
47
|
+
text has None."""
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if self.needs.vision != (self.looks_at is not None):
|
|
51
|
+
msg = f"the {self.title} agent needs vision exactly when it says which screenshots it looks at"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AGENTS = (
|
|
56
|
+
Agent("checkout UX", checkout_ux.SOURCE.name, checkout_ux.review_checkout, checkout_ux.NEEDS),
|
|
57
|
+
Agent(
|
|
58
|
+
"copy & persuasion", copy_persuasion.SOURCE.name, copy_persuasion.review_copy, copy_persuasion.NEEDS
|
|
59
|
+
),
|
|
60
|
+
Agent("trust & pricing", trust_pricing.SOURCE.name, trust_pricing.review_trust, trust_pricing.NEEDS),
|
|
61
|
+
Agent(
|
|
62
|
+
"mobile layout",
|
|
63
|
+
mobile_layout.SOURCE.name,
|
|
64
|
+
mobile_layout.review_mobile,
|
|
65
|
+
mobile_layout.NEEDS,
|
|
66
|
+
mobile_layout.looks_at,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
"""Artest's agents, which review a funnel after the rules, one after another in this order, which finding
|
|
70
|
+
IDs follow, except that one the routing profile has no model for doesn't run. Each looks at different steps
|
|
71
|
+
or for different problems, and leaves what the rules measure to them. An agent may still see a rule's
|
|
72
|
+
problem from its own side: the mobile agent a main call to action too small to tap, or content past the
|
|
73
|
+
screen's side, and the trust agent a cart "Total" that leaves out the shipping. Findings of an agent and a
|
|
74
|
+
rule about one element merge (ADR-0012)."""
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The checkout UX agent: what in each checkout step makes shoppers give up (DESIGN.md §8).
|
|
2
|
+
|
|
3
|
+
It reads one checkout step at a time and judges what no rule can: whether the order needs each field the
|
|
4
|
+
step asks a shopper to fill in, whether labels, buttons and error messages say what to do, whether the way
|
|
5
|
+
on as a guest is easy to find, and whether something draws shoppers away or the checkout adds a fee. It
|
|
6
|
+
checks each step for a short list of faults, writing its check of each before any finding, and a fault the
|
|
7
|
+
step gives nothing to judge, such as error wording on a step that shows no error, isn't asked about. Last, it
|
|
8
|
+
is asked about anything else on the step that would make a shopper give up, in any of its categories, since
|
|
9
|
+
no list names every problem a real checkout has. Whether checkout works without an account, how many fields
|
|
10
|
+
it asks for, generic error messages, when shipping costs show, links out of the checkout and an open coupon
|
|
11
|
+
field are the rules' (ADR-0025), and whether prices are clear and add up is the trust and pricing agent's,
|
|
12
|
+
so its prompt leaves them out. The page's messages, like its elements, reach the model only in a page block.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
|
|
20
|
+
from artest.core import (
|
|
21
|
+
Capture,
|
|
22
|
+
Claim,
|
|
23
|
+
Finding,
|
|
24
|
+
FindingCategory,
|
|
25
|
+
FindingRejected,
|
|
26
|
+
FindingSource,
|
|
27
|
+
FunnelGraph,
|
|
28
|
+
FunnelStep,
|
|
29
|
+
MessageKind,
|
|
30
|
+
SourceKind,
|
|
31
|
+
StepKind,
|
|
32
|
+
StepLeftOut,
|
|
33
|
+
StepView,
|
|
34
|
+
Viewport,
|
|
35
|
+
)
|
|
36
|
+
from artest.engine.agents.prompting import SITE_NOTICE, UNTRUSTED_PAGE, message_block, page_block
|
|
37
|
+
from artest.engine.agents.review import (
|
|
38
|
+
CHECKS_FIRST,
|
|
39
|
+
CheckedReview,
|
|
40
|
+
FailedReviews,
|
|
41
|
+
Screenshots,
|
|
42
|
+
fault_lines,
|
|
43
|
+
review_steps,
|
|
44
|
+
)
|
|
45
|
+
from artest.engine.funnel.classify import is_field
|
|
46
|
+
from artest.llm import CallObserver, Gateway, LlmRequest, Message, ModelSpec, Needs
|
|
47
|
+
|
|
48
|
+
SOURCE = FindingSource(kind=SourceKind.AGENT, name="checkout-ux")
|
|
49
|
+
NEEDS = Needs(reasoning="strong")
|
|
50
|
+
"""Its faults call for judgement about the whole step, such as whether the order needs a field the step
|
|
51
|
+
asks for, or whether the way on as a guest is easy to find next to signing in: work for a model that
|
|
52
|
+
reasons well (DESIGN.md §8)."""
|
|
53
|
+
MAX_FINDINGS = 5
|
|
54
|
+
|
|
55
|
+
type Asked = Callable[[FunnelGraph, StepView, Capture], bool]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _always(_graph: FunnelGraph, _view: StepView, _capture: Capture) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _has_fields(_graph: FunnelGraph, _view: StepView, capture: Capture) -> bool:
|
|
63
|
+
"""Whether the page has a field a shopper fills in or picks from, by its tag: a checkout built without
|
|
64
|
+
a form element has fields too, and so does one that marks required fields only with an asterisk."""
|
|
65
|
+
return any(element.visible and is_field(element) for element in capture.elements)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _shows_errors(_graph: FunnelGraph, view: StepView, _capture: Capture) -> bool:
|
|
69
|
+
"""Whether the page shows an error, or was captured after the explorer submitted its form empty: a page
|
|
70
|
+
may write its errors as plain text, which the funnel model finds only by what that submit brought up,
|
|
71
|
+
and may miss even then."""
|
|
72
|
+
return view.submitted_empty or any(message.kind is MessageKind.ERROR for message in view.messages)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _offers_guest_checkout(graph: FunnelGraph, _view: StepView, _capture: Capture) -> bool:
|
|
76
|
+
return graph.facts.guest_checkout is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Fault(NamedTuple):
|
|
80
|
+
text: str
|
|
81
|
+
category: FindingCategory | None
|
|
82
|
+
"""The category of a finding about it; None for the last fault, which may be in any of the agent's."""
|
|
83
|
+
asked: Asked = _always
|
|
84
|
+
"""Whether the step gives the fault something to judge: one about error wording needs an error."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Checkout that works only with an account is the forced-account rule's, so this is asked only where
|
|
88
|
+
# code found a way on as a guest.
|
|
89
|
+
GUEST_OPTION = Fault(
|
|
90
|
+
"the way to check out as a guest is harder to find than signing in or creating an account, such as a "
|
|
91
|
+
"small link below a big sign-in form",
|
|
92
|
+
FindingCategory.ACCOUNT_FRICTION,
|
|
93
|
+
_offers_guest_checkout,
|
|
94
|
+
)
|
|
95
|
+
# How many fields the checkout asks for, optional ones included, is the form-length rule's: which of
|
|
96
|
+
# them the order needs isn't.
|
|
97
|
+
NEEDLESS_FIELD = Fault(
|
|
98
|
+
"a field the shopper must fill in that the order doesn't need, such as a date of birth, where the "
|
|
99
|
+
"page doesn't say why it asks",
|
|
100
|
+
FindingCategory.FORM_FRICTION,
|
|
101
|
+
_has_fields,
|
|
102
|
+
)
|
|
103
|
+
# No rule judges whether a label is clear, and the copy agent looks for placeholder text on the landing
|
|
104
|
+
# and product pages only, so the checkout's own fields and options are this agent's.
|
|
105
|
+
UNCLEAR_LABEL = Fault(
|
|
106
|
+
"a field or option whose label leaves a shopper unsure what to enter or choose, such as placeholder "
|
|
107
|
+
"text like Lorem ipsum, or no word on the format a field wants",
|
|
108
|
+
FindingCategory.COPY_CLARITY,
|
|
109
|
+
_has_fields,
|
|
110
|
+
)
|
|
111
|
+
# The error-quality rule reports the generic messages its word lists know, such as "Invalid input" or
|
|
112
|
+
# "This field is required", and a form whose only messages are the browser's generic ones. Any other
|
|
113
|
+
# wording that leaves the shopper guessing is beyond those lists. When both report one message, the two
|
|
114
|
+
# findings merge (ADR-0012).
|
|
115
|
+
ERROR_WITHOUT_FIX = Fault(
|
|
116
|
+
"an error message that doesn't say which field is wrong or what to enter instead, such as "
|
|
117
|
+
'"Postcode is invalid" or "Please complete all required fields" (one such as "Enter a postcode, '
|
|
118
|
+
'for example 10115" is fine)',
|
|
119
|
+
FindingCategory.INPUT_ERRORS,
|
|
120
|
+
_shows_errors,
|
|
121
|
+
)
|
|
122
|
+
# The checkout-distraction rule counts the links that lead out of the checkout, reporting three or
|
|
123
|
+
# more, and reports an open coupon field. What draws the eye without being a way out, such as products
|
|
124
|
+
# offered to add, it can't count.
|
|
125
|
+
DISTRACTION = Fault(
|
|
126
|
+
"a promotion, offer or pop-up, other than links, a coupon field and a cookie or consent notice, that "
|
|
127
|
+
"draws attention away from finishing the order, such as a sale banner or products offered to add",
|
|
128
|
+
FindingCategory.CHECKOUT_DISTRACTION,
|
|
129
|
+
)
|
|
130
|
+
VAGUE_BUTTON = Fault(
|
|
131
|
+
"the button that goes on to the next step or places the order, labelled so vaguely that a shopper "
|
|
132
|
+
'can\'t tell what it does, such as "Submit" or "OK"',
|
|
133
|
+
FindingCategory.COPY_CLARITY,
|
|
134
|
+
)
|
|
135
|
+
# When shipping costs first show is the shipping-reveal rule's, and whether the payment and review
|
|
136
|
+
# steps' prices are clear and add up is the trust and pricing agent's: a fee the checkout adds, however
|
|
137
|
+
# clearly, is neither's. An unclear fee both report becomes one finding when both cite it. Tax is left
|
|
138
|
+
# out: a sales tax worked out from the address is expected, and a tax line a price includes adds nothing.
|
|
139
|
+
ADDED_FEE = Fault(
|
|
140
|
+
"a fee or surcharge the checkout adds on top of the items, shipping and tax, such as a service, "
|
|
141
|
+
"handling or payment fee",
|
|
142
|
+
FindingCategory.PRICING_TRANSPARENCY,
|
|
143
|
+
)
|
|
144
|
+
_FORM = (NEEDLESS_FIELD, UNCLEAR_LABEL, ERROR_WITHOUT_FIX, DISTRACTION, VAGUE_BUTTON)
|
|
145
|
+
FAULTS = {
|
|
146
|
+
StepKind.CHECKOUT_ACCOUNT: (GUEST_OPTION, *_FORM),
|
|
147
|
+
StepKind.CHECKOUT_ADDRESS: _FORM,
|
|
148
|
+
StepKind.CHECKOUT_SHIPPING: (ADDED_FEE, *_FORM),
|
|
149
|
+
StepKind.CHECKOUT_PAYMENT: (ADDED_FEE, *_FORM),
|
|
150
|
+
StepKind.CHECKOUT_REVIEW: (ADDED_FEE, *_FORM),
|
|
151
|
+
}
|
|
152
|
+
"""The faults each checkout step is checked for, in the order the agent checks them, before the last."""
|
|
153
|
+
CATEGORIES = frozenset(fault.category for faults in FAULTS.values() for fault in faults if fault.category)
|
|
154
|
+
# Asked on every step, after the listed faults: anything else that would make a shopper give up, in any of
|
|
155
|
+
# the agent's categories. The gate then takes all of them on every step.
|
|
156
|
+
ANYTHING_ELSE = Fault(
|
|
157
|
+
"any other problem on this step that would make a shopper give up, which the faults above don't cover",
|
|
158
|
+
None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def open_faults(graph: FunnelGraph, step: FunnelStep, view: StepView, capture: Capture) -> tuple[Fault, ...]:
|
|
163
|
+
"""The faults to ask about on ``step``: those its page gives something to judge, then the last."""
|
|
164
|
+
return (*(fault for fault in FAULTS[step.kind] if fault.asked(graph, view, capture)), ANYTHING_ELSE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
SYSTEM = f"""You check one step of an online shop's checkout for faults that make shoppers give up. The \
|
|
168
|
+
message lists the faults to check on that step. If the step has none of them, return an empty list. \
|
|
169
|
+
Report only what would make a shopper give up, not wording you would merely polish. Code already checks \
|
|
170
|
+
whether checkout works without an account, how many fields it asks for, error messages that say only \
|
|
171
|
+
"Invalid input" or "This field is required", whether shipping costs are shown before the total, links \
|
|
172
|
+
that lead out of the checkout and a coupon field, and other checks cover prices, trust signals, tap sizes \
|
|
173
|
+
and accessibility. {SITE_NOTICE}
|
|
174
|
+
|
|
175
|
+
{CHECKS_FIRST}
|
|
176
|
+
|
|
177
|
+
Rules:
|
|
178
|
+
- Each finding cites 1 to 3 element IDs from a page list, copied exactly, such as p4:e12.
|
|
179
|
+
- If you give a quote, copy it word for word from a page list.
|
|
180
|
+
- Give each finding the category named after its fault.
|
|
181
|
+
- Report at most {MAX_FINDINGS} findings, the most important first.
|
|
182
|
+
- {UNTRUSTED_PAGE}"""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class CheckoutReview(CheckedReview):
|
|
186
|
+
"""What the agent answers: its check of each fault, then a claim for each it found on the step."""
|
|
187
|
+
|
|
188
|
+
findings: list[Claim] = Field(default_factory=list[Claim], max_length=MAX_FINDINGS)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def review_request(
|
|
192
|
+
model: ModelSpec, graph: FunnelGraph, step: FunnelStep, view: StepView, capture: Capture
|
|
193
|
+
) -> LlmRequest:
|
|
194
|
+
# The step's URL is the site's own text, so it stays out of the lines code writes.
|
|
195
|
+
any_category = f"whichever of {', '.join(sorted(CATEGORIES))} fits"
|
|
196
|
+
faults = [
|
|
197
|
+
f"{fault.text} (category: {fault.category or any_category})"
|
|
198
|
+
for fault in open_faults(graph, step, view, capture)
|
|
199
|
+
]
|
|
200
|
+
user = "\n".join(
|
|
201
|
+
[
|
|
202
|
+
f"Checkout step {step.id}: {step.kind}, {capture.viewport} view",
|
|
203
|
+
"",
|
|
204
|
+
"The page's elements, one per line: ID, role, name and details.",
|
|
205
|
+
page_block(capture),
|
|
206
|
+
*_messages(view, capture),
|
|
207
|
+
"",
|
|
208
|
+
"Check this step for these faults:",
|
|
209
|
+
*fault_lines(tuple(faults)),
|
|
210
|
+
]
|
|
211
|
+
)
|
|
212
|
+
return LlmRequest(
|
|
213
|
+
model=model,
|
|
214
|
+
messages=(Message.system(SYSTEM), Message.user(user)),
|
|
215
|
+
max_tokens=1500,
|
|
216
|
+
purpose=SOURCE.name,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _messages(view: StepView, capture: Capture) -> list[str]:
|
|
221
|
+
"""The step's messages, under a line that says when the page showed them.
|
|
222
|
+
|
|
223
|
+
A page without messages gets no list: saying "none" read as something the explorer saw on the page,
|
|
224
|
+
and the model quoted it as such. If the explorer submitted its form empty, a line says so, since the
|
|
225
|
+
agent is then asked about error wording that the page may have written as plain text.
|
|
226
|
+
"""
|
|
227
|
+
if not view.messages:
|
|
228
|
+
if not view.submitted_empty:
|
|
229
|
+
return []
|
|
230
|
+
return [
|
|
231
|
+
"",
|
|
232
|
+
"The explorer submitted the step's form empty just before this view was captured, so any error "
|
|
233
|
+
"message the page shows is among its elements.",
|
|
234
|
+
]
|
|
235
|
+
after = " after the explorer submitted its form empty" if view.submitted_empty else ""
|
|
236
|
+
return [
|
|
237
|
+
"",
|
|
238
|
+
f"Messages on the page{after}, one per line: ID, kind, text and the field it is about.",
|
|
239
|
+
message_block(view.messages, capture),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def review_checkout(
|
|
244
|
+
gateway: Gateway,
|
|
245
|
+
model: ModelSpec,
|
|
246
|
+
graph: FunnelGraph,
|
|
247
|
+
captures: Mapping[str, Capture],
|
|
248
|
+
*,
|
|
249
|
+
ids: Iterator[str],
|
|
250
|
+
viewport: Viewport = Viewport.DESKTOP,
|
|
251
|
+
observer: CallObserver | None = None,
|
|
252
|
+
screenshots: Screenshots | None = None,
|
|
253
|
+
failed: FailedReviews | None = None,
|
|
254
|
+
) -> list[Finding | FindingRejected | StepLeftOut]:
|
|
255
|
+
"""Review every checkout step in ``viewport``, and pass each claim through the evidence gate."""
|
|
256
|
+
return await review_steps(
|
|
257
|
+
gateway,
|
|
258
|
+
graph,
|
|
259
|
+
captures,
|
|
260
|
+
reviews=lambda step: step.kind in FAULTS,
|
|
261
|
+
ask=lambda step, view, capture: review_request(model, graph, step, view, capture),
|
|
262
|
+
answer=CheckoutReview,
|
|
263
|
+
claims=lambda review: review.findings,
|
|
264
|
+
source=SOURCE,
|
|
265
|
+
allowed=CATEGORIES,
|
|
266
|
+
ids=ids,
|
|
267
|
+
viewport=viewport,
|
|
268
|
+
observer=observer,
|
|
269
|
+
failed=failed,
|
|
270
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The copy and persuasion agent: words on the pages that sell that leave shoppers unsure (DESIGN.md §8).
|
|
2
|
+
|
|
3
|
+
It reads the landing and product pages, where a shopper decides whether the shop and the product are
|
|
4
|
+
worth it, and judges what no rule can: whether the words say what is sold and what a button does, and
|
|
5
|
+
whether placeholder text was left behind. It checks each page for a short list of faults, writing its check
|
|
6
|
+
of each before any finding, and last for anything else in the words that would leave a shopper unsure. It
|
|
7
|
+
reads each element's text whole, since its subject is the words. Forms, errors, prices, trust signals and
|
|
8
|
+
tap sizes belong to the rules and the other agents, so its prompt leaves them out.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator, Mapping
|
|
12
|
+
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from artest.core import (
|
|
16
|
+
Capture,
|
|
17
|
+
Claim,
|
|
18
|
+
Finding,
|
|
19
|
+
FindingCategory,
|
|
20
|
+
FindingRejected,
|
|
21
|
+
FindingSource,
|
|
22
|
+
FunnelGraph,
|
|
23
|
+
FunnelStep,
|
|
24
|
+
SourceKind,
|
|
25
|
+
StepKind,
|
|
26
|
+
StepLeftOut,
|
|
27
|
+
StepView,
|
|
28
|
+
Viewport,
|
|
29
|
+
)
|
|
30
|
+
from artest.engine.agents.prompting import SITE_NOTICE, SKIPPED_ROLES, UNTRUSTED_PAGE, page_block
|
|
31
|
+
from artest.engine.agents.review import (
|
|
32
|
+
CHECKS_FIRST,
|
|
33
|
+
CheckedReview,
|
|
34
|
+
FailedReviews,
|
|
35
|
+
Screenshots,
|
|
36
|
+
fault_lines,
|
|
37
|
+
review_steps,
|
|
38
|
+
)
|
|
39
|
+
from artest.llm import CallObserver, Gateway, LlmRequest, Message, ModelSpec, Needs
|
|
40
|
+
|
|
41
|
+
SOURCE = FindingSource(kind=SourceKind.AGENT, name="copy-persuasion")
|
|
42
|
+
NEEDS = Needs()
|
|
43
|
+
"""It reads a page's words for a short, fixed list of faults, which is basic work for a model that reads."""
|
|
44
|
+
CATEGORIES = frozenset({FindingCategory.COPY_CLARITY})
|
|
45
|
+
MAX_FINDINGS = 3
|
|
46
|
+
SKIPPED = SKIPPED_ROLES | {"form"}
|
|
47
|
+
"""A form is named after all the text inside it, which the lines of its fields and labels list again."""
|
|
48
|
+
|
|
49
|
+
_PLACEHOLDER = "placeholder or leftover text, such as Lorem ipsum"
|
|
50
|
+
_VAGUE_LABEL = (
|
|
51
|
+
"a button or link labelled so vaguely that a shopper can't tell what it does, such as "
|
|
52
|
+
'"Click here" or "Submit"'
|
|
53
|
+
)
|
|
54
|
+
FAULTS = {
|
|
55
|
+
StepKind.LANDING: (_PLACEHOLDER, _VAGUE_LABEL, "a main heading that doesn't say what the shop sells"),
|
|
56
|
+
StepKind.PRODUCT: (_PLACEHOLDER, _VAGUE_LABEL, "text that doesn't say what the product is"),
|
|
57
|
+
}
|
|
58
|
+
"""The faults each kind of page is checked for, in the order the agent checks them, before the last."""
|
|
59
|
+
ANYTHING_ELSE = (
|
|
60
|
+
"any other problem in the page's words that would leave a shopper unsure what is sold or what to do "
|
|
61
|
+
"next, which the faults above don't cover"
|
|
62
|
+
)
|
|
63
|
+
"""Asked on every page, after the listed faults."""
|
|
64
|
+
|
|
65
|
+
SYSTEM = f"""You check the copy on one page of an online shop for faults that make shoppers leave. The \
|
|
66
|
+
message lists the faults to check on that page. If the page has none of them, return an empty list. \
|
|
67
|
+
Report only what would make a shopper leave, not copy you would merely polish. Other checks cover forms, \
|
|
68
|
+
errors, prices, shipping, reviews and other trust signals, tap sizes and accessibility. {SITE_NOTICE}
|
|
69
|
+
|
|
70
|
+
{CHECKS_FIRST}
|
|
71
|
+
|
|
72
|
+
Rules:
|
|
73
|
+
- Each finding cites 1 to 3 element IDs from the page list, copied exactly, such as p2:e12.
|
|
74
|
+
- If you give a quote, copy it word for word from the page list.
|
|
75
|
+
- Use only the category {FindingCategory.COPY_CLARITY}.
|
|
76
|
+
- Report at most {MAX_FINDINGS} findings, the most important first.
|
|
77
|
+
- {UNTRUSTED_PAGE}"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CopyReview(CheckedReview):
|
|
81
|
+
"""What the agent answers: its check of each fault, then a claim for each it found on the page."""
|
|
82
|
+
|
|
83
|
+
findings: list[Claim] = Field(default_factory=list[Claim], max_length=MAX_FINDINGS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def review_request(model: ModelSpec, step: FunnelStep, capture: Capture) -> LlmRequest:
|
|
87
|
+
# The step's URL is the site's own text, so it stays out of the lines code writes.
|
|
88
|
+
user = "\n".join(
|
|
89
|
+
[
|
|
90
|
+
f"Step {step.id}: {step.kind} page, {capture.viewport} view",
|
|
91
|
+
"",
|
|
92
|
+
"The page's elements, one per line: ID, role, name and details.",
|
|
93
|
+
page_block(capture, skip=SKIPPED),
|
|
94
|
+
"",
|
|
95
|
+
"Check this page for these faults:",
|
|
96
|
+
*fault_lines((*FAULTS[step.kind], ANYTHING_ELSE)),
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
return LlmRequest(
|
|
100
|
+
model=model,
|
|
101
|
+
messages=(Message.system(SYSTEM), Message.user(user)),
|
|
102
|
+
max_tokens=1500,
|
|
103
|
+
purpose=SOURCE.name,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def review_copy(
|
|
108
|
+
gateway: Gateway,
|
|
109
|
+
model: ModelSpec,
|
|
110
|
+
graph: FunnelGraph,
|
|
111
|
+
captures: Mapping[str, Capture],
|
|
112
|
+
*,
|
|
113
|
+
ids: Iterator[str],
|
|
114
|
+
viewport: Viewport = Viewport.DESKTOP,
|
|
115
|
+
observer: CallObserver | None = None,
|
|
116
|
+
screenshots: Screenshots | None = None,
|
|
117
|
+
failed: FailedReviews | None = None,
|
|
118
|
+
) -> list[Finding | FindingRejected | StepLeftOut]:
|
|
119
|
+
"""Review the landing and product pages in ``viewport``, and pass each claim through the gate."""
|
|
120
|
+
|
|
121
|
+
def ask(step: FunnelStep, _view: StepView, capture: Capture) -> LlmRequest:
|
|
122
|
+
return review_request(model, step, capture)
|
|
123
|
+
|
|
124
|
+
return await review_steps(
|
|
125
|
+
gateway,
|
|
126
|
+
graph,
|
|
127
|
+
captures,
|
|
128
|
+
reviews=lambda step: step.kind in FAULTS,
|
|
129
|
+
ask=ask,
|
|
130
|
+
answer=CopyReview,
|
|
131
|
+
claims=lambda review: review.findings,
|
|
132
|
+
source=SOURCE,
|
|
133
|
+
allowed=CATEGORIES,
|
|
134
|
+
ids=ids,
|
|
135
|
+
viewport=viewport,
|
|
136
|
+
observer=observer,
|
|
137
|
+
failed=failed,
|
|
138
|
+
)
|