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