@rudra-js/core 0.5.0 → 0.7.0
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 +64 -16
- package/dist/claim-screening.d.ts +10 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +157 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts +0 -70
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +27 -135
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +4 -159
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +0 -92
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +0 -6
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +0 -21
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +5 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +5 -14
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +1 -30
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +19 -78
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -29
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -22
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +0 -55
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +0 -12
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +6 -61
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +76 -466
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +1 -18
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +27 -66
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +8 -70
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +5 -68
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +0 -49
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +5 -105
- package/dist/tracking-input.js.map +1 -1
- package/package.json +7 -6
- package/src/claim-screening.ts +190 -0
- package/src/component-generator.ts +45 -240
- package/src/component-spec.ts +17 -118
- package/src/fallback-component.ts +0 -22
- package/src/fit-to-shopper.ts +12 -16
- package/src/index.ts +1 -8
- package/src/model-prompt.ts +25 -84
- package/src/product-selection.ts +6 -52
- package/src/provider.ts +4 -60
- package/src/reconciliation.ts +104 -499
- package/src/signal-digest.ts +34 -83
- package/src/spec-cache.ts +10 -97
- package/src/tracking-input.ts +17 -111
package/src/signal-digest.ts
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Interaction,
|
|
3
3
|
Product,
|
|
4
|
-
PurchaseSignal,
|
|
5
4
|
SkuSignal,
|
|
6
5
|
TrackingInput,
|
|
7
6
|
ViewSignal,
|
|
8
7
|
} from './tracking-input.js';
|
|
9
8
|
|
|
10
|
-
/**
|
|
11
|
-
* Reduces a validated tracking payload to the compact, ordered, bounded view
|
|
12
|
-
* that everything downstream reads.
|
|
13
|
-
*
|
|
14
|
-
* Two jobs:
|
|
15
|
-
* 1. Keep the volatile part of a prompt small and stable. The contract lets a
|
|
16
|
-
* host send 500 signals per category; a prompt cannot afford them, and the
|
|
17
|
-
* long tail is noise anyway.
|
|
18
|
-
* 2. Give the deterministic path the same evidence the model gets, so a
|
|
19
|
-
* degraded render is a weaker version of the same decision rather than an
|
|
20
|
-
* unrelated one.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
9
|
export interface CategoryAffinity {
|
|
24
10
|
category: string;
|
|
25
|
-
|
|
11
|
+
|
|
26
12
|
score: number;
|
|
27
13
|
}
|
|
28
14
|
|
|
@@ -59,14 +45,9 @@ export interface SignalDigest {
|
|
|
59
45
|
categoryAffinity: CategoryAffinity[];
|
|
60
46
|
interactionCounts: InteractionCount[];
|
|
61
47
|
|
|
62
|
-
/** True when there is no behavioural evidence to personalise on. */
|
|
63
48
|
isColdStart: boolean;
|
|
64
49
|
}
|
|
65
50
|
|
|
66
|
-
/**
|
|
67
|
-
* How much of each signal category survives into the digest. Chosen to keep the
|
|
68
|
-
* per-shopper part of a prompt in the low hundreds of tokens.
|
|
69
|
-
*/
|
|
70
51
|
export const DIGEST_LIMITS = {
|
|
71
52
|
liked: 12,
|
|
72
53
|
disliked: 12,
|
|
@@ -78,11 +59,6 @@ export const DIGEST_LIMITS = {
|
|
|
78
59
|
interactionTypes: 8,
|
|
79
60
|
} as const;
|
|
80
61
|
|
|
81
|
-
/**
|
|
82
|
-
* How much intent each kind of signal implies. A purchase says more about a
|
|
83
|
-
* shopper than a view; an explicit dislike says the most, and says it in the
|
|
84
|
-
* opposite direction.
|
|
85
|
-
*/
|
|
86
62
|
const SIGNAL_WEIGHTS = {
|
|
87
63
|
purchase: 5,
|
|
88
64
|
like: 4,
|
|
@@ -91,15 +67,8 @@ const SIGNAL_WEIGHTS = {
|
|
|
91
67
|
dislike: -6,
|
|
92
68
|
} as const;
|
|
93
69
|
|
|
94
|
-
/**
|
|
95
|
-
* A signal that does not state a weight counts at full strength. This lives in
|
|
96
|
-
* one place on purpose: when the default was written out at each call site, the
|
|
97
|
-
* merge step defaulted to 0 while scoring defaulted to 1, and an unweighted view
|
|
98
|
-
* merged with a `weight: 0.2` view scored as though both were 0.2.
|
|
99
|
-
*/
|
|
100
70
|
const effectiveWeight = (signal: { weight?: number | undefined }): number => signal.weight ?? 1;
|
|
101
71
|
|
|
102
|
-
/** Most recent first. A signal with no timestamp sorts last. */
|
|
103
72
|
function byMostRecent(
|
|
104
73
|
left: { at?: number | undefined },
|
|
105
74
|
right: { at?: number | undefined },
|
|
@@ -107,7 +76,6 @@ function byMostRecent(
|
|
|
107
76
|
return (right.at ?? 0) - (left.at ?? 0);
|
|
108
77
|
}
|
|
109
78
|
|
|
110
|
-
/** The most recent `limit` SKUs, each appearing once. */
|
|
111
79
|
function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
|
|
112
80
|
const skus: string[] = [];
|
|
113
81
|
const alreadySeen = new Set<string>();
|
|
@@ -122,11 +90,6 @@ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
|
|
|
122
90
|
return skus;
|
|
123
91
|
}
|
|
124
92
|
|
|
125
|
-
/**
|
|
126
|
-
* A signal may name its own category; otherwise we look the SKU up in the
|
|
127
|
-
* candidate set. A signal for a product that is not a candidate today and
|
|
128
|
-
* carries no category simply contributes nothing.
|
|
129
|
-
*/
|
|
130
93
|
function categoryOf(
|
|
131
94
|
signal: { sku: string; category?: string | undefined },
|
|
132
95
|
candidatesBySku: Map<string, Product>,
|
|
@@ -159,9 +122,7 @@ function computeCategoryAffinity(
|
|
|
159
122
|
for (const inCart of signals.cart) {
|
|
160
123
|
addScore(categoryOf(inCart, candidatesBySku), SIGNAL_WEIGHTS.cart * effectiveWeight(inCart));
|
|
161
124
|
}
|
|
162
|
-
|
|
163
|
-
// repeat cheaply, so the tenth view counts for far less than the second, and
|
|
164
|
-
// log scaling keeps a single obsessive session from drowning out a purchase.
|
|
125
|
+
|
|
165
126
|
for (const view of mergeViewsBySku(signals.mostViewed)) {
|
|
166
127
|
const scaledViews = Math.log2(1 + view.views);
|
|
167
128
|
addScore(categoryOf(view, candidatesBySku), SIGNAL_WEIGHTS.view * scaledViews * view.weight);
|
|
@@ -173,37 +134,42 @@ function computeCategoryAffinity(
|
|
|
173
134
|
);
|
|
174
135
|
}
|
|
175
136
|
|
|
176
|
-
// The category the shopper is standing in right now is itself evidence.
|
|
177
137
|
addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
|
|
178
138
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
139
|
+
const affinities: CategoryAffinity[] = [];
|
|
140
|
+
for (const [category, score] of scoreByCategory) {
|
|
141
|
+
const rounded = Math.round(score * 100) / 100;
|
|
142
|
+
if (rounded > 0) affinities.push({ category, score: rounded });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return affinities
|
|
185
146
|
.toSorted((left, right) => right.score - left.score)
|
|
186
147
|
.slice(0, DIGEST_LIMITS.affinity);
|
|
187
148
|
}
|
|
188
149
|
|
|
150
|
+
export function engagedCategories(input: TrackingInput): Set<string> {
|
|
151
|
+
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
152
|
+
const { signals } = input;
|
|
153
|
+
const categories = new Set<string>();
|
|
154
|
+
|
|
155
|
+
for (const signal of [
|
|
156
|
+
...signals.lastPurchased,
|
|
157
|
+
...signals.likes,
|
|
158
|
+
...signals.cart,
|
|
159
|
+
...signals.mostViewed,
|
|
160
|
+
]) {
|
|
161
|
+
const category = categoryOf(signal, candidatesBySku);
|
|
162
|
+
if (category) categories.add(category);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return categories;
|
|
166
|
+
}
|
|
167
|
+
|
|
189
168
|
interface MergedView extends ViewedProduct {
|
|
190
169
|
category?: string;
|
|
191
|
-
/** Always resolved, so no consumer has to re-apply the default. */
|
|
192
170
|
weight: number;
|
|
193
171
|
}
|
|
194
172
|
|
|
195
|
-
/**
|
|
196
|
-
* Collapses every view record for one SKU into a single running total.
|
|
197
|
-
*
|
|
198
|
-
* This has to happen before any scoring. A host is free to emit one record per
|
|
199
|
-
* page view rather than a running count, and scoring each record separately
|
|
200
|
-
* would let thirty `views: 1` records outweigh one `views: 30` record six times
|
|
201
|
-
* over, defeating the sub-linear scaling in `computeCategoryAffinity` entirely.
|
|
202
|
-
* Merging first makes the score depend on how much someone looked, not on how
|
|
203
|
-
* their tracking pipeline happens to batch.
|
|
204
|
-
*
|
|
205
|
-
* Where records disagree on `weight`, the strongest wins.
|
|
206
|
-
*/
|
|
207
173
|
function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
208
174
|
const totalsBySku = new Map<string, MergedView>();
|
|
209
175
|
|
|
@@ -228,22 +194,16 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
|
228
194
|
return [...totalsBySku.values()];
|
|
229
195
|
}
|
|
230
196
|
|
|
231
|
-
/** The most-viewed few, as the digest reports them. */
|
|
232
197
|
function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
|
|
233
198
|
const top = mergeViewsBySku(views)
|
|
234
199
|
.toSorted((left, right) => right.views - left.views)
|
|
235
200
|
.slice(0, DIGEST_LIMITS.viewed);
|
|
236
201
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (merged.dwellMs !== undefined) product.dwellMs = merged.dwellMs;
|
|
241
|
-
products.push(product);
|
|
242
|
-
}
|
|
243
|
-
return products;
|
|
202
|
+
return top.map(({ sku, views: count, dwellMs }) =>
|
|
203
|
+
dwellMs === undefined ? { sku, views: count } : { sku, views: count, dwellMs },
|
|
204
|
+
);
|
|
244
205
|
}
|
|
245
206
|
|
|
246
|
-
/** The open-vocabulary long tail, reduced to "which kinds, and how often". */
|
|
247
207
|
function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
|
|
248
208
|
const countByType = new Map<string, number>();
|
|
249
209
|
|
|
@@ -251,28 +211,23 @@ function countByInteractionType(interactions: Interaction[]): InteractionCount[]
|
|
|
251
211
|
countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
|
|
252
212
|
}
|
|
253
213
|
|
|
254
|
-
|
|
255
|
-
|
|
214
|
+
const counts = [...countByType.entries()].map(([type, count]) => ({ type, count }));
|
|
215
|
+
|
|
216
|
+
return counts
|
|
256
217
|
.toSorted((left, right) => right.count - left.count)
|
|
257
218
|
.slice(0, DIGEST_LIMITS.interactionTypes);
|
|
258
219
|
}
|
|
259
220
|
|
|
260
|
-
function recentPurchasedSkus(purchases: PurchaseSignal[]): string[] {
|
|
261
|
-
return recentUniqueSkus(purchases, DIGEST_LIMITS.purchased);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
221
|
export function buildDigest(input: TrackingInput): SignalDigest {
|
|
265
222
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
266
223
|
const { signals, context, user } = input;
|
|
267
224
|
|
|
268
225
|
const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
|
|
269
226
|
const dislikedSkus = recentUniqueSkus(signals.dislikes, DIGEST_LIMITS.disliked);
|
|
270
|
-
const purchasedSkus =
|
|
227
|
+
const purchasedSkus = recentUniqueSkus(signals.lastPurchased, DIGEST_LIMITS.purchased);
|
|
271
228
|
const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
|
|
272
229
|
const topViewed = mostViewedProducts(signals.mostViewed);
|
|
273
230
|
|
|
274
|
-
// Searches say what a shopper wants; they do not say they engaged with any
|
|
275
|
-
// product, so they do not lift a shopper out of cold start on their own.
|
|
276
231
|
const evidenceCount =
|
|
277
232
|
likedSkus.length +
|
|
278
233
|
dislikedSkus.length +
|
|
@@ -306,13 +261,9 @@ export function buildDigest(input: TrackingInput): SignalDigest {
|
|
|
306
261
|
};
|
|
307
262
|
}
|
|
308
263
|
|
|
309
|
-
// Everything the cohort key leaves out has to leave the prompt too, or the
|
|
310
|
-
// first shopper's searches and history end up shaping copy that is cached and
|
|
311
|
-
// served to everyone else in their cohort.
|
|
312
264
|
export function toCohortDigest(digest: SignalDigest): SignalDigest {
|
|
313
265
|
const top = digest.categoryAffinity[0];
|
|
314
266
|
|
|
315
|
-
// Listed rather than spread, so what survives is the thing you read.
|
|
316
267
|
const cohort: SignalDigest = {
|
|
317
268
|
userId: 'cohort',
|
|
318
269
|
isReturning: false,
|
package/src/spec-cache.ts
CHANGED
|
@@ -3,37 +3,12 @@ import { SPEC_VERSION, type GeneratedSpec } from './component-spec.js';
|
|
|
3
3
|
import { SYSTEM_PROMPT } from './model-prompt.js';
|
|
4
4
|
import type { SignalDigest } from './signal-digest.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Where a generated component is kept between requests.
|
|
8
|
-
*
|
|
9
|
-
* A generation is stable for as long as the shopper's signals are, so asking a
|
|
10
|
-
* model again on the next page view buys nothing and costs a round trip on the
|
|
11
|
-
* render path — which is the latency this whole design exists to avoid.
|
|
12
|
-
*
|
|
13
|
-
* The store is a port rather than an implementation. A single process can use
|
|
14
|
-
* the in-memory one; anything running more than one instance needs a shared
|
|
15
|
-
* store, or each instance keeps its own copy and the hit rate divides by the
|
|
16
|
-
* instance count.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A stored generation, with the moment it was produced.
|
|
21
|
-
*
|
|
22
|
-
* The timestamp travels with the spec because it cannot be recovered later: a
|
|
23
|
-
* component served from cache is not newly generated, and `generatedAt` is the
|
|
24
|
-
* only way anything downstream can tell how stale what it is showing has become.
|
|
25
|
-
*/
|
|
26
6
|
export interface CachedSpec {
|
|
27
7
|
spec: GeneratedSpec;
|
|
28
|
-
|
|
8
|
+
|
|
29
9
|
generatedAt: number;
|
|
30
10
|
}
|
|
31
11
|
|
|
32
|
-
/**
|
|
33
|
-
* Asynchronous on purpose. A synchronous `get` would look simpler and would
|
|
34
|
-
* make the shared store the docs recommend impossible to write, since no Redis
|
|
35
|
-
* or Memcached client can return a value without awaiting.
|
|
36
|
-
*/
|
|
37
12
|
export interface SpecCache {
|
|
38
13
|
get(key: string): Promise<CachedSpec | undefined>;
|
|
39
14
|
set(key: string, cached: CachedSpec): Promise<void>;
|
|
@@ -41,11 +16,10 @@ export interface SpecCache {
|
|
|
41
16
|
}
|
|
42
17
|
|
|
43
18
|
export interface MemorySpecCacheOptions {
|
|
44
|
-
/** How long an entry stays valid. Defaults to 60 seconds. */
|
|
45
19
|
ttlMs?: number;
|
|
46
|
-
|
|
20
|
+
|
|
47
21
|
maxEntries?: number;
|
|
48
|
-
|
|
22
|
+
|
|
49
23
|
now?: () => number;
|
|
50
24
|
}
|
|
51
25
|
|
|
@@ -63,24 +37,10 @@ interface CacheEntry {
|
|
|
63
37
|
expiresAt: number;
|
|
64
38
|
}
|
|
65
39
|
|
|
66
|
-
/**
|
|
67
|
-
* A bounded in-process cache.
|
|
68
|
-
*
|
|
69
|
-
* `maxEntries` is generous relative to the default TTL for a reason: a ceiling
|
|
70
|
-
* low enough to evict entries before they expire silently turns a longer TTL
|
|
71
|
-
* into a setting that does nothing, and the person who raised it has no way to
|
|
72
|
-
* tell.
|
|
73
|
-
*/
|
|
74
40
|
export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): SpecCache {
|
|
75
41
|
const ttlMs = options.ttlMs ?? 60_000;
|
|
76
42
|
const maxEntries = options.maxEntries ?? 10_000;
|
|
77
43
|
|
|
78
|
-
// Checked rather than trusted, because the way these are usually supplied is
|
|
79
|
-
// `Number(process.env.SOMETHING)`, and an unset or misspelled variable makes
|
|
80
|
-
// that NaN. Every comparison against NaN is false, so the cache would then
|
|
81
|
-
// never expire an entry and never evict one — it would grow forever while
|
|
82
|
-
// serving a shopper the component they were given last week, and nothing
|
|
83
|
-
// would report it. Failing at construction is the only loud option.
|
|
84
44
|
assertFiniteAtLeastZero('ttlMs', ttlMs);
|
|
85
45
|
assertFiniteAtLeastZero('maxEntries', maxEntries);
|
|
86
46
|
if (!Number.isSafeInteger(maxEntries)) {
|
|
@@ -100,8 +60,6 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
100
60
|
return undefined;
|
|
101
61
|
}
|
|
102
62
|
|
|
103
|
-
// Re-insert so insertion order tracks recency of use, which is what makes
|
|
104
|
-
// the eviction below least-recently-used rather than oldest-written.
|
|
105
63
|
entries.delete(key);
|
|
106
64
|
entries.set(key, entry);
|
|
107
65
|
return entry.cached;
|
|
@@ -124,25 +82,15 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
124
82
|
};
|
|
125
83
|
}
|
|
126
84
|
|
|
127
|
-
/**
|
|
128
|
-
* A cache that stores nothing, so every request generates.
|
|
129
|
-
*
|
|
130
|
-
* This is not a stub. It is the control for any measurement of generation cost
|
|
131
|
-
* or latency: with a cache in front, a benchmark reports how often it hit, not
|
|
132
|
-
* what generating costs.
|
|
133
|
-
*/
|
|
134
85
|
export function createNullSpecCache(): SpecCache {
|
|
135
86
|
return {
|
|
136
87
|
async get() {
|
|
137
88
|
return undefined;
|
|
138
89
|
},
|
|
139
|
-
async set() {
|
|
140
|
-
// Deliberately nothing.
|
|
141
|
-
},
|
|
90
|
+
async set() {},
|
|
142
91
|
};
|
|
143
92
|
}
|
|
144
93
|
|
|
145
|
-
/** Stable JSON: object keys sorted, so key order in a digest cannot matter. */
|
|
146
94
|
function canonicalise(value: unknown): string {
|
|
147
95
|
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
148
96
|
if (Array.isArray(value)) return `[${value.map(canonicalise).join(',')}]`;
|
|
@@ -156,42 +104,15 @@ function canonicalise(value: unknown): string {
|
|
|
156
104
|
|
|
157
105
|
const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
|
|
158
106
|
|
|
159
|
-
/**
|
|
160
|
-
* Derives the cache key.
|
|
161
|
-
*
|
|
162
|
-
* The digest goes in **whole**, rather than as a hand-picked list of fields.
|
|
163
|
-
* That is the entire point of this function. The previous implementation listed
|
|
164
|
-
* the fields it thought mattered, and four of them drifted: they reached the
|
|
165
|
-
* model but not the key, so two shoppers with genuinely different histories
|
|
166
|
-
* collided and were served each other's component. Hashing the whole digest
|
|
167
|
-
* makes that class of bug unreachable — a field cannot be forgotten here,
|
|
168
|
-
* because nothing is named here.
|
|
169
|
-
*
|
|
170
|
-
* The cost is a lower hit rate than a hand-tuned key would give, since every
|
|
171
|
-
* signal now moves the key. That is the right way round: a key that is too
|
|
172
|
-
* specific wastes money, and a key that is too loose shows one shopper another
|
|
173
|
-
* shopper's page. Raising the hit rate means deliberately coarsening the digest
|
|
174
|
-
* itself, which is a decision to make against measurements rather than by
|
|
175
|
-
* guessing here.
|
|
176
|
-
*
|
|
177
|
-
* Candidates enter as SKUs only. Their titles, prices and ratings reach the
|
|
178
|
-
* model too, but including them would churn the key on every price change for
|
|
179
|
-
* copy that would almost always come back the same. The consequence is bounded
|
|
180
|
-
* and worth stating: within one TTL, a price change does not refresh the
|
|
181
|
-
* generated copy. It cannot show a stale price — prices are read from the live
|
|
182
|
-
* catalog at render time, never from the model.
|
|
183
|
-
*/
|
|
184
107
|
export function specCacheKey(
|
|
185
108
|
digest: SignalDigest,
|
|
186
109
|
candidateSkus: readonly string[],
|
|
187
|
-
|
|
110
|
+
provider: { name: string; model: string },
|
|
188
111
|
): string {
|
|
189
112
|
const material = canonicalise({
|
|
190
|
-
// The spec's own version, so a shape change cannot read entries written by
|
|
191
|
-
// the previous shape out of a shared store that outlives a deploy.
|
|
192
113
|
specVersion: SPEC_VERSION,
|
|
193
114
|
prompt: PROMPT_FINGERPRINT,
|
|
194
|
-
provider
|
|
115
|
+
provider,
|
|
195
116
|
digest,
|
|
196
117
|
candidates: candidateSkus.toSorted(),
|
|
197
118
|
});
|
|
@@ -199,33 +120,25 @@ export function specCacheKey(
|
|
|
199
120
|
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
200
121
|
}
|
|
201
122
|
|
|
202
|
-
// Leaves out the fields that make a key personal: who the shopper is, what they
|
|
203
|
-
// liked, viewed or searched for. Candidates go too, because every shopper's list
|
|
204
|
-
// is different — that is safe because the SKUs in a cohort spec get replaced
|
|
205
|
-
// before the page is served.
|
|
206
123
|
export function cohortCacheKey(
|
|
207
124
|
digest: SignalDigest,
|
|
208
125
|
candidateSkus: readonly string[],
|
|
209
|
-
|
|
126
|
+
provider: { name: string; model: string },
|
|
210
127
|
): string {
|
|
211
128
|
const material = canonicalise({
|
|
212
129
|
specVersion: SPEC_VERSION,
|
|
213
130
|
prompt: PROMPT_FINGERPRINT,
|
|
214
|
-
provider
|
|
131
|
+
provider,
|
|
215
132
|
segment: digest.segment ?? null,
|
|
216
133
|
surface: digest.surface,
|
|
217
134
|
slot: digest.slot,
|
|
218
135
|
locale: digest.locale,
|
|
219
136
|
maxItems: digest.maxItems,
|
|
220
137
|
isColdStart: digest.isColdStart,
|
|
221
|
-
|
|
222
|
-
// served on a tent page.
|
|
138
|
+
|
|
223
139
|
currentCategory: digest.currentCategory ?? null,
|
|
224
140
|
topCategory: digest.categoryAffinity[0]?.category ?? null,
|
|
225
|
-
|
|
226
|
-
// from the page and everyone on it shares them. A shop that picks
|
|
227
|
-
// candidates per shopper gets smaller cohorts, which is the honest result:
|
|
228
|
-
// its prompt really is personal.
|
|
141
|
+
|
|
229
142
|
candidates: candidateSkus.toSorted(),
|
|
230
143
|
});
|
|
231
144
|
|
package/src/tracking-input.ts
CHANGED
|
@@ -1,36 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The tracking-input contract — the boundary between the host application and
|
|
5
|
-
* rudra-js.
|
|
6
|
-
*
|
|
7
|
-
* rudra-js does not collect, store, or aggregate anything. The host owns its
|
|
8
|
-
* tracking pipeline (an event stream, a CDP, a warehouse) and hands the
|
|
9
|
-
* framework one JSON object per render. This module is that contract: one
|
|
10
|
-
* schema, validated at the edge, so a malformed payload fails loudly here
|
|
11
|
-
* rather than quietly producing a bad prompt several layers later.
|
|
12
|
-
*
|
|
13
|
-
* Every fixed-shape object below is a `strictObject`, so an unrecognised field
|
|
14
|
-
* is an error rather than being dropped. That matters more than it looks: with
|
|
15
|
-
* a lenient object, a host that misspells `recentSearches` gets a shopper who
|
|
16
|
-
* silently looks like a first-time visitor, and nothing anywhere reports it.
|
|
17
|
-
* The only dynamic shape is `interaction.meta`, which is a record by design.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Every free-text field and every array is length-capped.
|
|
22
|
-
*
|
|
23
|
-
* These caps are not cosmetic. Host-supplied strings end up inside the prompt
|
|
24
|
-
* we send to a language model, and a model is billed per token — so an
|
|
25
|
-
* unbounded string is an unbounded bill, and an unbounded array of candidates
|
|
26
|
-
* is the same problem multiplied.
|
|
27
|
-
*
|
|
28
|
-
* What this buys is that no single field is unbounded. It is deliberately not
|
|
29
|
-
* an aggregate budget: the caps multiply out to far more than any context
|
|
30
|
-
* window, because rejecting a large-but-legitimate payload is the wrong
|
|
31
|
-
* response to one. Fitting a payload into a prompt is `digest`'s job, and it
|
|
32
|
-
* trims rather than throws.
|
|
33
|
-
*/
|
|
34
3
|
export const FIELD_LIMITS = {
|
|
35
4
|
identifier: 128,
|
|
36
5
|
shortText: 200,
|
|
@@ -44,45 +13,34 @@ export const FIELD_LIMITS = {
|
|
|
44
13
|
bundles: 20,
|
|
45
14
|
localeTag: 35,
|
|
46
15
|
maxItems: 12,
|
|
47
|
-
|
|
48
|
-
// same place a model's is, so the same length has to hold.
|
|
16
|
+
|
|
49
17
|
reason: 120,
|
|
50
18
|
} as const;
|
|
51
19
|
|
|
52
|
-
/** Assigning this as an object key mutates the prototype instead of the object. */
|
|
53
20
|
const RESERVED_META_KEY = '__proto__';
|
|
54
21
|
|
|
55
|
-
/** Upper bound on any timestamp: 2100-01-01. */
|
|
56
22
|
const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
|
|
57
23
|
|
|
58
24
|
const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
|
|
59
25
|
const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).optional();
|
|
60
26
|
|
|
61
|
-
/**
|
|
62
|
-
* Epoch milliseconds, used only for recency ordering. Bounded because a
|
|
63
|
-
* negative or year-3000 timestamp does not fail anywhere downstream — it just
|
|
64
|
-
* sorts to one end and silently reorders the shopper's history.
|
|
65
|
-
*/
|
|
66
27
|
const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
|
|
67
28
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
29
|
+
const isRootRelative = (value: string) => {
|
|
30
|
+
const asParsed = value.replace(/[\t\n\r]/g, '');
|
|
31
|
+
return value.startsWith('/') && /^\/(?![/\\])/.test(asParsed);
|
|
32
|
+
};
|
|
33
|
+
|
|
73
34
|
const imageReference = () =>
|
|
74
35
|
z
|
|
75
36
|
.string()
|
|
76
37
|
.max(FIELD_LIMITS.shortText)
|
|
77
38
|
.refine(
|
|
78
39
|
(value) =>
|
|
79
|
-
value.
|
|
80
|
-
? true
|
|
81
|
-
: z.url({ protocol: /^https?$/ }).safeParse(value).success,
|
|
40
|
+
isRootRelative(value) ? true : z.url({ protocol: /^https?$/ }).safeParse(value).success,
|
|
82
41
|
{ message: 'expected an http(s) URL or a root-relative path' },
|
|
83
42
|
);
|
|
84
43
|
|
|
85
|
-
/** A product the generated component is permitted to place. */
|
|
86
44
|
export const productSchema = z.strictObject({
|
|
87
45
|
sku: identifier(),
|
|
88
46
|
title: z.string().min(1).max(FIELD_LIMITS.shortText),
|
|
@@ -92,15 +50,10 @@ export const productSchema = z.strictObject({
|
|
|
92
50
|
.string()
|
|
93
51
|
.regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
|
|
94
52
|
.default('USD'),
|
|
95
|
-
|
|
96
|
-
// a bare capped string would accept '' and 'javascript:'. Root-relative paths
|
|
97
|
-
// are allowed because most catalogs store images that way, and they carry no
|
|
98
|
-
// scheme to abuse.
|
|
53
|
+
|
|
99
54
|
imageUrl: imageReference().optional(),
|
|
100
55
|
rating: z.number().min(0).max(5).optional(),
|
|
101
|
-
|
|
102
|
-
// one. It renders as the item's reason and is not screened: these are your
|
|
103
|
-
// words about your product, like the title, so you stand behind them.
|
|
56
|
+
|
|
104
57
|
reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
|
|
105
58
|
isInStock: z.boolean().default(true),
|
|
106
59
|
tags: z
|
|
@@ -110,12 +63,11 @@ export const productSchema = z.strictObject({
|
|
|
110
63
|
});
|
|
111
64
|
export type Product = z.infer<typeof productSchema>;
|
|
112
65
|
|
|
113
|
-
/** Base shape for any signal that points at a single SKU. */
|
|
114
66
|
export const skuSignalSchema = z.strictObject({
|
|
115
67
|
sku: identifier(),
|
|
116
68
|
category: optionalIdentifier(),
|
|
117
69
|
at: epochMs(),
|
|
118
|
-
|
|
70
|
+
|
|
119
71
|
weight: z.number().min(0).max(1).optional(),
|
|
120
72
|
});
|
|
121
73
|
export type SkuSignal = z.infer<typeof skuSignalSchema>;
|
|
@@ -132,27 +84,12 @@ export const purchaseSignalSchema = skuSignalSchema.extend({
|
|
|
132
84
|
});
|
|
133
85
|
export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
|
|
134
86
|
|
|
135
|
-
/**
|
|
136
|
-
* `meta` is the one dynamic shape in the contract, so its key is checked before
|
|
137
|
-
* the record is parsed rather than after. Zod builds its result by assigning
|
|
138
|
-
* each key, and assigning `__proto__` sets the prototype instead of adding a
|
|
139
|
-
* key — so the entry would vanish from the parsed output with no error, which
|
|
140
|
-
* is the silent drop this module exists to prevent.
|
|
141
|
-
*
|
|
142
|
-
* `__proto__` is the only key that behaves this way. `constructor` and
|
|
143
|
-
* `prototype` are ordinary own properties: assigning either shadows it on that
|
|
144
|
-
* one object and leaves the prototype alone, so both survive a parse intact and
|
|
145
|
-
* are accepted. `meta` is a host-defined vocabulary, and a shop with a facet,
|
|
146
|
-
* filter, or CMS field by either name should get a component, not a throw.
|
|
147
|
-
*/
|
|
148
87
|
const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
|
|
149
88
|
(value) =>
|
|
150
89
|
typeof value === 'object' && value !== null && !Object.hasOwn(value, RESERVED_META_KEY),
|
|
151
90
|
{ message: `meta may not use the reserved key ${RESERVED_META_KEY}` },
|
|
152
91
|
);
|
|
153
92
|
|
|
154
|
-
// `z.record` bounds key and value shape but not how many entries a record may
|
|
155
|
-
// carry, so the count is checked separately.
|
|
156
93
|
const metaSchema = metaKeysAreSafe.pipe(
|
|
157
94
|
z
|
|
158
95
|
.record(
|
|
@@ -164,11 +101,6 @@ const metaSchema = metaKeysAreSafe.pipe(
|
|
|
164
101
|
}),
|
|
165
102
|
);
|
|
166
103
|
|
|
167
|
-
/**
|
|
168
|
-
* Catch-all for everything else the shopper did. `type` is an open vocabulary
|
|
169
|
-
* on purpose — 'scroll_depth', 'wishlist', 'filter_applied', whatever the host
|
|
170
|
-
* already emits — so hosts do not have to map their events onto ours.
|
|
171
|
-
*/
|
|
172
104
|
export const interactionSchema = z.strictObject({
|
|
173
105
|
type: identifier(),
|
|
174
106
|
sku: optionalIdentifier(),
|
|
@@ -179,23 +111,20 @@ export const interactionSchema = z.strictObject({
|
|
|
179
111
|
});
|
|
180
112
|
export type Interaction = z.infer<typeof interactionSchema>;
|
|
181
113
|
|
|
182
|
-
/** Where on the site this component is being rendered. */
|
|
183
114
|
export const renderContextSchema = z.strictObject({
|
|
184
|
-
/** 'pdp', 'home', 'cart', 'search', or any host-defined surface. */
|
|
185
115
|
surface: identifier(),
|
|
186
|
-
|
|
116
|
+
|
|
187
117
|
slot: z.string().min(1).max(FIELD_LIMITS.identifier).default('recommendations'),
|
|
188
118
|
currentSku: optionalIdentifier(),
|
|
189
119
|
currentCategory: optionalIdentifier(),
|
|
190
120
|
searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
|
|
191
|
-
|
|
192
|
-
// the locale is part of the cohort cache key, so a list makes its own cohort.
|
|
121
|
+
|
|
193
122
|
locale: z
|
|
194
123
|
.string()
|
|
195
124
|
.max(FIELD_LIMITS.localeTag)
|
|
196
125
|
.regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, 'expected one language tag, such as en-US')
|
|
197
126
|
.default('en-US'),
|
|
198
|
-
|
|
127
|
+
|
|
199
128
|
maxItems: z.number().int().min(1).max(FIELD_LIMITS.maxItems).default(4),
|
|
200
129
|
});
|
|
201
130
|
export type RenderContext = z.infer<typeof renderContextSchema>;
|
|
@@ -214,7 +143,6 @@ export const trackingSignalsSchema = z.strictObject({
|
|
|
214
143
|
});
|
|
215
144
|
export type TrackingSignals = z.infer<typeof trackingSignalsSchema>;
|
|
216
145
|
|
|
217
|
-
/** A set the shop sells together. The model never picks or invents one. */
|
|
218
146
|
export const bundleSchema = z.strictObject({
|
|
219
147
|
id: identifier(),
|
|
220
148
|
skus: z
|
|
@@ -224,8 +152,7 @@ export const bundleSchema = z.strictObject({
|
|
|
224
152
|
.refine((skus) => new Set(skus).size === skus.length, {
|
|
225
153
|
message: 'a bundle must not list the same product twice',
|
|
226
154
|
}),
|
|
227
|
-
|
|
228
|
-
// which money it is in rather than borrowing it from a member.
|
|
155
|
+
|
|
229
156
|
price: z.number().nonnegative(),
|
|
230
157
|
currency: z
|
|
231
158
|
.string()
|
|
@@ -244,19 +171,9 @@ export const trackingInputSchema = z
|
|
|
244
171
|
isReturning: z.boolean().optional(),
|
|
245
172
|
}),
|
|
246
173
|
context: renderContextSchema,
|
|
247
|
-
|
|
248
|
-
// error. Every category defaults to empty, so a first-time visitor needs no
|
|
249
|
-
// special handling from the host.
|
|
174
|
+
|
|
250
175
|
signals: trackingSignalsSchema.prefault({}),
|
|
251
|
-
|
|
252
|
-
* The only products the generated component may place. Merchandising rules
|
|
253
|
-
* belong here: whatever the host leaves out cannot be recommended, which is
|
|
254
|
-
* what makes it impossible to surface a product that does not exist or is not
|
|
255
|
-
* merchandised for this shopper.
|
|
256
|
-
*
|
|
257
|
-
* SKUs must be unique — a duplicate is a host bug that spends prompt budget
|
|
258
|
-
* twice and invites the same product in two slots.
|
|
259
|
-
*/
|
|
176
|
+
|
|
260
177
|
candidates: z
|
|
261
178
|
.array(productSchema)
|
|
262
179
|
.min(1)
|
|
@@ -267,8 +184,7 @@ export const trackingInputSchema = z
|
|
|
267
184
|
message: 'candidates must have unique SKUs',
|
|
268
185
|
},
|
|
269
186
|
),
|
|
270
|
-
|
|
271
|
-
// duplicate would let it draw the wrong set at the wrong price.
|
|
187
|
+
|
|
272
188
|
bundles: z
|
|
273
189
|
.array(bundleSchema)
|
|
274
190
|
.max(FIELD_LIMITS.bundles)
|
|
@@ -292,24 +208,14 @@ export const trackingInputSchema = z
|
|
|
292
208
|
|
|
293
209
|
export type TrackingInput = z.infer<typeof trackingInputSchema>;
|
|
294
210
|
|
|
295
|
-
/** The shape a caller passes in, before defaults are applied. */
|
|
296
211
|
export type TrackingInputDraft = z.input<typeof trackingInputSchema>;
|
|
297
212
|
|
|
298
|
-
/**
|
|
299
|
-
* The result of a non-throwing parse. Exported so a consumer can type a
|
|
300
|
-
* validation failure without depending on zod directly.
|
|
301
|
-
*/
|
|
302
213
|
export type TrackingInputResult = z.ZodSafeParseResult<TrackingInput>;
|
|
303
214
|
|
|
304
|
-
/**
|
|
305
|
-
* Validates a payload, throwing a `ZodError` if it does not satisfy the
|
|
306
|
-
* contract. An invalid payload is a caller bug, and it should be loud.
|
|
307
|
-
*/
|
|
308
215
|
export function parseTrackingInput(value: unknown): TrackingInput {
|
|
309
216
|
return trackingInputSchema.parse(value);
|
|
310
217
|
}
|
|
311
218
|
|
|
312
|
-
/** Non-throwing variant, for callers that want to inspect the failure. */
|
|
313
219
|
export function safeParseTrackingInput(value: unknown): TrackingInputResult {
|
|
314
220
|
return trackingInputSchema.safeParse(value);
|
|
315
221
|
}
|