@rudra-js/core 0.4.0 → 0.6.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 +139 -15
- package/dist/claim-screening.d.ts +33 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +226 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +33 -98
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -49
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -17
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +3 -0
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +3 -13
- 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 +6 -4
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +13 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +53 -21
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +1 -9
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -4
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +1 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +1 -8
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +15 -12
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +150 -340
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +10 -0
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +38 -28
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +14 -2
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +14 -24
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +15 -13
- package/dist/tracking-input.js.map +1 -1
- package/package.json +4 -2
- package/src/claim-screening.ts +242 -0
- package/src/component-generator.ts +38 -123
- package/src/component-spec.ts +5 -18
- package/src/fallback-component.ts +3 -14
- package/src/fit-to-shopper.ts +11 -4
- package/src/index.ts +1 -0
- package/src/model-prompt.ts +58 -23
- package/src/product-selection.ts +2 -13
- package/src/provider.ts +1 -8
- package/src/reconciliation.ts +178 -359
- package/src/signal-digest.ts +41 -31
- package/src/spec-cache.ts +27 -24
- package/src/tracking-input.ts +16 -13
package/src/signal-digest.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Interaction,
|
|
3
3
|
Product,
|
|
4
|
-
PurchaseSignal,
|
|
5
4
|
SkuSignal,
|
|
6
5
|
TrackingInput,
|
|
7
6
|
ViewSignal,
|
|
@@ -78,11 +77,7 @@ export const DIGEST_LIMITS = {
|
|
|
78
77
|
interactionTypes: 8,
|
|
79
78
|
} as const;
|
|
80
79
|
|
|
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
|
-
*/
|
|
80
|
+
/** How much intent each kind of signal implies. */
|
|
86
81
|
const SIGNAL_WEIGHTS = {
|
|
87
82
|
purchase: 5,
|
|
88
83
|
like: 4,
|
|
@@ -107,7 +102,6 @@ function byMostRecent(
|
|
|
107
102
|
return (right.at ?? 0) - (left.at ?? 0);
|
|
108
103
|
}
|
|
109
104
|
|
|
110
|
-
/** The most recent `limit` SKUs, each appearing once. */
|
|
111
105
|
function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
|
|
112
106
|
const skus: string[] = [];
|
|
113
107
|
const alreadySeen = new Set<string>();
|
|
@@ -122,11 +116,6 @@ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
|
|
|
122
116
|
return skus;
|
|
123
117
|
}
|
|
124
118
|
|
|
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
119
|
function categoryOf(
|
|
131
120
|
signal: { sku: string; category?: string | undefined },
|
|
132
121
|
candidatesBySku: Map<string, Product>,
|
|
@@ -173,22 +162,50 @@ function computeCategoryAffinity(
|
|
|
173
162
|
);
|
|
174
163
|
}
|
|
175
164
|
|
|
176
|
-
// The category the shopper is standing in right now is itself evidence
|
|
165
|
+
// The category the shopper is standing in right now is itself evidence — for
|
|
166
|
+
// ranking. It is not evidence that they like it: see `engagedCategories`.
|
|
177
167
|
addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
|
|
178
168
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
169
|
+
const affinities: CategoryAffinity[] = [];
|
|
170
|
+
for (const [category, score] of scoreByCategory) {
|
|
171
|
+
const rounded = Math.round(score * 100) / 100;
|
|
172
|
+
if (rounded > 0) affinities.push({ category, score: rounded });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return affinities
|
|
185
176
|
.toSorted((left, right) => right.score - left.score)
|
|
186
177
|
.slice(0, DIGEST_LIMITS.affinity);
|
|
187
178
|
}
|
|
188
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Categories this shopper did something in — bought, liked, carted or viewed.
|
|
182
|
+
*
|
|
183
|
+
* Not the same thing as `categoryAffinity`, which also scores the category the
|
|
184
|
+
* page itself is in. That scoring is right for ranking and wrong as evidence:
|
|
185
|
+
* a first-time visitor standing on a tent page has a tent affinity and has
|
|
186
|
+
* never touched a tent, so a sentence claiming they keep coming back to tents
|
|
187
|
+
* is checked against this instead.
|
|
188
|
+
*/
|
|
189
|
+
export function engagedCategories(input: TrackingInput): Set<string> {
|
|
190
|
+
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
191
|
+
const { signals } = input;
|
|
192
|
+
const categories = new Set<string>();
|
|
193
|
+
|
|
194
|
+
for (const signal of [
|
|
195
|
+
...signals.lastPurchased,
|
|
196
|
+
...signals.likes,
|
|
197
|
+
...signals.cart,
|
|
198
|
+
...signals.mostViewed,
|
|
199
|
+
]) {
|
|
200
|
+
const category = categoryOf(signal, candidatesBySku);
|
|
201
|
+
if (category) categories.add(category);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return categories;
|
|
205
|
+
}
|
|
206
|
+
|
|
189
207
|
interface MergedView extends ViewedProduct {
|
|
190
208
|
category?: string;
|
|
191
|
-
/** Always resolved, so no consumer has to re-apply the default. */
|
|
192
209
|
weight: number;
|
|
193
210
|
}
|
|
194
211
|
|
|
@@ -201,8 +218,6 @@ interface MergedView extends ViewedProduct {
|
|
|
201
218
|
* over, defeating the sub-linear scaling in `computeCategoryAffinity` entirely.
|
|
202
219
|
* Merging first makes the score depend on how much someone looked, not on how
|
|
203
220
|
* their tracking pipeline happens to batch.
|
|
204
|
-
*
|
|
205
|
-
* Where records disagree on `weight`, the strongest wins.
|
|
206
221
|
*/
|
|
207
222
|
function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
208
223
|
const totalsBySku = new Map<string, MergedView>();
|
|
@@ -228,7 +243,6 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
|
228
243
|
return [...totalsBySku.values()];
|
|
229
244
|
}
|
|
230
245
|
|
|
231
|
-
/** The most-viewed few, as the digest reports them. */
|
|
232
246
|
function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
|
|
233
247
|
const top = mergeViewsBySku(views)
|
|
234
248
|
.toSorted((left, right) => right.views - left.views)
|
|
@@ -243,7 +257,6 @@ function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
|
|
|
243
257
|
return products;
|
|
244
258
|
}
|
|
245
259
|
|
|
246
|
-
/** The open-vocabulary long tail, reduced to "which kinds, and how often". */
|
|
247
260
|
function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
|
|
248
261
|
const countByType = new Map<string, number>();
|
|
249
262
|
|
|
@@ -251,23 +264,20 @@ function countByInteractionType(interactions: Interaction[]): InteractionCount[]
|
|
|
251
264
|
countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
|
|
252
265
|
}
|
|
253
266
|
|
|
254
|
-
|
|
255
|
-
|
|
267
|
+
const counts = [...countByType.entries()].map(([type, count]) => ({ type, count }));
|
|
268
|
+
|
|
269
|
+
return counts
|
|
256
270
|
.toSorted((left, right) => right.count - left.count)
|
|
257
271
|
.slice(0, DIGEST_LIMITS.interactionTypes);
|
|
258
272
|
}
|
|
259
273
|
|
|
260
|
-
function recentPurchasedSkus(purchases: PurchaseSignal[]): string[] {
|
|
261
|
-
return recentUniqueSkus(purchases, DIGEST_LIMITS.purchased);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
274
|
export function buildDigest(input: TrackingInput): SignalDigest {
|
|
265
275
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
266
276
|
const { signals, context, user } = input;
|
|
267
277
|
|
|
268
278
|
const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
|
|
269
279
|
const dislikedSkus = recentUniqueSkus(signals.dislikes, DIGEST_LIMITS.disliked);
|
|
270
|
-
const purchasedSkus =
|
|
280
|
+
const purchasedSkus = recentUniqueSkus(signals.lastPurchased, DIGEST_LIMITS.purchased);
|
|
271
281
|
const cartSkus = recentUniqueSkus(signals.cart, DIGEST_LIMITS.cart);
|
|
272
282
|
const topViewed = mostViewedProducts(signals.mostViewed);
|
|
273
283
|
|
package/src/spec-cache.ts
CHANGED
|
@@ -75,12 +75,8 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
75
75
|
const ttlMs = options.ttlMs ?? 60_000;
|
|
76
76
|
const maxEntries = options.maxEntries ?? 10_000;
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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.
|
|
78
|
+
// Usually `Number(process.env.X)`: an unset variable makes these NaN, and every
|
|
79
|
+
// comparison against NaN is false, so nothing would ever expire or be evicted.
|
|
84
80
|
assertFiniteAtLeastZero('ttlMs', ttlMs);
|
|
85
81
|
assertFiniteAtLeastZero('maxEntries', maxEntries);
|
|
86
82
|
if (!Number.isSafeInteger(maxEntries)) {
|
|
@@ -100,8 +96,8 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
100
96
|
return undefined;
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
// Re-insert so insertion order tracks recency
|
|
104
|
-
//
|
|
99
|
+
// Re-insert so insertion order tracks recency, which makes the eviction below
|
|
100
|
+
// least-recently-used rather than oldest-written.
|
|
105
101
|
entries.delete(key);
|
|
106
102
|
entries.set(key, entry);
|
|
107
103
|
return entry.cached;
|
|
@@ -156,6 +152,19 @@ function canonicalise(value: unknown): string {
|
|
|
156
152
|
|
|
157
153
|
const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
|
|
158
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Who answered, as two named fields rather than one joined string.
|
|
157
|
+
*
|
|
158
|
+
* Joining them with a colon is ambiguous, and model ids carry colons — an
|
|
159
|
+
* `anthropic` / `claude:1` pair and an `anthropic:claude` / `1` pair flatten to
|
|
160
|
+
* the same text and read each other's entries out of a shared store. The
|
|
161
|
+
* provider is in the key precisely so that cannot happen.
|
|
162
|
+
*/
|
|
163
|
+
export interface ProviderIdentity {
|
|
164
|
+
name: string;
|
|
165
|
+
model: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
/**
|
|
160
169
|
* Derives the cache key.
|
|
161
170
|
*
|
|
@@ -184,14 +193,14 @@ const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('he
|
|
|
184
193
|
export function specCacheKey(
|
|
185
194
|
digest: SignalDigest,
|
|
186
195
|
candidateSkus: readonly string[],
|
|
187
|
-
|
|
196
|
+
provider: ProviderIdentity,
|
|
188
197
|
): string {
|
|
189
198
|
const material = canonicalise({
|
|
190
|
-
//
|
|
191
|
-
//
|
|
199
|
+
// So a shape change cannot read entries written by the previous shape out of
|
|
200
|
+
// a shared store that outlives a deploy.
|
|
192
201
|
specVersion: SPEC_VERSION,
|
|
193
202
|
prompt: PROMPT_FINGERPRINT,
|
|
194
|
-
provider
|
|
203
|
+
provider,
|
|
195
204
|
digest,
|
|
196
205
|
candidates: candidateSkus.toSorted(),
|
|
197
206
|
});
|
|
@@ -199,33 +208,27 @@ export function specCacheKey(
|
|
|
199
208
|
return createHash('sha256').update(material).digest('hex').slice(0, 32);
|
|
200
209
|
}
|
|
201
210
|
|
|
202
|
-
// Leaves out the fields
|
|
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.
|
|
211
|
+
// Leaves out the personal fields: who the shopper is, what they liked, viewed or searched.
|
|
206
212
|
export function cohortCacheKey(
|
|
207
213
|
digest: SignalDigest,
|
|
208
214
|
candidateSkus: readonly string[],
|
|
209
|
-
|
|
215
|
+
provider: ProviderIdentity,
|
|
210
216
|
): string {
|
|
211
217
|
const material = canonicalise({
|
|
212
218
|
specVersion: SPEC_VERSION,
|
|
213
219
|
prompt: PROMPT_FINGERPRINT,
|
|
214
|
-
provider
|
|
220
|
+
provider,
|
|
215
221
|
segment: digest.segment ?? null,
|
|
216
222
|
surface: digest.surface,
|
|
217
223
|
slot: digest.slot,
|
|
218
224
|
locale: digest.locale,
|
|
219
225
|
maxItems: digest.maxItems,
|
|
220
226
|
isColdStart: digest.isColdStart,
|
|
221
|
-
//
|
|
222
|
-
// served on a tent page.
|
|
227
|
+
// So copy written for a backpack page is not served on a tent page.
|
|
223
228
|
currentCategory: digest.currentCategory ?? null,
|
|
224
229
|
topCategory: digest.categoryAffinity[0]?.category ?? null,
|
|
225
|
-
// The model is shown
|
|
226
|
-
//
|
|
227
|
-
// candidates per shopper gets smaller cohorts, which is the honest result:
|
|
228
|
-
// its prompt really is personal.
|
|
230
|
+
// The model is shown products drawn from this list, so a different list must not reuse the
|
|
231
|
+
// copy. A shop that picks candidates per shopper gets smaller cohorts, not a wrong answer.
|
|
229
232
|
candidates: candidateSkus.toSorted(),
|
|
230
233
|
});
|
|
231
234
|
|
package/src/tracking-input.ts
CHANGED
|
@@ -52,7 +52,6 @@ export const FIELD_LIMITS = {
|
|
|
52
52
|
/** Assigning this as an object key mutates the prototype instead of the object. */
|
|
53
53
|
const RESERVED_META_KEY = '__proto__';
|
|
54
54
|
|
|
55
|
-
/** Upper bound on any timestamp: 2100-01-01. */
|
|
56
55
|
const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
|
|
57
56
|
|
|
58
57
|
const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
|
|
@@ -65,6 +64,20 @@ const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).
|
|
|
65
64
|
*/
|
|
66
65
|
const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
|
|
67
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Whether a path stays on the page's own origin.
|
|
69
|
+
*
|
|
70
|
+
* Read the way a browser reads it, not the way it looks. A browser drops tab,
|
|
71
|
+
* carriage return and newline from a URL before parsing it, and treats a
|
|
72
|
+
* backslash as a slash, so `/\evil.example/x.png` and `/<tab>//evil.example/x.png`
|
|
73
|
+
* both point at someone else's host while passing a plain "starts with one
|
|
74
|
+
* slash" test.
|
|
75
|
+
*/
|
|
76
|
+
const isRootRelative = (value: string) => {
|
|
77
|
+
const asParsed = value.replace(/[\t\n\r]/g, '');
|
|
78
|
+
return value.startsWith('/') && /^\/(?![/\\])/.test(asParsed);
|
|
79
|
+
};
|
|
80
|
+
|
|
68
81
|
/**
|
|
69
82
|
* An absolute http(s) URL, or a root-relative path such as `/images/tr-102.png`.
|
|
70
83
|
* Anything else — a bare word, a `javascript:` URI, a protocol-relative `//host`
|
|
@@ -76,9 +89,7 @@ const imageReference = () =>
|
|
|
76
89
|
.max(FIELD_LIMITS.shortText)
|
|
77
90
|
.refine(
|
|
78
91
|
(value) =>
|
|
79
|
-
value.
|
|
80
|
-
? true
|
|
81
|
-
: z.url({ protocol: /^https?$/ }).safeParse(value).success,
|
|
92
|
+
isRootRelative(value) ? true : z.url({ protocol: /^https?$/ }).safeParse(value).success,
|
|
82
93
|
{ message: 'expected an http(s) URL or a root-relative path' },
|
|
83
94
|
);
|
|
84
95
|
|
|
@@ -138,12 +149,6 @@ export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
|
|
|
138
149
|
* each key, and assigning `__proto__` sets the prototype instead of adding a
|
|
139
150
|
* key — so the entry would vanish from the parsed output with no error, which
|
|
140
151
|
* 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
152
|
*/
|
|
148
153
|
const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
|
|
149
154
|
(value) =>
|
|
@@ -244,9 +249,7 @@ export const trackingInputSchema = z
|
|
|
244
249
|
isReturning: z.boolean().optional(),
|
|
245
250
|
}),
|
|
246
251
|
context: renderContextSchema,
|
|
247
|
-
// A payload with no `signals` block at all is the cold-start case, not an
|
|
248
|
-
// error. Every category defaults to empty, so a first-time visitor needs no
|
|
249
|
-
// special handling from the host.
|
|
252
|
+
// A payload with no `signals` block at all is the cold-start case, not an error.
|
|
250
253
|
signals: trackingSignalsSchema.prefault({}),
|
|
251
254
|
/**
|
|
252
255
|
* The only products the generated component may place. Merchandising rules
|