@rudra-js/core 0.1.0 → 0.3.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 +306 -73
- package/dist/component-generator.d.ts +20 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +29 -15
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +7 -7
- package/dist/component-spec.js +8 -8
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +2 -1
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +2 -2
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +4 -2
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/product-selection.d.ts +14 -1
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +10 -11
- package/dist/product-selection.js.map +1 -1
- package/dist/reconciliation.d.ts +7 -1
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +40 -16
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +10 -7
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +6 -5
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +7 -0
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +5 -0
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +17 -2
- package/dist/tracking-input.js.map +1 -1
- package/package.json +3 -3
- package/src/component-generator.ts +54 -21
- package/src/component-spec.ts +8 -8
- package/src/fallback-component.ts +7 -3
- package/src/fit-to-shopper.ts +3 -1
- package/src/index.ts +2 -6
- package/src/product-selection.ts +29 -5
- package/src/reconciliation.ts +56 -12
- package/src/signal-digest.ts +10 -7
- package/src/spec-cache.ts +15 -5
- package/src/tracking-input.ts +17 -2
package/README.md
CHANGED
|
@@ -9,42 +9,60 @@ component specification.
|
|
|
9
9
|
npm install @rudra-js/core zod@^4
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
`zod` is a peer dependency
|
|
13
|
-
|
|
14
|
-
required
|
|
15
|
-
with `ERESOLVE
|
|
12
|
+
`zod` is a peer dependency. The public API of this package _is_ zod schemas, so
|
|
13
|
+
your app and the package have to resolve the same copy of zod. **zod 4.5 or later is
|
|
14
|
+
required**, which is what the peer range asks for. The schemas use zod 4 APIs,
|
|
15
|
+
and installing into a zod 3 app fails with `ERESOLVE`, which isn't the most
|
|
16
|
+
helpful error you'll ever read. The floor is 4.5 rather than 4.0 because 4.5
|
|
17
|
+
changed how a nullable field is written into the tool schema we send the model.
|
|
16
18
|
|
|
17
19
|
## Running without a model
|
|
18
20
|
|
|
19
21
|
`createComponentGenerator` takes a `provider`. Leave it out, or pass `null`, and
|
|
20
|
-
nothing calls a model and nothing
|
|
22
|
+
nothing calls a model and nothing gets billed:
|
|
21
23
|
|
|
22
24
|
```ts
|
|
23
25
|
const generator = createComponentGenerator({ provider: null });
|
|
24
26
|
const spec = await generator.generate(input);
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
This is a supported setting, not a stub. It's the control arm of the benchmark,
|
|
30
|
+
and it's the right one until you've settled on a provider. `generate` returns a
|
|
31
|
+
promise either way, so your code keeps its shape when you add one.
|
|
32
|
+
|
|
33
|
+
The deterministic component emits exactly one **grid** block, with a headline
|
|
34
|
+
from a fixed set of four, or no blocks at all when there's nothing left to show.
|
|
35
|
+
That happens when no candidate is in stock, and equally when every one of them
|
|
36
|
+
is ruled out for this shopper.
|
|
37
|
+
Every other block kind in the vocabulary (hero, carousel, banner, copy, bundle)
|
|
38
|
+
only ever comes from a model. So if you're wiring up bundles and none of them
|
|
39
|
+
appear, that's why. It isn't your catalog.
|
|
40
|
+
|
|
41
|
+
Want to render a spec you wrote yourself, still without a model? Pass
|
|
42
|
+
`createFixedSpecProvider(spec)` as the provider and it answers every request with
|
|
43
|
+
that spec. That's how our tests exercise the blocks the deterministic component
|
|
44
|
+
never emits.
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
no candidate is in stock — with a headline from a fixed set of four. Every other
|
|
34
|
-
block kind in the vocabulary (hero, carousel, banner, copy, bundle) only ever
|
|
35
|
-
comes from a model. If you are wiring bundles and none appear, that is why, and
|
|
36
|
-
not your catalog.
|
|
46
|
+
## Options
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
Everything `createComponentGenerator` takes, and what you get if you leave it
|
|
49
|
+
out.
|
|
50
|
+
|
|
51
|
+
| Option | What it does | Default |
|
|
52
|
+
| ---------------- | ----------------------------------------------------------------------------------------------- | ------------------------- |
|
|
53
|
+
| `provider` | The model adapter. `null` runs without a model and bills nothing. | `null` |
|
|
54
|
+
| `cache` | Where generated specs live between requests. Pass `createNullSpecCache()` to keep none. | `createMemorySpecCache()` |
|
|
55
|
+
| `generation` | `'cohort'` shares one component between shoppers who look alike; `'per-shopper'` does each one. | `'cohort'` |
|
|
56
|
+
| `rank` | `'signals'` orders products by the shopper's signals; `'given'` keeps the order you sent. | `'signals'` |
|
|
57
|
+
| `modelTimeoutMs` | How long the model gets. Past that, we abort the request and render the deterministic one. | `1500` |
|
|
58
|
+
| `cacheTimeoutMs` | How long a cache read gets. Past that, the request generates as if the store had nothing. | `50` |
|
|
59
|
+
| `onEvent` | Called once per `generate` with a `GenerationEvent`. If your hook throws, we swallow it. | none |
|
|
42
60
|
|
|
43
61
|
## `tracking-input`
|
|
44
62
|
|
|
45
|
-
The boundary between
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
The boundary between your application and rudra-js. We collect nothing, store
|
|
64
|
+
nothing, aggregate nothing. You own your tracking pipeline and hand us one JSON
|
|
65
|
+
object per render.
|
|
48
66
|
|
|
49
67
|
```ts
|
|
50
68
|
import { parseTrackingInput } from '@rudra-js/core';
|
|
@@ -68,51 +86,55 @@ const input = parseTrackingInput({
|
|
|
68
86
|
});
|
|
69
87
|
```
|
|
70
88
|
|
|
71
|
-
`parseTrackingInput` throws a `ZodError
|
|
72
|
-
`TrackingInputResult` instead, so
|
|
73
|
-
without importing zod
|
|
89
|
+
`parseTrackingInput` throws a `ZodError`. If you'd rather not catch,
|
|
90
|
+
`safeParseTrackingInput` hands you a `TrackingInputResult` instead, so you can
|
|
91
|
+
read `result.error.issues` without importing zod yourself.
|
|
74
92
|
|
|
75
93
|
### Cold start is not an error
|
|
76
94
|
|
|
77
95
|
A payload with no `signals` block is a first-time visitor, not a malformed
|
|
78
|
-
request. Every category defaults to `[]`, so
|
|
96
|
+
request. Every category defaults to `[]`, so you don't need a special case for
|
|
97
|
+
it.
|
|
79
98
|
|
|
80
99
|
### What the host must supply
|
|
81
100
|
|
|
82
|
-
`user.id`, `context.surface`, and at least one entry in `candidates`.
|
|
83
|
-
|
|
84
|
-
be recommended, which is what makes it impossible to surface a product that
|
|
85
|
-
does not exist or is not merchandised for this shopper. SKUs must be unique.
|
|
101
|
+
`user.id`, `context.surface`, and at least one entry in `candidates`. SKUs must
|
|
102
|
+
be unique.
|
|
86
103
|
|
|
87
|
-
`
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
104
|
+
`candidates` is the merchandising boundary. Every SKU the model writes gets
|
|
105
|
+
looked up in that list, and one that isn't on it is dropped by reconciliation
|
|
106
|
+
before anything renders. A product you left out doesn't reach the page.
|
|
107
|
+
|
|
108
|
+
`bundles` is optional. These are the sets you sell together, each with your own
|
|
109
|
+
price for the set, the currency that price is in, and, if you want one, your own
|
|
110
|
+
name for it. Every product in a set has to be a candidate as well. That's what
|
|
111
|
+
lets the same checks that pass a single product pass a whole set, and what lets
|
|
112
|
+
the renderer look the members up in the catalog it already has. Ids must be
|
|
92
113
|
unique, and one set must not name the same product twice.
|
|
93
114
|
|
|
94
|
-
The model
|
|
95
|
-
bundle block and
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
Every word the model writes is read for claims: the headline, the subheadline,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
The model _never_ picks a set and is never told a price. All it does is ask for a
|
|
116
|
+
bundle block and write the words around it. We pick which set when the page is
|
|
117
|
+
served, going on what the shopper has in their basket, has looked at, or is
|
|
118
|
+
browsing right now.
|
|
119
|
+
|
|
120
|
+
Every word the model writes is read for claims: the headline, the subheadline, a
|
|
121
|
+
hero, a banner, a block title, the copy block, the reason under a product, and
|
|
122
|
+
the words around the set. Text you supplied is never read this way. A product
|
|
123
|
+
title, a category and a bundle `label` are your words, not the model's.
|
|
124
|
+
|
|
125
|
+
We drop text that makes a claim we can't check. The check looks for money, a
|
|
126
|
+
customer score, a delivery date and a count of what's left, and it leaves a
|
|
127
|
+
specification alone even when that specification has a number in it. Spotting one
|
|
128
|
+
isn't a guarantee, not the way checking a price against your catalog is.
|
|
129
|
+
|
|
130
|
+
Some fields can't be empty, like a headline or a banner's text. Those get emptied
|
|
131
|
+
instead of nulled, so the block drops the way any block with no text drops. And
|
|
110
132
|
an emptied page headline makes the whole generation unusable.
|
|
111
133
|
|
|
112
|
-
For the set the prompt also tells the model to write about the offer
|
|
113
|
-
products in it, and never to say the set saves money or by how much. Pass a
|
|
114
|
-
`label` on the bundle to put your own words on the set
|
|
115
|
-
wrote, not text the model wrote,
|
|
134
|
+
For the set, the prompt also tells the model to write about the offer rather than
|
|
135
|
+
the products in it, and never to say the set saves money or by how much. Pass a
|
|
136
|
+
`label` on the bundle to put your own words on the set. A label is text you
|
|
137
|
+
wrote, not text the model wrote, so it renders ahead of the model's words.
|
|
116
138
|
|
|
117
139
|
### Defaults
|
|
118
140
|
|
|
@@ -131,34 +153,50 @@ wrote, not text the model wrote, and it renders ahead of the model's words.
|
|
|
131
153
|
| `mostViewed[].views` | `1` |
|
|
132
154
|
| `lastPurchased[].quantity` | `1` |
|
|
133
155
|
|
|
156
|
+
`context.locale` has to be a single language tag, such as `en-US`. One tag. Not a
|
|
157
|
+
list, and not an `Accept-Language` header.
|
|
158
|
+
|
|
134
159
|
### Cohorts
|
|
135
160
|
|
|
136
|
-
By default one generated component is shared between shoppers who look alike,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
searched for
|
|
161
|
+
By default one generated component is shared between shoppers who look alike, and
|
|
162
|
+
each shopper's own products are filled in per request. A cohort is the shopper's
|
|
163
|
+
segment, the surface and slot, the locale, the item count, whether they're a
|
|
164
|
+
first-time visitor, and the category they lean towards. Everything that makes a
|
|
165
|
+
person an individual stays out of it: who they are, what they liked, viewed or
|
|
166
|
+
searched for. That's what lets many page views reuse one call.
|
|
142
167
|
|
|
143
|
-
The candidate list is part of the cohort too,
|
|
168
|
+
The candidate list is part of the cohort too, since the model is shown those
|
|
144
169
|
products and writes about them. In most shops candidates come from the page, so
|
|
145
|
-
everyone looking at it shares them.
|
|
146
|
-
|
|
170
|
+
everyone looking at it shares them. If you pick candidates per shopper you'll get
|
|
171
|
+
smaller cohorts. That's the honest outcome, because your prompt really is
|
|
147
172
|
personal.
|
|
148
173
|
|
|
149
|
-
Pass `generation: 'per-shopper'` to generate for the individual instead.
|
|
150
|
-
|
|
174
|
+
Pass `generation: 'per-shopper'` to generate for the individual instead. The
|
|
175
|
+
model then chooses the products too, and every shopper pays for their own call.
|
|
151
176
|
|
|
152
177
|
```ts
|
|
153
178
|
createComponentGenerator({ provider, generation: 'per-shopper' });
|
|
154
179
|
```
|
|
155
180
|
|
|
181
|
+
#### What you put in `segment`
|
|
182
|
+
|
|
183
|
+
`segment` is sent to the model exactly as you wrote it, in both modes, and the
|
|
184
|
+
contract takes any string up to 128 characters. Stick to plain merchandising
|
|
185
|
+
labels like `lapsed`, `high-value`, `trial`, `wholesale`. Keep out anything that
|
|
186
|
+
says something protected about a person: health, race, ethnic origin, religion or
|
|
187
|
+
belief, sex life or sexual orientation, politics, union membership, biometric or
|
|
188
|
+
genetic data.
|
|
189
|
+
|
|
190
|
+
The same goes for `recentSearches`, `context.searchQuery` and `interaction.type`
|
|
191
|
+
in per-shopper mode. Those three are shopper text and they're sent as written.
|
|
192
|
+
What a shopper types is theirs. What you label them with is your choice.
|
|
193
|
+
|
|
156
194
|
### Limits
|
|
157
195
|
|
|
158
|
-
Every free-text field and every array is capped, because
|
|
196
|
+
Every free-text field and every array is capped, because your strings end up
|
|
159
197
|
inside a model prompt and a model is billed per token. The caps live in
|
|
160
|
-
`FIELD_LIMITS` and
|
|
161
|
-
|
|
198
|
+
`FIELD_LIMITS` and we export them, so you can validate against the same numbers
|
|
199
|
+
instead of finding them out from a rejection.
|
|
162
200
|
|
|
163
201
|
| Limit | Value | Applies to |
|
|
164
202
|
| -------------------- | ----- | ------------------------------------------------------------------- |
|
|
@@ -172,17 +210,212 @@ numbers rather than discovering them from a rejection.
|
|
|
172
210
|
| `candidates` | 200 | `candidates` |
|
|
173
211
|
| `productsPerBundle` | 5 | `bundles[].skus`, which also needs at least 2 |
|
|
174
212
|
| `bundles` | 20 | `bundles` |
|
|
213
|
+
| `localeTag` | 35 | `context.locale`, which also has to be one language tag |
|
|
214
|
+
| `maxItems` | 12 | `context.maxItems`, which also needs at least 1 |
|
|
215
|
+
| `reason` | 120 | `candidates[].reason`, your own phrase for a product |
|
|
175
216
|
|
|
176
|
-
These bound each field
|
|
217
|
+
These bound each field on its own. They aren't an aggregate prompt budget.
|
|
177
218
|
Fitting a payload into a context window is `digest`'s job, and it trims rather
|
|
178
219
|
than throws.
|
|
179
220
|
|
|
180
221
|
### Unknown fields are rejected
|
|
181
222
|
|
|
182
|
-
Every fixed-shape object is a `strictObject`.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
223
|
+
Every fixed-shape object is a `strictObject`. Misspell `recentSearches` and
|
|
224
|
+
you'll get an error, not a shopper who quietly looks like a first-time visitor.
|
|
225
|
+
`interaction.meta` is the one dynamic shape: an open record, minus the keys that
|
|
226
|
+
would mutate a prototype instead of the object.
|
|
227
|
+
|
|
228
|
+
## Bringing your own ranking
|
|
229
|
+
|
|
230
|
+
By default we order the products for you, scoring each candidate against the
|
|
231
|
+
shopper's signals. But you might already have a recommender you trust: bought
|
|
232
|
+
together, an engine trained on your own orders, or a merchandiser's hand-picked
|
|
233
|
+
row. Pass `rank: 'given'` and the order you sent is the order that renders.
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const generator = createComponentGenerator({ provider, rank: 'given' });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
You keep the rest either way. We still drop anything the shopper shouldn't be
|
|
240
|
+
shown, whether it's out of stock, already bought, in the basket, disliked, or
|
|
241
|
+
the product they're looking at right now. Every product still carries a basis
|
|
242
|
+
we check against their real signals, and everything the model writes is still
|
|
243
|
+
screened.
|
|
244
|
+
|
|
245
|
+
Each candidate can carry its own `reason`, the phrase shown under the product.
|
|
246
|
+
Reach for it when your ranking knows something the signals don't:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
candidates: [
|
|
250
|
+
{
|
|
251
|
+
sku: 'A-2',
|
|
252
|
+
title: 'Enamel dutch oven',
|
|
253
|
+
category: 'Cookware',
|
|
254
|
+
price: 89,
|
|
255
|
+
reason: 'Bought together with your skillet',
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
A reason you supply is your own words, like the title, so it is rendered as
|
|
261
|
+
written and not screened. That only applies where this request actually used it,
|
|
262
|
+
which is the default `cohort` mode. In `per-shopper` mode the model writes the
|
|
263
|
+
reasons itself, so every one of them is screened, including one that happens to
|
|
264
|
+
read the same as yours.
|
|
265
|
+
|
|
266
|
+
Without a reason of your own, the basis is stated for you from the shopper's
|
|
267
|
+
signals.
|
|
268
|
+
|
|
269
|
+
## What the model sees
|
|
270
|
+
|
|
271
|
+
The two generation modes send different things. Cohort is the default.
|
|
272
|
+
|
|
273
|
+
### Cohort mode
|
|
274
|
+
|
|
275
|
+
- the surface and the slot
|
|
276
|
+
- the locale
|
|
277
|
+
- the segment, when you set one
|
|
278
|
+
- the category being browsed (`context.currentCategory`)
|
|
279
|
+
- the name of the category the shopper leans towards most. Just the name, the
|
|
280
|
+
score stays behind
|
|
281
|
+
- whether this shopper has no history at all
|
|
282
|
+
- how many products the component may place (`context.maxItems`)
|
|
283
|
+
- the candidate list: one line per product, with its SKU, title, category, rating
|
|
284
|
+
and tags
|
|
285
|
+
|
|
286
|
+
### Per-shopper mode
|
|
287
|
+
|
|
288
|
+
Everything above, and:
|
|
289
|
+
|
|
290
|
+
- the SKU being looked at right now
|
|
291
|
+
- the current search
|
|
292
|
+
- whether this is a returning shopper
|
|
293
|
+
- liked SKUs, and disliked SKUs
|
|
294
|
+
- purchased SKUs, and what is in the basket
|
|
295
|
+
- the most-viewed SKUs, each with its view count
|
|
296
|
+
- recent searches
|
|
297
|
+
- every category they lean towards, strongest first
|
|
298
|
+
- the other kinds of interaction, each with a count
|
|
299
|
+
|
|
300
|
+
### Left out of both
|
|
301
|
+
|
|
302
|
+
- `user.id`
|
|
303
|
+
- every timestamp (`at`), which we use to sort signals and then drop
|
|
304
|
+
- dwell time (`dwellMs`), added up in the digest and left out of the prompt
|
|
305
|
+
- every price, and every currency
|
|
306
|
+
- `imageUrl`
|
|
307
|
+
- `interaction.value` and `interaction.meta`. The model is told which kinds of
|
|
308
|
+
interaction happened and how often, and no more
|
|
309
|
+
|
|
310
|
+
The candidate list is trimmed on the way out. An out-of-stock product is dropped,
|
|
311
|
+
and at most 60 products go, in the order you supplied them.
|
|
312
|
+
|
|
313
|
+
`spec-cache.test.ts` walks every field of the digest and checks that each one is
|
|
314
|
+
either in the cohort key or scrubbed from the cohort prompt. A field the key
|
|
315
|
+
leaves out that still changes the prompt fails that test. Adding a field to the
|
|
316
|
+
digest fails it too, until someone says which side the field is on.
|
|
317
|
+
|
|
318
|
+
## What the model decides, by mode
|
|
319
|
+
|
|
320
|
+
What the model wrote, and what we replace before the page is served. Cohort is
|
|
321
|
+
the default.
|
|
322
|
+
|
|
323
|
+
| Decision | Cohort, the default | Per-shopper |
|
|
324
|
+
| ---------------------------------- | --------------------------------------- | -------------------------------------- |
|
|
325
|
+
| Layout and block order | The model | The model |
|
|
326
|
+
| Headline, subheadline, copy | The model | The model |
|
|
327
|
+
| Emphasis per item | The model | The model |
|
|
328
|
+
| Badge text | Dropped, written for another product | The model |
|
|
329
|
+
| Which products, and in what order | Filled in per request, not by the model | The model, from your candidates |
|
|
330
|
+
| The reason and basis per product | Filled in per request, not by the model | The model, checked against the signals |
|
|
331
|
+
| Which bundle, of the ones you pass | Chosen per request, not by the model | Chosen per request, not by the model |
|
|
332
|
+
|
|
333
|
+
In cohort mode the grid and carousel items are filled in per request, best pick
|
|
334
|
+
first, so a component written for one shopper still fits the next.
|
|
335
|
+
|
|
336
|
+
The hero is the exception. It keeps the product the model named, because its
|
|
337
|
+
headline and body were written about that product, and swapping it would leave
|
|
338
|
+
copy about something else. When that product can't be placed, reconciliation
|
|
339
|
+
drops the link and keeps the words. It can't be placed if this
|
|
340
|
+
shopper can't see it (out of stock, not a candidate, disliked, already bought, in
|
|
341
|
+
the basket, or the one being looked at), an earlier block already placed it, or
|
|
342
|
+
the item budget ran out before the hero was reached.
|
|
343
|
+
|
|
344
|
+
## Any provider
|
|
345
|
+
|
|
346
|
+
A provider is three things:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
export interface ComponentProvider {
|
|
350
|
+
/** Short identifier recorded on every generated spec, e.g. 'anthropic'. */
|
|
351
|
+
readonly name: string;
|
|
352
|
+
/** Concrete model identifier, e.g. 'claude-opus-5'. */
|
|
353
|
+
readonly model: string;
|
|
354
|
+
generate(request: ProviderRequest): Promise<ProviderResult>;
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`createComponentGenerator({ provider })` takes any object of that shape: a hosted
|
|
359
|
+
API, a model you run yourself, a deployment inside your own tenancy, or a
|
|
360
|
+
recorded fixture. `@rudra-js/core` depends on no vendor SDK.
|
|
361
|
+
`@rudra-js/anthropic` is one adapter, not a requirement, and `provider: null` is
|
|
362
|
+
the default that costs nothing.
|
|
363
|
+
|
|
364
|
+
An adapter takes its API key as an option, so you choose where the key comes
|
|
365
|
+
from. `ANTHROPIC_API_KEY` is just the name the example shop uses for its own
|
|
366
|
+
convenience. No package here reads the environment.
|
|
367
|
+
|
|
368
|
+
## The cache
|
|
369
|
+
|
|
370
|
+
`cache` defaults to an in-process store. `createMemorySpecCache()` keeps an entry
|
|
371
|
+
for `ttlMs`, 60,000 milliseconds by default, so one minute. It holds up to
|
|
372
|
+
`maxEntries`, 10,000 by default. Once it's full, the entry read longest ago is
|
|
373
|
+
the first to go.
|
|
374
|
+
|
|
375
|
+
An entry holds the generated spec and `generatedAt`, the epoch milliseconds when
|
|
376
|
+
the model produced it. That's the whole of it. No payload, no shopper, no prompt.
|
|
377
|
+
|
|
378
|
+
The port is two methods, and an optional third:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
export interface SpecCache {
|
|
382
|
+
get(key: string): Promise<CachedSpec | undefined>;
|
|
383
|
+
set(key: string, cached: CachedSpec): Promise<void>;
|
|
384
|
+
delete?(key: string): Promise<void>;
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Pass your own store, whether that's Redis, Memcached or whatever you already run,
|
|
389
|
+
and it keeps entries on its own terms. Just keep in mind that what that store
|
|
390
|
+
holds, and for how long, is yours to declare to your users. This package doesn't
|
|
391
|
+
set it. Pass `createNullSpecCache()` to store nothing at all.
|
|
392
|
+
|
|
393
|
+
### When a generation is wrong
|
|
394
|
+
|
|
395
|
+
Pass `provider: null` and nothing new is generated, so every page renders the
|
|
396
|
+
deterministic component. Shorten `ttlMs` and a bad entry ends sooner. A store
|
|
397
|
+
with `delete` can drop one entry by the `key` on its `GenerationEvent`, and the
|
|
398
|
+
next request generates again. Per-shopper entries only end by TTL, because
|
|
399
|
+
nothing maps a shopper to their keys.
|
|
400
|
+
|
|
401
|
+
## Watching it in production
|
|
402
|
+
|
|
403
|
+
The generator never fails a render, so a provider that's been down for a week
|
|
404
|
+
only shows as plainer pages. The way to know is `onEvent`: every call to
|
|
405
|
+
`generate` that gets past input validation reports exactly one `GenerationEvent`.
|
|
406
|
+
A payload that fails `parseTrackingInput` throws instead, and reports nothing.
|
|
407
|
+
|
|
408
|
+
These are the numbers worth keeping:
|
|
409
|
+
|
|
410
|
+
- **Fallback share** — the share of events with `source: 'fallback'`. Alert when
|
|
411
|
+
it climbs. `degradedReason` tells you which way the call failed, and `error`
|
|
412
|
+
carries what was thrown when the reason is `'provider-error'` or `'timeout'`.
|
|
413
|
+
- **Cache hit rate** — `cache: 'hit'` over the events that have a `cache` field.
|
|
414
|
+
A store that's down shows as `cache: 'error'`, and a slow one as
|
|
415
|
+
`cache: 'timeout'`, rather than as a rising bill.
|
|
416
|
+
- **Spend** — sum `usage` over the events where `calledModel` is true. Requests
|
|
417
|
+
that joined an in-flight generation carry the same `usage`, so summing over
|
|
418
|
+
every event counts one call many times.
|
|
186
419
|
|
|
187
420
|
## Licence
|
|
188
421
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ComponentSpec, type DegradedReason, type SpecSource } from './component-spec.js';
|
|
2
2
|
import type { ComponentProvider, TokenUsage } from './provider.js';
|
|
3
|
+
import { type RankOrder } from './product-selection.js';
|
|
3
4
|
import { type SpecCache } from './spec-cache.js';
|
|
4
5
|
import { type TrackingInputDraft } from './tracking-input.js';
|
|
5
6
|
/**
|
|
@@ -50,6 +51,8 @@ export interface GenerationEvent {
|
|
|
50
51
|
violations?: string[];
|
|
51
52
|
usage?: TokenUsage;
|
|
52
53
|
degradedReason?: DegradedReason;
|
|
54
|
+
error?: unknown;
|
|
55
|
+
cache?: 'hit' | 'miss' | 'error' | 'timeout';
|
|
53
56
|
}
|
|
54
57
|
export interface ComponentGeneratorOptions {
|
|
55
58
|
/**
|
|
@@ -71,7 +74,21 @@ export interface ComponentGeneratorOptions {
|
|
|
71
74
|
* hold the page open, which is exactly what this module exists to prevent.
|
|
72
75
|
*/
|
|
73
76
|
cacheTimeoutMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* 'cohort' shares one generated component between shoppers who look alike and
|
|
79
|
+
* fills in each shopper's own products. 'per-shopper' generates for the
|
|
80
|
+
* individual, which is what the benchmark compares against. Defaults to
|
|
81
|
+
* 'cohort'.
|
|
82
|
+
*/
|
|
74
83
|
generation?: 'cohort' | 'per-shopper';
|
|
84
|
+
/**
|
|
85
|
+
* How the products are ordered. 'signals' scores each candidate from this
|
|
86
|
+
* shopper's signals. 'given' keeps the order you sent, for a shop whose own
|
|
87
|
+
* ranking is better than four weights. Either way the exclusions and the
|
|
88
|
+
* stock check still apply, and each product still carries a basis
|
|
89
|
+
* reconciliation can verify. Defaults to 'signals'.
|
|
90
|
+
*/
|
|
91
|
+
rank?: RankOrder;
|
|
75
92
|
/** Observability. Never allowed to break a render. */
|
|
76
93
|
onEvent?: (event: GenerationEvent) => void;
|
|
77
94
|
}
|
|
@@ -80,5 +97,8 @@ export interface ComponentGenerator {
|
|
|
80
97
|
/** The deterministic component, without consulting a model or a cache. */
|
|
81
98
|
generateDeterministic(input: TrackingInputDraft): ComponentSpec;
|
|
82
99
|
}
|
|
100
|
+
export declare class TimeoutError extends Error {
|
|
101
|
+
constructor(label: string, milliseconds: number);
|
|
102
|
+
}
|
|
83
103
|
export declare function createComponentGenerator(options?: ComponentGeneratorOptions): ComponentGenerator;
|
|
84
104
|
//# sourceMappingURL=component-generator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAOnE,OAAO,EAAkB,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAG1F,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAED,qBAAa,YAAa,SAAQ,KAAK;IACrC,YAAY,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAG9C;CACF;AAgKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA6PpB"}
|
|
@@ -8,7 +8,7 @@ import { fitToShopper } from './fit-to-shopper.js';
|
|
|
8
8
|
import { buildDigest, toCohortDigest } from './signal-digest.js';
|
|
9
9
|
import { createMemorySpecCache, cohortCacheKey, specCacheKey, } from './spec-cache.js';
|
|
10
10
|
import { parseTrackingInput, } from './tracking-input.js';
|
|
11
|
-
class TimeoutError extends Error {
|
|
11
|
+
export class TimeoutError extends Error {
|
|
12
12
|
constructor(label, milliseconds) {
|
|
13
13
|
super(`${label} exceeded ${milliseconds}ms`);
|
|
14
14
|
this.name = 'TimeoutError';
|
|
@@ -91,8 +91,8 @@ const cachedSpecSchema = z.object({
|
|
|
91
91
|
* reaches the same set this did — it can only ever have more placed than the
|
|
92
92
|
* pre-choice assumed, and never one of the set's own products.
|
|
93
93
|
*/
|
|
94
|
-
function fitCohortSpec(spec, input, digest) {
|
|
95
|
-
const picks = selectProducts(input, digest);
|
|
94
|
+
function fitCohortSpec(spec, input, digest, rank, hostReasonSkus) {
|
|
95
|
+
const picks = selectProducts(input, digest, { rank });
|
|
96
96
|
// Blocks past the cap never render, so a set is not worth reserving for one.
|
|
97
97
|
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
98
98
|
let hasBundleBlock = false;
|
|
@@ -105,25 +105,25 @@ function fitCohortSpec(spec, input, digest) {
|
|
|
105
105
|
aboveBundle.push(block);
|
|
106
106
|
}
|
|
107
107
|
if (!hasBundleBlock)
|
|
108
|
-
return fitToShopper(spec, picks, digest.maxItems);
|
|
108
|
+
return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
109
109
|
// Only the heroes above the bundle block are placed when it is reached, so
|
|
110
110
|
// they are all the choice may account for.
|
|
111
111
|
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
|
|
112
112
|
if (!chosen)
|
|
113
|
-
return fitToShopper(spec, picks, digest.maxItems);
|
|
113
|
+
return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
114
114
|
const spokenFor = new Set(chosen.skus);
|
|
115
115
|
for (const sku of placeableHeroSkus(blocks, input, digest))
|
|
116
116
|
spokenFor.add(sku);
|
|
117
117
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
118
118
|
// A set is worth showing, but not at the cost of an empty grid.
|
|
119
119
|
if (roomLeft <= 0)
|
|
120
|
-
return fitToShopper(spec, picks, digest.maxItems);
|
|
120
|
+
return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
121
121
|
const forGrid = [];
|
|
122
122
|
for (const pick of picks) {
|
|
123
123
|
if (!spokenFor.has(pick.product.sku))
|
|
124
124
|
forGrid.push(pick);
|
|
125
125
|
}
|
|
126
|
-
return fitToShopper(spec, forGrid, roomLeft);
|
|
126
|
+
return fitToShopper(spec, forGrid, roomLeft, hostReasonSkus);
|
|
127
127
|
}
|
|
128
128
|
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
129
129
|
function withProvenance(spec, provenance) {
|
|
@@ -133,6 +133,7 @@ export function createComponentGenerator(options = {}) {
|
|
|
133
133
|
const provider = options.provider ?? null;
|
|
134
134
|
const cache = options.cache ?? createMemorySpecCache();
|
|
135
135
|
const generation = options.generation ?? 'cohort';
|
|
136
|
+
const rank = options.rank ?? 'signals';
|
|
136
137
|
const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
|
|
137
138
|
const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
|
|
138
139
|
const singleFlight = createSingleFlight();
|
|
@@ -164,7 +165,7 @@ export function createComponentGenerator(options = {}) {
|
|
|
164
165
|
...modelCall,
|
|
165
166
|
degradedReason,
|
|
166
167
|
});
|
|
167
|
-
return withProvenance(buildFallbackSpec(input, digest), {
|
|
168
|
+
return withProvenance(buildFallbackSpec(input, digest, { rank }), {
|
|
168
169
|
slot: digest.slot,
|
|
169
170
|
source: 'fallback',
|
|
170
171
|
generatedAt: finishedAt,
|
|
@@ -187,12 +188,12 @@ export function createComponentGenerator(options = {}) {
|
|
|
187
188
|
try {
|
|
188
189
|
const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
|
|
189
190
|
const parsed = cachedSpecSchema.safeParse(stored);
|
|
190
|
-
return parsed.success ? parsed.data :
|
|
191
|
+
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
191
192
|
}
|
|
192
|
-
catch {
|
|
193
|
+
catch (error) {
|
|
193
194
|
// A store that is down or slow degrades to generating, not to an error
|
|
194
195
|
// page. Nothing here is worth failing a render over.
|
|
195
|
-
return
|
|
196
|
+
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
196
197
|
}
|
|
197
198
|
};
|
|
198
199
|
/**
|
|
@@ -251,7 +252,8 @@ export function createComponentGenerator(options = {}) {
|
|
|
251
252
|
const key = generation === 'cohort'
|
|
252
253
|
? cohortCacheKey(digest, input.candidates.map((product) => product.sku), providerId)
|
|
253
254
|
: specCacheKey(digest, input.candidates.map((product) => product.sku), providerId);
|
|
254
|
-
const
|
|
255
|
+
const read = await readCache(key);
|
|
256
|
+
const cached = read.entry;
|
|
255
257
|
let calledModel = false;
|
|
256
258
|
let answer;
|
|
257
259
|
// When the model produced this, not when it was served. A cached
|
|
@@ -276,11 +278,16 @@ export function createComponentGenerator(options = {}) {
|
|
|
276
278
|
// The request went out. Leaving `calledModel` to default here reported
|
|
277
279
|
// every failed call as no call at all, so the calls that cost money
|
|
278
280
|
// and produced nothing were the only ones missing from the count.
|
|
279
|
-
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
281
|
+
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
282
|
+
calledModel,
|
|
283
|
+
cache: read.outcome,
|
|
284
|
+
error,
|
|
285
|
+
});
|
|
280
286
|
}
|
|
281
287
|
if (!call.spec) {
|
|
282
288
|
return buildDeterministic(input, digest, startedAt, key, 'invalid-generation', {
|
|
283
289
|
calledModel,
|
|
290
|
+
cache: read.outcome,
|
|
284
291
|
...(call.usage ? { usage: call.usage } : {}),
|
|
285
292
|
});
|
|
286
293
|
}
|
|
@@ -298,11 +305,17 @@ export function createComponentGenerator(options = {}) {
|
|
|
298
305
|
// One place where anything is served, whichever side of the cache it came
|
|
299
306
|
// from, and always against the facts of the shopper asking now.
|
|
300
307
|
// A cohort spec names products chosen for whoever asked first.
|
|
301
|
-
|
|
302
|
-
|
|
308
|
+
// Only the cohort path writes a host reason into a spec, so in
|
|
309
|
+
// per-shopper mode this stays empty and every reason is screened.
|
|
310
|
+
const hostReasonSkus = new Set();
|
|
311
|
+
const served = generation === 'cohort'
|
|
312
|
+
? fitCohortSpec(answer.spec, input, digest, rank, hostReasonSkus)
|
|
313
|
+
: answer.spec;
|
|
314
|
+
const reconciled = reconcileSpec(served, input, digest, hostReasonSkus);
|
|
303
315
|
if (!reconciled.isUsable) {
|
|
304
316
|
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|
|
305
317
|
calledModel,
|
|
318
|
+
cache: read.outcome,
|
|
306
319
|
violations: reconciled.violations,
|
|
307
320
|
...(answer.usage ? { usage: answer.usage } : {}),
|
|
308
321
|
});
|
|
@@ -314,6 +327,7 @@ export function createComponentGenerator(options = {}) {
|
|
|
314
327
|
source,
|
|
315
328
|
elapsedMs: finishedAt - startedAt,
|
|
316
329
|
calledModel,
|
|
330
|
+
cache: read.outcome,
|
|
317
331
|
violations: reconciled.violations,
|
|
318
332
|
...(answer.usage ? { usage: answer.usage } : {}),
|
|
319
333
|
});
|