@rudra-js/core 0.8.3 → 0.9.1
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.
- package/README.md +399 -365
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,12 +15,13 @@ You give this package one page view:
|
|
|
15
15
|
- the page they're on
|
|
16
16
|
- the products you're willing to show
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
those products. There's no React in this
|
|
21
|
-
spec into markup.
|
|
18
|
+
You will get back a spec, which is just data to describe the recommendations on
|
|
19
|
+
that page. This can be written by your model or can be built by core without a
|
|
20
|
+
model. Either way, it's checked against those products. There's no React in this
|
|
21
|
+
package: the job of `@rudra-js/react` is to turn the spec into markup.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
You can find the guides and the API reference at
|
|
24
|
+
[rudrajs.com](https://rudrajs.com).
|
|
24
25
|
|
|
25
26
|
## Install
|
|
26
27
|
|
|
@@ -28,75 +29,80 @@ The guides and the API reference are at [rudrajs.com](https://rudrajs.com).
|
|
|
28
29
|
npm install @rudra-js/core@latest @rudra-js/attested@latest
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
upgrade.
|
|
32
|
+
You can run this command in a new app or in an app that already uses rudra-js.
|
|
33
|
+
It's also how to upgrade.
|
|
33
34
|
|
|
34
|
-
If your app also has `@rudra-js/react` or `@rudra-js/anthropic`,
|
|
35
|
-
command with `@latest`.
|
|
36
|
-
|
|
37
|
-
`npm ci`
|
|
35
|
+
If your app also has `@rudra-js/react` or `@rudra-js/anthropic`, you need to add
|
|
36
|
+
them to the command with `@latest`. For each of these packages, the peer range
|
|
37
|
+
on core covers only its own minor release. If you leave one behind, npm will
|
|
38
|
+
just warn you, but the next `npm install` or `npm ci` will fail with `ERESOLVE`.
|
|
38
39
|
|
|
39
|
-
If your app has `@rudra-js/a2ui`, use the install line in
|
|
40
|
-
instead.
|
|
40
|
+
If your app has `@rudra-js/a2ui`, you need to use the install line in the README
|
|
41
|
+
of that package instead. That line also names the two A2UI packages.
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
`package.json` already has
|
|
44
|
-
release it's on.
|
|
43
|
+
Be sure to keep `@latest` on all the names. For a bare name, npm will stay
|
|
44
|
+
inside the range your `package.json` already has. An app on an older core will
|
|
45
|
+
then never leave the minor release it's on.
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
of this package _is_ zod schemas.
|
|
48
|
-
with
|
|
47
|
+
You don't need to install `zod` yourself, because it's a dependency of this
|
|
48
|
+
package. The public API of this package _is_ zod schemas. If your zod 4 fits
|
|
49
|
+
this package's `zod` range, your app will share a single copy of zod with this
|
|
50
|
+
package. If your app's zod is below that range, it will get a second copy.
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
the exported schemas with their own methods
|
|
52
|
-
|
|
52
|
+
Apps on zod 3 will get their own copy next to ours, but the two copies don't
|
|
53
|
+
mix. Build on the exported schemas with their own methods
|
|
54
|
+
(`productSchema.array()`) instead of the methods of your zod
|
|
55
|
+
(`z.array(productSchema)`). Read `error.issues` instead of checking
|
|
53
56
|
`instanceof ZodError`.
|
|
54
57
|
|
|
55
58
|
`@rudra-js/anthropic` uses its own zod to write the tool schema sent to the
|
|
56
|
-
model,
|
|
57
|
-
4.5, a nullable field
|
|
58
|
-
the same thing.
|
|
59
|
-
|
|
60
|
-
`@rudra-js/attested` is a peer dependency
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
npm at the same version
|
|
59
|
+
model. Because of this, the wording of the schema will shift a little between
|
|
60
|
+
zod releases. Before zod 4.5, a nullable field will be written as `anyOf`
|
|
61
|
+
instead of a type array, but both of these mean the same thing.
|
|
62
|
+
|
|
63
|
+
`@rudra-js/attested` is a peer dependency to make sure your app has exactly one
|
|
64
|
+
copy of its phrase list. None of its types appear in the public API of this
|
|
65
|
+
package and you never have to import it. If your app pins its own version, npm
|
|
66
|
+
will stop with an `ERESOLVE` you can see and fix. If you pin nothing, npm will
|
|
67
|
+
install a single copy at the top of the tree. All `@rudra-js` packages are
|
|
68
|
+
published to npm by a single tag at the same version. That's why these ranges
|
|
69
|
+
will always move together.
|
|
66
70
|
|
|
67
71
|
## Running without a model
|
|
68
72
|
|
|
69
|
-
`createComponentGenerator` takes a `provider`.
|
|
70
|
-
|
|
73
|
+
`createComponentGenerator` takes a `provider`. If you leave this out or pass
|
|
74
|
+
`null`, no model will be called and nothing will be billed:
|
|
71
75
|
|
|
72
76
|
```ts
|
|
73
77
|
const generator = createComponentGenerator({ provider: null });
|
|
74
78
|
const spec = await generator.generate(input);
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
This is a supported setting.
|
|
78
|
-
the right setting until you've picked a provider.
|
|
79
|
-
either way,
|
|
81
|
+
This is a supported setting. It's what the benchmark compares the models
|
|
82
|
+
against. It's also the right setting to use until you've picked a provider.
|
|
83
|
+
`generate` returns a promise either way, to let your code keep its shape when
|
|
84
|
+
you add one.
|
|
80
85
|
|
|
81
|
-
Without a model, core
|
|
82
|
-
spec the fallback. It has exactly one **grid** block
|
|
83
|
-
fixed set of four.
|
|
84
|
-
|
|
85
|
-
ruled out for this shopper.
|
|
86
|
+
Without a model, core will build the spec itself. In the rest of this README
|
|
87
|
+
that spec is called the fallback. It has exactly one **grid** block with a
|
|
88
|
+
headline from a fixed set of four. If there's nothing left to show, it will have
|
|
89
|
+
no blocks at all. This will happen if no candidate is in stock, and also if all
|
|
90
|
+
of the candidates are ruled out for this shopper.
|
|
86
91
|
|
|
87
|
-
|
|
88
|
-
a model.
|
|
89
|
-
isn't your catalog.
|
|
92
|
+
All the other block kinds (hero, carousel, banner, copy, bundle) only ever come
|
|
93
|
+
from a model. If you're wiring up bundles and none of them appear, this is why.
|
|
94
|
+
It isn't your catalog.
|
|
90
95
|
|
|
91
|
-
To render a spec you wrote yourself, still without a model, pass
|
|
92
|
-
`createFixedSpecProvider(spec)` as the provider.
|
|
93
|
-
that spec. Your spec
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
To render a spec you wrote yourself, still without a model, you can pass
|
|
97
|
+
`createFixedSpecProvider(spec)` as the provider. This will answer all requests
|
|
98
|
+
with that spec. Your spec will also go through the checks for a model's spec.
|
|
99
|
+
This means it will need a headline that passes the
|
|
100
|
+
[claim check](#what-the-host-must-supply) and also at least one product on show.
|
|
101
|
+
Otherwise you will get the fallback.
|
|
96
102
|
|
|
97
103
|
## Options
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
All the options of `createComponentGenerator` and what you get if you leave them
|
|
100
106
|
out.
|
|
101
107
|
|
|
102
108
|
| Option | What it does | Default |
|
|
@@ -112,8 +118,8 @@ out.
|
|
|
112
118
|
## `tracking-input`
|
|
113
119
|
|
|
114
120
|
This is the boundary between your app and rudra-js. You own your tracking
|
|
115
|
-
pipeline and pass
|
|
116
|
-
stores nothing. It doesn't aggregate anything either.
|
|
121
|
+
pipeline and you just pass in a single JSON object per render. rudra-js collects
|
|
122
|
+
nothing and stores nothing. It doesn't aggregate anything either.
|
|
117
123
|
|
|
118
124
|
```ts
|
|
119
125
|
import { parseTrackingInput } from '@rudra-js/core';
|
|
@@ -137,33 +143,34 @@ const input = parseTrackingInput({
|
|
|
137
143
|
});
|
|
138
144
|
```
|
|
139
145
|
|
|
140
|
-
`parseTrackingInput` throws a `ZodError`. If you'd rather not catch,
|
|
141
|
-
`safeParseTrackingInput`
|
|
142
|
-
read `result.error.issues` without importing zod yourself.
|
|
146
|
+
`parseTrackingInput` throws a `ZodError`. If you'd rather not catch it, you can
|
|
147
|
+
use `safeParseTrackingInput` to get a `TrackingInputResult` instead. You will
|
|
148
|
+
then be able to read `result.error.issues` without importing zod yourself.
|
|
143
149
|
|
|
144
150
|
### Cold start is not an error
|
|
145
151
|
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
Any payload with no `signals` block is a first-time visitor. All of the signal
|
|
153
|
+
lists will default to `[]`. You don't need a special case for it.
|
|
148
154
|
|
|
149
155
|
### What the host must supply
|
|
150
156
|
|
|
151
|
-
|
|
152
|
-
must be unique.
|
|
157
|
+
You need to send `user.id`, `context.surface` and at least one entry in
|
|
158
|
+
`candidates`. All SKUs must be unique.
|
|
153
159
|
|
|
154
|
-
`candidates` sets which products can reach the page. Core looks up
|
|
155
|
-
model writes in that list.
|
|
156
|
-
renders.
|
|
160
|
+
`candidates` sets which products can reach the page. Core looks up all the SKUs
|
|
161
|
+
the model writes in that list. Any SKU that isn't in the list will be dropped
|
|
162
|
+
before anything renders.
|
|
157
163
|
|
|
158
|
-
`bundles` is optional. These are the sets you sell together. Each
|
|
159
|
-
own price for the set and the currency that price
|
|
160
|
-
own name for the set
|
|
161
|
-
as well. Bundle ids
|
|
164
|
+
`bundles` is optional. These are the sets you sell together. Each of these has
|
|
165
|
+
your own price for the set and the currency of that price. It can also have your
|
|
166
|
+
own name for the set in its `label`. All the products in a set have to be
|
|
167
|
+
candidates as well. Bundle ids have to be unique and a set can't name a product
|
|
162
168
|
twice.
|
|
163
169
|
|
|
164
|
-
|
|
165
|
-
the shopper already bought or has in the basket
|
|
166
|
-
that product.
|
|
170
|
+
Because a set is placed whole, one of the checks works differently. A set may
|
|
171
|
+
hold something the shopper has already bought or has in the basket, even though
|
|
172
|
+
a grid or a carousel would drop that product. But the whole set will be blocked
|
|
173
|
+
if any member is:
|
|
167
174
|
|
|
168
175
|
- disliked by the shopper
|
|
169
176
|
- out of stock
|
|
@@ -171,23 +178,24 @@ that product. The whole set is still blocked when any member is:
|
|
|
171
178
|
- the product being looked at right now
|
|
172
179
|
|
|
173
180
|
That last rule has a cost. A "frequently bought together, including this item"
|
|
174
|
-
set
|
|
175
|
-
you wrote it for.
|
|
176
|
-
rejection
|
|
181
|
+
set will name the product of the page it sits on. It's then refused on exactly
|
|
182
|
+
the page you wrote it for. To get around this, you can leave that item out and
|
|
183
|
+
sell its companions as the set. That rejection will go away, but the other
|
|
184
|
+
checks will still apply.
|
|
177
185
|
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
If a bundle block is the only block the model asked for, refusing the set will
|
|
187
|
+
leave nothing to render. Then the fallback will take over.
|
|
180
188
|
|
|
181
|
-
The model _never_ picks a set and is never told a price. All it
|
|
182
|
-
a bundle block and write the words around it. Core picks which set
|
|
183
|
-
is served
|
|
184
|
-
|
|
189
|
+
The model _never_ picks a set and is never told a price. All it can do is ask
|
|
190
|
+
for a bundle block and write the words around it. Core picks which set to use as
|
|
191
|
+
the page is served, based on the shopper's basket, the products they've looked
|
|
192
|
+
at or what they're browsing right now.
|
|
185
193
|
|
|
186
|
-
The prompt tells the model to write about the offer
|
|
187
|
-
also tells the model never to say the set saves money, or by how much.
|
|
188
|
-
`label`,
|
|
194
|
+
The prompt tells the model to write about the offer and not the products in it.
|
|
195
|
+
It also tells the model never to say the set saves money, or by how much. If you
|
|
196
|
+
pass a `label`, this will render ahead of the model's words.
|
|
189
197
|
|
|
190
|
-
Core reads
|
|
198
|
+
Core reads all of the words the model writes, to look for claims:
|
|
191
199
|
|
|
192
200
|
- the headline and the subheadline
|
|
193
201
|
- a hero
|
|
@@ -197,69 +205,74 @@ Core reads every word the model writes for claims:
|
|
|
197
205
|
- the reason under a product
|
|
198
206
|
- the words around the set
|
|
199
207
|
|
|
200
|
-
Text you supplied is never read this way.
|
|
201
|
-
bundle `label
|
|
208
|
+
Text you supplied is never read this way. All of these are your words: a product
|
|
209
|
+
title, a category and a bundle `label`.
|
|
202
210
|
|
|
203
|
-
Core drops text that makes a claim it can't check. This README calls that
|
|
204
|
-
claim check.
|
|
211
|
+
Core drops any text that makes a claim it can't check. This README calls that
|
|
212
|
+
the claim check. There are three passes to it:
|
|
205
213
|
|
|
206
|
-
1.
|
|
207
|
-
what's left.
|
|
208
|
-
2.
|
|
209
|
-
|
|
210
|
-
|
|
214
|
+
1. In the first pass, core looks for money, a customer score, a delivery date
|
|
215
|
+
and a count of what's left.
|
|
216
|
+
2. In the second pass, core asks whether all the numerals in the sentence are
|
|
217
|
+
ones you supplied.
|
|
218
|
+
3. In the third pass, core uses the phrase list of `@rudra-js/attested`. This
|
|
219
|
+
catches claims with no number in them to check, like "top pick" and
|
|
220
|
+
"customer favourite".
|
|
211
221
|
|
|
212
|
-
For example, the second pass
|
|
213
|
-
turns up in a `tag` or a `category` name you sent.
|
|
214
|
-
not the string
|
|
215
|
-
`-5C comfort`.
|
|
222
|
+
For example, the second pass will keep "a comfort rating of -5C" only if a `5`
|
|
223
|
+
turns up in a `tag` or a `category` name you sent. This pass compares the number
|
|
224
|
+
and not the string. A tag reading `5-pocket` will keep it just as surely as a
|
|
225
|
+
tag reading `-5C comfort`.
|
|
216
226
|
|
|
217
|
-
Tags and category names count because
|
|
218
|
-
and lets it repeat. The model is also shown
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
Tags and category names count because these are the strings core hands to the
|
|
228
|
+
model and lets it repeat. The model is also shown the `title` and `rating` of
|
|
229
|
+
each product, but neither of these can back a number, because the prompt tells
|
|
230
|
+
the model never to restate either. If you put your product's figures in `tags`,
|
|
231
|
+
these can be quoted by the model. If you leave these out, any figure the model
|
|
232
|
+
writes about them is made up and will be dropped.
|
|
222
233
|
|
|
223
234
|
Which strings count, exactly:
|
|
224
235
|
|
|
225
|
-
- Only candidates the model was shown.
|
|
226
|
-
the 60 core sends
|
|
227
|
-
- Not the `currentCategory` on the request.
|
|
228
|
-
a row of your catalog
|
|
229
|
-
it.
|
|
230
|
-
- A `reason` or a `badge`
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
`40 litre` tag
|
|
236
|
-
nothing in
|
|
237
|
-
|
|
238
|
-
from you
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
drops
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
236
|
+
- Only candidates the model was shown. Any product that's out of stock or past
|
|
237
|
+
the 60 core sends is a product the model never saw. Its tags back nothing.
|
|
238
|
+
- Not the `currentCategory` on the request. That's a string from this request
|
|
239
|
+
and not a row of your catalog. There are also plenty of sites that pass a URL
|
|
240
|
+
segment straight into it.
|
|
241
|
+
- A `reason` or a `badge` will sit under a named product. It's read against the
|
|
242
|
+
tags and category of that product. All other fields are read against the tags
|
|
243
|
+
and categories of all the candidates, pooled.
|
|
244
|
+
|
|
245
|
+
There are two things to know about the second pass. Pooled means pooled: a
|
|
246
|
+
`40 litre` tag on a product will back "take 40 off" in a headline about another
|
|
247
|
+
product. And nothing in this pass can know which quantity a tag was about.
|
|
248
|
+
That's why a tag holding a weight will back a price that has those digits in it.
|
|
249
|
+
This pass will prove the digits came from you, but it won't prove the sentence
|
|
250
|
+
is true.
|
|
251
|
+
|
|
252
|
+
In the first and third passes, core uses word lists. Word lists can miss a
|
|
253
|
+
claim. They aren't a guarantee in the way that checking a price against your
|
|
254
|
+
catalog is. For the second pass, the proof is narrower than it sounds: all of
|
|
255
|
+
the digits were yours. To this pass, a number written as a word isn't a number.
|
|
256
|
+
It can't see "four and a half stars" as a rating. It also can't read some
|
|
257
|
+
numerals, like a ½ or the K in "10K". Those will be dropped, not let through.
|
|
258
|
+
|
|
259
|
+
None of this is told to the model. In the prompt there's a ban on prices,
|
|
260
|
+
ratings, discounts, delivery dates and stock levels, but nothing about digits.
|
|
261
|
+
The model has no way to tell `3-season` (a tag you sent and fine to use) from
|
|
262
|
+
`2 litres` (which isn't). However much good text the check drops on your
|
|
263
|
+
catalog, nothing in the prompt can bring that down.
|
|
264
|
+
|
|
265
|
+
Some fields can't be null, like a headline or the text of a banner. If the check
|
|
266
|
+
drops text from one of these, it will empty the field instead. Banner and copy
|
|
267
|
+
blocks will then be dropped. For a hero, this will only happen if it has no
|
|
268
|
+
product either. An empty page headline makes the whole generation unusable.
|
|
269
|
+
|
|
270
|
+
That's the costly case. For example, the model might write
|
|
271
|
+
"Our 3 favourites for wet weather" when no candidate carries a 3. This headline
|
|
272
|
+
will be emptied. That makes the generation unusable and the fallback will
|
|
273
|
+
render. You will have paid for a model call just to throw it away. In cohort
|
|
274
|
+
mode the spec is cached before it's checked. This means all cache hits will
|
|
275
|
+
repeat the check and get the same fallback until the entry expires.
|
|
263
276
|
|
|
264
277
|
### Defaults
|
|
265
278
|
|
|
@@ -278,14 +291,14 @@ the same check and gets the same fallback until the entry expires.
|
|
|
278
291
|
| `mostViewed[].views` | `1` |
|
|
279
292
|
| `lastPurchased[].quantity` | `1` |
|
|
280
293
|
|
|
281
|
-
`context.locale` has to be
|
|
282
|
-
|
|
294
|
+
`context.locale` has to be a single language tag, such as `en-US`. It can't be a
|
|
295
|
+
list or an `Accept-Language` header.
|
|
283
296
|
|
|
284
297
|
### Cohorts
|
|
285
298
|
|
|
286
|
-
By default, shoppers who look alike share
|
|
287
|
-
products are filled in per request. A group of look-alike
|
|
288
|
-
|
|
299
|
+
By default, shoppers who look alike will share a single generated spec and the
|
|
300
|
+
products for each shopper are filled in per request. A group of look-alike
|
|
301
|
+
shoppers is called a cohort. It's set by:
|
|
289
302
|
|
|
290
303
|
- the shopper's segment
|
|
291
304
|
- the surface and the slot
|
|
@@ -295,53 +308,57 @@ A cohort is set by:
|
|
|
295
308
|
- the category being browsed
|
|
296
309
|
- the category they lean towards
|
|
297
310
|
|
|
298
|
-
Only two of these come from the shopper's
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
products and writes about them. In most shops candidates come from the page
|
|
305
|
-
|
|
306
|
-
get smaller cohorts, because each prompt really is personal.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
order
|
|
310
|
-
fell.
|
|
311
|
-
|
|
312
|
-
your
|
|
313
|
-
you put at the top.
|
|
314
|
-
|
|
315
|
-
Prices, titles and stock
|
|
316
|
-
|
|
317
|
-
|
|
311
|
+
Only two of these come from the shopper's data: whether they have any history
|
|
312
|
+
and the category they lean towards. Who they are, their searches and the
|
|
313
|
+
products they liked or viewed are all left out. This lets many page views reuse
|
|
314
|
+
a single model call.
|
|
315
|
+
|
|
316
|
+
Candidate lists are also part of the cohort, because the model is shown those
|
|
317
|
+
products and writes about them. In most shops the candidates come from the page
|
|
318
|
+
and will be shared by all the people looking at it. If you pick candidates per
|
|
319
|
+
shopper, you'll get smaller cohorts, because each prompt really is personal.
|
|
320
|
+
|
|
321
|
+
For a cohort, the cache key holds the _set_ of candidate SKUs. This key will
|
|
322
|
+
ignore the order they were sent in, which of them were in stock and where the
|
|
323
|
+
60-product cut fell. Two requests with the same SKUs in a different order are
|
|
324
|
+
going to share a spec. Both of them will get the first spec to be generated. If
|
|
325
|
+
your ranking is personal and you order your candidates per shopper, the products
|
|
326
|
+
the copy talks about won't be the ones you put at the top.
|
|
327
|
+
|
|
328
|
+
Prices, titles and stock will always be read from your catalog at render time.
|
|
329
|
+
This is to make sure a stale value never shows in those fields. If a product
|
|
330
|
+
sold out, it's dropped as the page is served, but any sentence the model wrote
|
|
331
|
+
about it will stay. You can also end up with copy written from an old title,
|
|
332
|
+
rating or tag, because the cache key holds SKUs and none of those fields.
|
|
318
333
|
|
|
319
334
|
#### Who can create a cohort
|
|
320
335
|
|
|
321
|
-
|
|
322
|
-
cohort of its own.
|
|
323
|
-
visitor can choose
|
|
324
|
-
pay for. These are the three that go wrong:
|
|
336
|
+
You supply all of the cohort fields above and each distinct value will make a
|
|
337
|
+
cohort of its own. This will cost you a fresh model call and also a slot in the
|
|
338
|
+
cache. If a visitor can choose any of these values, they can choose how many
|
|
339
|
+
calls you pay for. These are the three that go wrong:
|
|
325
340
|
|
|
326
341
|
- a `locale` copied straight out of `Accept-Language`
|
|
327
342
|
- a `currentCategory` read off a URL slug
|
|
328
343
|
- a `segment` built from a query parameter
|
|
329
344
|
|
|
330
|
-
|
|
331
|
-
out the cohorts your real shoppers were being served from.
|
|
345
|
+
There's room for 10,000 entries in the built-in cache. Once it's full, made-up
|
|
346
|
+
values will push out the cohorts your real shoppers were being served from.
|
|
332
347
|
|
|
333
|
-
|
|
348
|
+
You need to draw those fields from sets you control:
|
|
334
349
|
|
|
335
350
|
- your own locale list
|
|
336
351
|
- your own category ids
|
|
337
352
|
- your own segment labels
|
|
338
353
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
354
|
+
A cohort prompt leaves out the shopper's searches and signal lists, but those
|
|
355
|
+
three fields go in as written. If a visitor can set one, what they send reaches
|
|
356
|
+
your provider. The other cost will be the bill, plus a model round trip on a
|
|
357
|
+
page that was being served from cache.
|
|
342
358
|
|
|
343
|
-
|
|
344
|
-
then
|
|
359
|
+
To generate for each shopper instead, you can pass `generation: 'per-shopper'`.
|
|
360
|
+
The model will then pick the products too and each shopper will cost a call of
|
|
361
|
+
their own.
|
|
345
362
|
|
|
346
363
|
```ts
|
|
347
364
|
createComponentGenerator({ provider, generation: 'per-shopper' });
|
|
@@ -349,9 +366,9 @@ createComponentGenerator({ provider, generation: 'per-shopper' });
|
|
|
349
366
|
|
|
350
367
|
#### What you put in `segment`
|
|
351
368
|
|
|
352
|
-
`segment` goes to the model exactly as you wrote it
|
|
353
|
-
takes any string up to 128 characters. Stick to plain merchandising labels
|
|
354
|
-
`lapsed`, `high-value`, `trial` or `wholesale`. Keep out anything that says
|
|
369
|
+
In both modes, `segment` goes to the model exactly as you wrote it. The schema
|
|
370
|
+
takes any string of up to 128 characters. Stick to plain merchandising labels
|
|
371
|
+
like `lapsed`, `high-value`, `trial` or `wholesale`. Keep out anything that says
|
|
355
372
|
something protected about a person:
|
|
356
373
|
|
|
357
374
|
- health
|
|
@@ -363,14 +380,15 @@ something protected about a person:
|
|
|
363
380
|
- union membership
|
|
364
381
|
- biometric or genetic data
|
|
365
382
|
|
|
366
|
-
|
|
367
|
-
in per-shopper mode.
|
|
368
|
-
|
|
383
|
+
This also applies to `recentSearches`, `context.searchQuery` and
|
|
384
|
+
`interaction.type` in per-shopper mode. These three are shopper text and will be
|
|
385
|
+
sent as written. Anything a shopper types belongs to them, but the label you
|
|
386
|
+
give them is your choice.
|
|
369
387
|
|
|
370
388
|
### Limits
|
|
371
389
|
|
|
372
|
-
|
|
373
|
-
inside a model prompt and you pay for a model per token.
|
|
390
|
+
All free-text fields and all arrays have a cap, because your strings end up
|
|
391
|
+
inside a model prompt and you pay for a model per token. These caps are in
|
|
374
392
|
`FIELD_LIMITS`, which is exported. Validate against the same numbers instead of
|
|
375
393
|
finding them out from a rejection.
|
|
376
394
|
|
|
@@ -390,28 +408,32 @@ finding them out from a rejection.
|
|
|
390
408
|
| `maxItems` | 12 | `context.maxItems`, which also needs at least 1 |
|
|
391
409
|
| `reason` | 120 | `candidates[].reason`, your own phrase for a product |
|
|
392
410
|
|
|
393
|
-
Each cap bounds
|
|
394
|
-
whole prompt.
|
|
395
|
-
|
|
396
|
-
|
|
411
|
+
Each cap bounds a single field on its own. Together they aren't a budget for the
|
|
412
|
+
whole prompt. Core doesn't count tokens either. The digest, the short summary of
|
|
413
|
+
the shopper and the page, trims signals instead of throwing. The prompt will
|
|
414
|
+
also be limited to 60 in-stock candidates. With long titles and tag lists, those
|
|
415
|
+
60 products can still be too much for a small model.
|
|
397
416
|
|
|
398
417
|
### Unknown fields are rejected
|
|
399
418
|
|
|
400
|
-
|
|
401
|
-
get an error
|
|
402
|
-
`interaction.meta` is the
|
|
403
|
-
would change a prototype instead of the object.
|
|
419
|
+
Any fixed-shape object is a `strictObject`. If you misspell `recentSearches`,
|
|
420
|
+
you'll get an error instead of a shopper who looks like a first-time visitor
|
|
421
|
+
with no sign of why. `interaction.meta` is the only open shape. It takes any
|
|
422
|
+
key, except the keys that would change a prototype instead of the object.
|
|
404
423
|
|
|
405
424
|
## Bringing your own ranking
|
|
406
425
|
|
|
407
|
-
By default core orders the products for you
|
|
408
|
-
the shopper's signals. You might already have
|
|
426
|
+
By default core orders the products for you in cohort mode and in the fallback,
|
|
427
|
+
by scoring each candidate against the shopper's signals. You might already have
|
|
428
|
+
a recommender you trust:
|
|
409
429
|
|
|
410
430
|
- bought-together data
|
|
411
|
-
- an engine trained on your
|
|
431
|
+
- an engine trained on your orders
|
|
412
432
|
- a merchandiser's hand-picked row
|
|
413
433
|
|
|
414
|
-
|
|
434
|
+
If you pass `rank: 'given'`, the order you sent will be kept for grids and
|
|
435
|
+
carousels in cohort mode and in the fallback. The hero's product will still be
|
|
436
|
+
picked by the model. In per-shopper mode the model will pick the order too.
|
|
415
437
|
|
|
416
438
|
```ts
|
|
417
439
|
const generator = createComponentGenerator({ provider, rank: 'given' });
|
|
@@ -424,30 +446,30 @@ shouldn't be shown:
|
|
|
424
446
|
- already bought
|
|
425
447
|
- in the basket
|
|
426
448
|
- disliked
|
|
427
|
-
-
|
|
449
|
+
- being looked at right now
|
|
428
450
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
shown
|
|
433
|
-
model
|
|
451
|
+
These checks use the payload you sent and not a trimmed copy of it. This means
|
|
452
|
+
a shopper with a long order history is covered too. Each product will still have
|
|
453
|
+
a basis: this is a code like `most_viewed` to say why
|
|
454
|
+
it's shown. Core checks this basis against the shopper's real signals. All of
|
|
455
|
+
the model's text will still be read by the claim check.
|
|
434
456
|
|
|
435
|
-
There are six bases,
|
|
457
|
+
There are six bases, but two of them prove less than their names suggest:
|
|
436
458
|
|
|
437
459
|
- `most_viewed` is checked against the SKUs they viewed.
|
|
438
460
|
- `similar_to_current` is checked against the category being browsed.
|
|
439
|
-
- `liked_category` is checked against the categories they bought,
|
|
440
|
-
or viewed
|
|
461
|
+
- `liked_category` is checked against the categories of products they've bought,
|
|
462
|
+
liked, carted or viewed. Just standing on a category page doesn't count.
|
|
441
463
|
- `popular` claims nothing.
|
|
442
464
|
- `complements_cart` checks only that the basket isn't empty.
|
|
443
465
|
- `complements_purchase` checks only that the order history isn't empty.
|
|
444
466
|
|
|
445
|
-
|
|
446
|
-
that
|
|
447
|
-
the badge stating it
|
|
467
|
+
Of these, the two `complements_` bases are the weak ones, because nothing here
|
|
468
|
+
can know that a product goes with another. If a basis fails, the sentence and
|
|
469
|
+
the badge stating it are dropped too.
|
|
448
470
|
|
|
449
|
-
|
|
450
|
-
|
|
471
|
+
Candidates can also carry a `reason` (the phrase shown under the product). You
|
|
472
|
+
can use this if your ranking knows something the signals don't:
|
|
451
473
|
|
|
452
474
|
```ts
|
|
453
475
|
candidates: [
|
|
@@ -461,69 +483,73 @@ candidates: [
|
|
|
461
483
|
];
|
|
462
484
|
```
|
|
463
485
|
|
|
464
|
-
|
|
465
|
-
and skips the claim check.
|
|
466
|
-
|
|
467
|
-
the reasons itself.
|
|
468
|
-
|
|
486
|
+
Any reason you supply is in your own words, like the title. It renders as
|
|
487
|
+
written and skips the claim check. But that only applies where this request
|
|
488
|
+
actually used it (the default `cohort` mode). In `per-shopper` mode the model
|
|
489
|
+
writes the reasons itself. All of these will be read by the claim check, even if
|
|
490
|
+
a reason reads the same as yours.
|
|
469
491
|
|
|
470
|
-
|
|
471
|
-
signals, like "More in Backpacks" or "Goes with what is in your cart".
|
|
472
|
-
core's words
|
|
473
|
-
|
|
492
|
+
You don't have to supply a reason: core will state the basis for you from the
|
|
493
|
+
shopper's signals, like "More in Backpacks" or "Goes with what is in your cart".
|
|
494
|
+
These are core's words and not the model's. In `cohort` mode they also skip the
|
|
495
|
+
claim check.
|
|
474
496
|
|
|
475
497
|
## What the model sees
|
|
476
498
|
|
|
477
|
-
|
|
499
|
+
There are two generation modes and they send different things. Cohort is the
|
|
500
|
+
default.
|
|
478
501
|
|
|
479
502
|
### Cohort mode
|
|
480
503
|
|
|
481
504
|
- the surface and the slot
|
|
482
505
|
- the locale
|
|
483
|
-
- the segment,
|
|
506
|
+
- the segment, if set
|
|
484
507
|
- the category being browsed (`context.currentCategory`)
|
|
485
|
-
- the name of the category the shopper leans towards most,
|
|
486
|
-
- whether this shopper has no history at all
|
|
487
|
-
|
|
488
|
-
- how many products the spec
|
|
489
|
-
- the candidate list:
|
|
490
|
-
|
|
508
|
+
- the name of the category the shopper leans towards most, but not its score
|
|
509
|
+
- whether this shopper has no history at all (no likes, dislikes, purchases,
|
|
510
|
+
basket items or views). Searches and other interactions don't count
|
|
511
|
+
- how many products the spec can place (`context.maxItems`)
|
|
512
|
+
- the candidate list: a line per product with its SKU, title, category, rating
|
|
513
|
+
and tags
|
|
491
514
|
|
|
492
515
|
### Per-shopper mode
|
|
493
516
|
|
|
494
|
-
|
|
517
|
+
All of the above and also:
|
|
495
518
|
|
|
496
519
|
- the SKU being looked at right now
|
|
497
520
|
- the current search
|
|
498
521
|
- whether this is a returning shopper
|
|
499
522
|
- liked SKUs, and disliked SKUs
|
|
500
|
-
- purchased SKUs
|
|
501
|
-
-
|
|
523
|
+
- purchased SKUs and what is in the basket
|
|
524
|
+
- most-viewed SKUs, each with its view count
|
|
502
525
|
- recent searches
|
|
503
526
|
- up to six categories they lean towards, strongest first
|
|
504
|
-
-
|
|
527
|
+
- other kinds of interaction, each with a count
|
|
505
528
|
|
|
506
529
|
### Left out of both
|
|
507
530
|
|
|
508
531
|
- `user.id`
|
|
509
|
-
-
|
|
532
|
+
- all timestamps (`at`). Core uses these to sort signals and then they're
|
|
533
|
+
dropped
|
|
510
534
|
- dwell time (`dwellMs`). It's added up in the digest and left out of the prompt
|
|
511
535
|
- every price, and every currency
|
|
512
536
|
- `imageUrl`
|
|
513
537
|
- `interaction.value` and `interaction.meta`. The model is told which kinds of
|
|
514
|
-
interaction happened and how often,
|
|
538
|
+
interaction happened and how often, but no more
|
|
515
539
|
|
|
516
|
-
The candidate list
|
|
517
|
-
dropped
|
|
540
|
+
The candidate list will be trimmed before it's sent. Out-of-stock products will
|
|
541
|
+
be dropped and no more than 60 products will go, in the order you supplied.
|
|
518
542
|
|
|
519
|
-
`spec-cache.test.ts` walks
|
|
520
|
-
either in the cache key or removed from the cohort prompt.
|
|
521
|
-
|
|
522
|
-
|
|
543
|
+
`spec-cache.test.ts` walks all the fields of the digest, to check that each
|
|
544
|
+
field is either in the cache key or removed from the cohort prompt. If a field
|
|
545
|
+
is left out of the key but still changes the prompt, that test will fail. It
|
|
546
|
+
will also fail for a new field added to the digest, until someone says which
|
|
547
|
+
side the field is on.
|
|
523
548
|
|
|
524
549
|
## What the model decides, by mode
|
|
525
550
|
|
|
526
|
-
|
|
551
|
+
Here you can see what the model wrote and what will be replaced by core before
|
|
552
|
+
the page is served.
|
|
527
553
|
|
|
528
554
|
| Decision | Cohort, the default | Per-shopper |
|
|
529
555
|
| ---------------------------------- | --------------------------------------- | -------------------------------------- |
|
|
@@ -535,21 +561,21 @@ What the model wrote, and what core replaces before the page is served.
|
|
|
535
561
|
| The reason and basis per product | Filled in per request, not by the model | The model, checked against the signals |
|
|
536
562
|
| Which bundle, of the ones you pass | Chosen per request, not by the model | Chosen per request, not by the model |
|
|
537
563
|
|
|
538
|
-
In cohort mode the grid and carousel items are filled in per request
|
|
539
|
-
first,
|
|
564
|
+
In cohort mode the grid and carousel items are filled in per request (best pick
|
|
565
|
+
first). That way, a spec written for one shopper will also fit the next.
|
|
540
566
|
|
|
541
|
-
|
|
542
|
-
headline and body were written about that product.
|
|
543
|
-
placed, core
|
|
567
|
+
But the hero is the exception. It keeps the product the model named, because its
|
|
568
|
+
headline and body were written about that product. If this product can't be
|
|
569
|
+
placed, core will drop the link and keep the words. It can't be placed if:
|
|
544
570
|
|
|
545
571
|
- this shopper can't see it: it's out of stock, not a candidate, disliked,
|
|
546
|
-
already bought, in the basket
|
|
572
|
+
already bought, in the basket or being looked at
|
|
547
573
|
- an earlier block already placed it
|
|
548
|
-
- the item budget
|
|
574
|
+
- the item budget had run out by the time the hero was reached
|
|
549
575
|
|
|
550
576
|
## Any provider
|
|
551
577
|
|
|
552
|
-
|
|
578
|
+
There are three things to a provider:
|
|
553
579
|
|
|
554
580
|
```ts
|
|
555
581
|
export interface ComponentProvider {
|
|
@@ -561,36 +587,36 @@ export interface ComponentProvider {
|
|
|
561
587
|
}
|
|
562
588
|
```
|
|
563
589
|
|
|
564
|
-
`createComponentGenerator({ provider })` takes any object of that shape.
|
|
565
|
-
|
|
566
|
-
tenancy
|
|
567
|
-
`@rudra-js/anthropic` is one adapter
|
|
590
|
+
`createComponentGenerator({ provider })` takes any object of that shape. This
|
|
591
|
+
might be a hosted API, a model you run yourself, a deployment inside your
|
|
592
|
+
tenancy or a recorded fixture. `@rudra-js/core` depends on no vendor SDK.
|
|
593
|
+
`@rudra-js/anthropic` is just one adapter and you don't have to use it.
|
|
568
594
|
|
|
569
|
-
An adapter takes its API key as an option
|
|
570
|
-
from. `ANTHROPIC_API_KEY` is just the name the example shop uses. No
|
|
571
|
-
here reads the environment.
|
|
595
|
+
An adapter takes its API key as an option. You can then choose where the key
|
|
596
|
+
comes from. `ANTHROPIC_API_KEY` is just the name the example shop uses. No
|
|
597
|
+
package here reads the environment.
|
|
572
598
|
|
|
573
599
|
## The cache
|
|
574
600
|
|
|
575
601
|
By default `cache` is an in-process store. `createMemorySpecCache()` keeps an
|
|
576
|
-
entry for `ttlMs
|
|
577
|
-
|
|
578
|
-
read longest ago
|
|
602
|
+
entry for `ttlMs`. This defaults to 60,000 milliseconds (one minute). This store
|
|
603
|
+
will hold up to `maxEntries`, which defaults to 10,000. Once it's full, the
|
|
604
|
+
entry read longest ago will be the first to go.
|
|
579
605
|
|
|
580
606
|
An entry holds the generated spec, as the model returned it before any checks.
|
|
581
|
-
It also holds `generatedAt
|
|
582
|
-
It
|
|
607
|
+
It also holds `generatedAt` (the epoch milliseconds of when the model produced
|
|
608
|
+
the spec). It doesn't hold a payload or a prompt.
|
|
583
609
|
|
|
584
|
-
In cohort mode an entry holds no shopper id
|
|
585
|
-
|
|
586
|
-
shopper. In per-shopper mode the model wrote the entry from
|
|
587
|
-
|
|
588
|
-
|
|
610
|
+
In cohort mode an entry holds no shopper id and it's shared by all the shoppers
|
|
611
|
+
in the cohort. But there's an exception if the segment or candidates were picked
|
|
612
|
+
for one shopper. In per-shopper mode the model wrote the entry from the signals
|
|
613
|
+
of that shopper. Its text can then repeat them (a search term, for instance)
|
|
614
|
+
and the entry will be personal data until it's gone.
|
|
589
615
|
|
|
590
|
-
Expiry is checked when an entry is read
|
|
591
|
-
until it's read again or pushed out by a newer
|
|
616
|
+
Expiry is checked when an entry is read. An expired entry will stay in memory
|
|
617
|
+
until it's read again or pushed out by a newer entry.
|
|
592
618
|
|
|
593
|
-
The cache interface is two methods
|
|
619
|
+
The cache interface is two methods and an optional third:
|
|
594
620
|
|
|
595
621
|
```ts
|
|
596
622
|
export interface SpecCache {
|
|
@@ -600,111 +626,118 @@ export interface SpecCache {
|
|
|
600
626
|
}
|
|
601
627
|
```
|
|
602
628
|
|
|
603
|
-
|
|
604
|
-
run
|
|
605
|
-
|
|
606
|
-
|
|
629
|
+
You can also pass a store of your own (Redis, Memcached or whatever you already
|
|
630
|
+
run) and it will keep entries on its own terms. It's up to you to declare to
|
|
631
|
+
your users what that store holds and for how long. That's not set by this
|
|
632
|
+
package. To store nothing at all, you can pass `createNullSpecCache()`.
|
|
607
633
|
|
|
608
634
|
### When a generation is wrong
|
|
609
635
|
|
|
610
|
-
-
|
|
611
|
-
fallback.
|
|
612
|
-
-
|
|
613
|
-
- If your store has `delete`, drop
|
|
614
|
-
`GenerationEvent`. The next request
|
|
636
|
+
- If you pass `provider: null`, nothing new will be generated and all pages will
|
|
637
|
+
render the fallback.
|
|
638
|
+
- If you shorten `ttlMs`, a bad entry will end sooner.
|
|
639
|
+
- If your store has `delete`, you can drop an entry by the `key` on its
|
|
640
|
+
`GenerationEvent`. The next request will then generate again.
|
|
615
641
|
|
|
616
|
-
|
|
617
|
-
their keys.
|
|
642
|
+
Core can't drop all the entries of a shopper for you, because nothing maps a
|
|
643
|
+
shopper to their keys. Per-shopper entries will end when they expire or when
|
|
644
|
+
newer entries push them out. You can also delete an entry by a `key` you kept.
|
|
618
645
|
|
|
619
646
|
## Watching it in production
|
|
620
647
|
|
|
621
|
-
The generator never fails a render
|
|
622
|
-
only
|
|
623
|
-
`generate` that gets past input validation reports exactly one
|
|
624
|
-
`GenerationEvent`.
|
|
625
|
-
|
|
648
|
+
The generator never fails a render. If a provider has been down for a week, this
|
|
649
|
+
will only show up as plainer pages. To find out, you need to watch `onEvent`.
|
|
650
|
+
Each call to `generate` that gets past input validation reports exactly one
|
|
651
|
+
`GenerationEvent`. If a payload fails `parseTrackingInput`, it will throw
|
|
652
|
+
instead and report nothing.
|
|
626
653
|
|
|
627
|
-
|
|
654
|
+
These are the numbers to keep:
|
|
628
655
|
|
|
629
|
-
- **Fallback share.**
|
|
630
|
-
it climbs. `degradedReason`
|
|
631
|
-
reason is `'provider-error'` or `'timeout'`, `error`
|
|
656
|
+
- **Fallback share.** This is the share of events with `source: 'fallback'`. Be
|
|
657
|
+
sure to alert if it climbs. `degradedReason` will tell you which way the call
|
|
658
|
+
failed. If the reason is `'provider-error'` or `'timeout'`, `error` has the
|
|
659
|
+
value that was thrown.
|
|
632
660
|
- **Cache hit rate.** `cache: 'hit'` over the events that have a `cache` field.
|
|
633
|
-
A store that's down
|
|
634
|
-
`cache: 'timeout'`.
|
|
635
|
-
bill.
|
|
636
|
-
- **Spend.**
|
|
637
|
-
that joined a generation already in flight carry the
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
661
|
+
A store that's down will show as `cache: 'error'` and a slow store as
|
|
662
|
+
`cache: 'timeout'`. A broken store will then show up here and not just as a
|
|
663
|
+
rising bill.
|
|
664
|
+
- **Spend.** This is the sum of `usage` over the events where `calledModel` is
|
|
665
|
+
true. Any requests that joined a generation already in flight will carry the
|
|
666
|
+
`usage` of that generation too. If you sum over all the events, this will
|
|
667
|
+
count a call many times. Some of the calls the model answered are turned down
|
|
668
|
+
by the adapter: a `max_tokens` stop, a refusal, no tool call or a reply that
|
|
669
|
+
doesn't fit the schema. These were billed too, but they arrive as a
|
|
670
|
+
`'provider-error'` with no `usage`. That's why your bill will be more than
|
|
671
|
+
this sum.
|
|
642
672
|
|
|
643
673
|
## Running it in a regulated shop
|
|
644
674
|
|
|
645
|
-
This isn't legal advice
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
**What leaves your server.** Nothing, unless you pass a `provider
|
|
650
|
-
|
|
651
|
-
and locale, the category, the segment you set, the shopper's strongest
|
|
652
|
-
whether they have any history
|
|
653
|
-
id, but it can still be personal data.
|
|
654
|
-
flag
|
|
655
|
-
|
|
656
|
-
|
|
675
|
+
This isn't legal advice. Which rules apply will depend on where you sell. To
|
|
676
|
+
help you answer the questions in a privacy or consumer-law review, this section
|
|
677
|
+
describes what the package does.
|
|
678
|
+
|
|
679
|
+
**What leaves your server.** Nothing, unless you pass a `provider` or a `cache`
|
|
680
|
+
that has its store somewhere else. With a provider, cohort mode will send the
|
|
681
|
+
page and locale, the category, the segment you set, the shopper's strongest
|
|
682
|
+
category, whether they have any history and the candidate products. That has no
|
|
683
|
+
shopper id, but it can still be personal data. Both the strongest category and
|
|
684
|
+
the history flag are taken from the shopper's data. Any segment will go to the
|
|
685
|
+
provider exactly as you wrote it. If you pick or order the candidate list per
|
|
686
|
+
shopper, this list is also about the shopper. Per-shopper mode will also send
|
|
687
|
+
the shopper's signals. You can see these lists under
|
|
657
688
|
[What the model sees](#what-the-model-sees). `user.id` is never sent in either
|
|
658
|
-
mode. The provider should be your processor, under a contract that says
|
|
659
|
-
Anthropic, see the `@rudra-js/anthropic` README.
|
|
689
|
+
mode. The provider should be your processor, under a contract that says this.
|
|
690
|
+
For Anthropic, see the `@rudra-js/anthropic` README.
|
|
660
691
|
|
|
661
692
|
**A shopper who objects to profiling.** Leave `signals` and `user.segment` out
|
|
662
|
-
of their payload. In per-shopper mode, leave out
|
|
663
|
-
`
|
|
664
|
-
|
|
665
|
-
`generateDeterministic(input)`
|
|
666
|
-
model or the cache.
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
cohort
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
shopper
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
693
|
+
of their payload. In per-shopper mode, you also need to leave out
|
|
694
|
+
`context.searchQuery` and `user.isReturning`. They will be treated like a
|
|
695
|
+
first-time visitor, with a spec built just from the page and the catalog. With
|
|
696
|
+
those left out, `generateDeterministic(input)` can be used to get the same kind
|
|
697
|
+
of spec without calling the model or the cache. But if you call it with signals,
|
|
698
|
+
it will personalise.
|
|
699
|
+
|
|
700
|
+
**Deleting a shopper's data.** This package keeps nothing about a shopper except
|
|
701
|
+
the cache. In cohort mode an entry holds no shopper id and it's shared by all
|
|
702
|
+
the shoppers in the cohort. There's usually no single shopper's entry to delete.
|
|
703
|
+
But there's an exception if a cohort entry has a segment or candidate list
|
|
704
|
+
picked for one shopper. Like all per-shopper entries, it's about that shopper.
|
|
705
|
+
Nothing maps a shopper to their entries. That's why you need to keep `ttlMs`
|
|
706
|
+
short or pass `createNullSpecCache()`. `onEvent` sends no shopper id. But in
|
|
707
|
+
per-shopper mode its `key` is derived from a shopper id. `violations` can also
|
|
708
|
+
name SKUs from a shopper's basket or purchases. Treat logs of `onEvent` as
|
|
709
|
+
personal data.
|
|
710
|
+
|
|
711
|
+
**Caching pages.** In its HTML, a personalised page will say things like
|
|
712
|
+
"Goes with your cart" and "You looked at this recently". Serve those pages with
|
|
713
|
+
`Cache-Control: private` or `no-store`, to be sure a shared cache never hands
|
|
714
|
+
one shopper's page to another.
|
|
715
|
+
|
|
716
|
+
**Telling shoppers a model wrote the copy.** In the wrapper, `data-rudra-source`
|
|
717
|
+
says `llm` or `cache` if a model wrote the copy but `fallback` if it didn't.
|
|
718
|
+
This makes it a machine-readable marker you can build on, for example to show a
|
|
719
|
+
note. Whether you must label AI-written copy and how you do it will depend on
|
|
687
720
|
where you sell. In the EU, Article 50 of the AI Act is the rule to ask about.
|
|
688
721
|
|
|
689
|
-
**Claims.**
|
|
722
|
+
**Claims.** All the copy on your page is your advertising, whoever wrote it. The
|
|
690
723
|
claim check catches prices, discounts, stock, ratings, delivery, urgency and
|
|
691
|
-
generic environmental claims. But it's a list
|
|
692
|
-
|
|
693
|
-
is still yours to stand behind.
|
|
724
|
+
generic environmental claims. But it's just a list and rewordings get through.
|
|
725
|
+
You can read why in SECURITY.md and see the known ones in the tests. Any claim
|
|
726
|
+
it misses is still yours to stand behind.
|
|
694
727
|
|
|
695
728
|
**Children.** In the UK, the ICO's Children's Code covers any shop that
|
|
696
|
-
under-18s are more likely than not to use, not only
|
|
697
|
-
standard 12 asks for profiling to be off by default unless you can show a
|
|
729
|
+
under-18s are more likely than not to use, not only a shop aimed at under-18s.
|
|
730
|
+
Its standard 12 asks for profiling to be off by default unless you can show a
|
|
698
731
|
compelling reason, judged by the child's best interests. It also asks for
|
|
699
732
|
safeguards when profiling is on. For a shop like that, don't use per-shopper
|
|
700
|
-
mode. For a shopper under 18, leave `signals` and `user.segment` out
|
|
701
|
-
payload unless they've switched personalisation on, as for a shopper
|
|
702
|
-
to profiling. If you can't tell a shopper's age with confidence that
|
|
703
|
-
risk, treat
|
|
704
|
-
off.
|
|
705
|
-
signals in cohort mode and in the fallback, even with no provider.
|
|
706
|
-
mode still
|
|
707
|
-
any segment to the model.
|
|
733
|
+
mode. For a shopper under 18, be sure to leave `signals` and `user.segment` out
|
|
734
|
+
of their payload unless they've switched personalisation on, as for a shopper
|
|
735
|
+
who objects to profiling. If you can't tell a shopper's age with confidence that
|
|
736
|
+
fits the risk, treat all shoppers as under 18. Just using cohort mode doesn't
|
|
737
|
+
turn profiling off. By default `rank: 'signals'` will still order products by
|
|
738
|
+
the shopper's signals in cohort mode and in the fallback, even with no provider.
|
|
739
|
+
Cohort mode will also still send their strongest category, whether they have any
|
|
740
|
+
history and any segment to the model.
|
|
708
741
|
|
|
709
742
|
**Marketplaces.** If other sellers list products on your site, EU platform rules
|
|
710
743
|
(Article 27 of the Digital Services Act) ask you to set out in your terms:
|
|
@@ -717,26 +750,27 @@ This doesn't apply if you're a micro or small business. If shoppers can pick
|
|
|
717
750
|
between options, such as switching personalisation off, the control has to sit
|
|
718
751
|
where the products are shown.
|
|
719
752
|
|
|
720
|
-
Which parameters apply
|
|
753
|
+
Which parameters apply will depend on your mode and your `rank` setting:
|
|
721
754
|
|
|
722
|
-
-
|
|
723
|
-
that's the order you sent
|
|
755
|
+
- In cohort mode and in the fallback, `rank` sets the order. With
|
|
756
|
+
`rank: 'given'` that's the order you sent. This means you will have to
|
|
757
|
+
describe your own ranking.
|
|
724
758
|
- With `rank: 'signals'` the order comes from the shopper's category interest
|
|
725
759
|
(the category they're browsing counts too), how often they've viewed a
|
|
726
|
-
product, its rating
|
|
727
|
-
|
|
760
|
+
product, its rating and any tags it shares with products they've engaged with.
|
|
761
|
+
Of these, category weighs most.
|
|
728
762
|
- In per-shopper mode the model picks which candidates to show and in what
|
|
729
763
|
order, from what's under [What the model sees](#what-the-model-sees).
|
|
730
|
-
- In cohort mode the model picks the hero's product
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
- A bundle
|
|
735
|
-
category being browsed.
|
|
736
|
-
|
|
737
|
-
**Accessibility.**
|
|
738
|
-
only render
|
|
739
|
-
|
|
764
|
+
- In cohort mode the model picks the hero's product from the cohort list there.
|
|
765
|
+
- In all modes, anything out of stock, disliked or being looked at right now
|
|
766
|
+
will be left out. This is also true of anything bought or in the basket,
|
|
767
|
+
except as part of a bundle.
|
|
768
|
+
- A bundle will be picked by what's in the basket, then what they viewed, then
|
|
769
|
+
the category being browsed.
|
|
770
|
+
|
|
771
|
+
**Accessibility.** Each of the cards is a single link with a readable name.
|
|
772
|
+
Headings will only render if they have text. You will have to check contrast,
|
|
773
|
+
focus styles and layout yourself, because these come from your stylesheet.
|
|
740
774
|
|
|
741
775
|
## Licence
|
|
742
776
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rudra-js/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Core of rudra-js, a TypeScript library for LLM-designed product recommendations: turns one page view into a recommendation spec checked against your catalog",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rudra-js",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"zod": "^4.6.7"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@rudra-js/attested": "^0.
|
|
63
|
+
"@rudra-js/attested": "^0.9.1"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@rudra-js/attested": "0.
|
|
66
|
+
"@rudra-js/attested": "0.9.1",
|
|
67
67
|
"@types/node": "^26.4.1"
|
|
68
68
|
}
|
|
69
69
|
}
|