@rudra-js/core 0.1.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/component-generator.d.ts +84 -0
- package/dist/component-generator.d.ts.map +1 -0
- package/dist/component-generator.js +331 -0
- package/dist/component-generator.js.map +1 -0
- package/dist/component-spec.d.ts +426 -0
- package/dist/component-spec.d.ts.map +1 -0
- package/dist/component-spec.js +170 -0
- package/dist/component-spec.js.map +1 -0
- package/dist/fallback-component.d.ts +11 -0
- package/dist/fallback-component.d.ts.map +1 -0
- package/dist/fallback-component.js +69 -0
- package/dist/fallback-component.js.map +1 -0
- package/dist/fit-to-shopper.d.ts +4 -0
- package/dist/fit-to-shopper.d.ts.map +1 -0
- package/dist/fit-to-shopper.js +36 -0
- package/dist/fit-to-shopper.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/model-prompt.d.ts +29 -0
- package/dist/model-prompt.d.ts.map +1 -0
- package/dist/model-prompt.js +227 -0
- package/dist/model-prompt.js.map +1 -0
- package/dist/product-selection.d.ts +33 -0
- package/dist/product-selection.d.ts.map +1 -0
- package/dist/product-selection.js +102 -0
- package/dist/product-selection.js.map +1 -0
- package/dist/provider.d.ts +80 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +23 -0
- package/dist/provider.js.map +1 -0
- package/dist/reconciliation.d.ts +49 -0
- package/dist/reconciliation.d.ts.map +1 -0
- package/dist/reconciliation.js +564 -0
- package/dist/reconciliation.js.map +1 -0
- package/dist/signal-digest.d.ts +66 -0
- package/dist/signal-digest.d.ts.map +1 -0
- package/dist/signal-digest.js +224 -0
- package/dist/signal-digest.js.map +1 -0
- package/dist/spec-cache.d.ts +88 -0
- package/dist/spec-cache.d.ts.map +1 -0
- package/dist/spec-cache.js +152 -0
- package/dist/spec-cache.js.map +1 -0
- package/dist/tracking-input.d.ts +258 -0
- package/dist/tracking-input.d.ts.map +1 -0
- package/dist/tracking-input.js +241 -0
- package/dist/tracking-input.js.map +1 -0
- package/package.json +60 -0
- package/src/component-generator.ts +521 -0
- package/src/component-spec.ts +243 -0
- package/src/fallback-component.ts +77 -0
- package/src/fit-to-shopper.ts +45 -0
- package/src/index.ts +102 -0
- package/src/model-prompt.ts +258 -0
- package/src/product-selection.ts +153 -0
- package/src/provider.ts +98 -0
- package/src/reconciliation.ts +675 -0
- package/src/signal-digest.ts +335 -0
- package/src/spec-cache.ts +223 -0
- package/src/tracking-input.ts +300 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
export const FIELD_LIMITS = {
|
|
35
|
+
identifier: 128,
|
|
36
|
+
shortText: 200,
|
|
37
|
+
searchQuery: 200,
|
|
38
|
+
tag: 64,
|
|
39
|
+
tagsPerProduct: 20,
|
|
40
|
+
metaEntries: 50,
|
|
41
|
+
signalsPerCategory: 500,
|
|
42
|
+
candidates: 200,
|
|
43
|
+
productsPerBundle: 5,
|
|
44
|
+
bundles: 20,
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** Assigning this as an object key mutates the prototype instead of the object. */
|
|
48
|
+
const RESERVED_META_KEY = '__proto__';
|
|
49
|
+
|
|
50
|
+
/** Upper bound on any timestamp: 2100-01-01. */
|
|
51
|
+
const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
|
|
52
|
+
|
|
53
|
+
const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
|
|
54
|
+
const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).optional();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Epoch milliseconds, used only for recency ordering. Bounded because a
|
|
58
|
+
* negative or year-3000 timestamp does not fail anywhere downstream — it just
|
|
59
|
+
* sorts to one end and silently reorders the shopper's history.
|
|
60
|
+
*/
|
|
61
|
+
const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An absolute http(s) URL, or a root-relative path such as `/images/tr-102.png`.
|
|
65
|
+
* Anything else — a bare word, a `javascript:` URI, a protocol-relative `//host`
|
|
66
|
+
* — is rejected before it can reach an `<img src>`.
|
|
67
|
+
*/
|
|
68
|
+
const imageReference = () =>
|
|
69
|
+
z
|
|
70
|
+
.string()
|
|
71
|
+
.max(FIELD_LIMITS.shortText)
|
|
72
|
+
.refine(
|
|
73
|
+
(value) =>
|
|
74
|
+
value.startsWith('/') && !value.startsWith('//')
|
|
75
|
+
? true
|
|
76
|
+
: z.url({ protocol: /^https?$/ }).safeParse(value).success,
|
|
77
|
+
{ message: 'expected an http(s) URL or a root-relative path' },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** A product the generated component is permitted to place. */
|
|
81
|
+
export const productSchema = z.strictObject({
|
|
82
|
+
sku: identifier(),
|
|
83
|
+
title: z.string().min(1).max(FIELD_LIMITS.shortText),
|
|
84
|
+
category: identifier(),
|
|
85
|
+
price: z.number().nonnegative(),
|
|
86
|
+
currency: z
|
|
87
|
+
.string()
|
|
88
|
+
.regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
|
|
89
|
+
.default('USD'),
|
|
90
|
+
// This lands in an `<img src>` the host did not write, so the scheme matters:
|
|
91
|
+
// a bare capped string would accept '' and 'javascript:'. Root-relative paths
|
|
92
|
+
// are allowed because most catalogs store images that way, and they carry no
|
|
93
|
+
// scheme to abuse.
|
|
94
|
+
imageUrl: imageReference().optional(),
|
|
95
|
+
rating: z.number().min(0).max(5).optional(),
|
|
96
|
+
isInStock: z.boolean().default(true),
|
|
97
|
+
tags: z
|
|
98
|
+
.array(z.string().min(1).max(FIELD_LIMITS.tag))
|
|
99
|
+
.max(FIELD_LIMITS.tagsPerProduct)
|
|
100
|
+
.default([]),
|
|
101
|
+
});
|
|
102
|
+
export type Product = z.infer<typeof productSchema>;
|
|
103
|
+
|
|
104
|
+
/** Base shape for any signal that points at a single SKU. */
|
|
105
|
+
export const skuSignalSchema = z.strictObject({
|
|
106
|
+
sku: identifier(),
|
|
107
|
+
category: optionalIdentifier(),
|
|
108
|
+
at: epochMs(),
|
|
109
|
+
/** Caller-supplied strength, 0..1. Defaults to 1 when absent. */
|
|
110
|
+
weight: z.number().min(0).max(1).optional(),
|
|
111
|
+
});
|
|
112
|
+
export type SkuSignal = z.infer<typeof skuSignalSchema>;
|
|
113
|
+
|
|
114
|
+
export const viewSignalSchema = skuSignalSchema.extend({
|
|
115
|
+
views: z.number().int().positive().default(1),
|
|
116
|
+
dwellMs: z.number().nonnegative().optional(),
|
|
117
|
+
});
|
|
118
|
+
export type ViewSignal = z.infer<typeof viewSignalSchema>;
|
|
119
|
+
|
|
120
|
+
export const purchaseSignalSchema = skuSignalSchema.extend({
|
|
121
|
+
quantity: z.number().int().positive().default(1),
|
|
122
|
+
price: z.number().nonnegative().optional(),
|
|
123
|
+
});
|
|
124
|
+
export type PurchaseSignal = z.infer<typeof purchaseSignalSchema>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `meta` is the one dynamic shape in the contract, so its key is checked before
|
|
128
|
+
* the record is parsed rather than after. Zod builds its result by assigning
|
|
129
|
+
* each key, and assigning `__proto__` sets the prototype instead of adding a
|
|
130
|
+
* key — so the entry would vanish from the parsed output with no error, which
|
|
131
|
+
* is the silent drop this module exists to prevent.
|
|
132
|
+
*
|
|
133
|
+
* `__proto__` is the only key that behaves this way. `constructor` and
|
|
134
|
+
* `prototype` are ordinary own properties: assigning either shadows it on that
|
|
135
|
+
* one object and leaves the prototype alone, so both survive a parse intact and
|
|
136
|
+
* are accepted. `meta` is a host-defined vocabulary, and a shop with a facet,
|
|
137
|
+
* filter, or CMS field by either name should get a component, not a throw.
|
|
138
|
+
*/
|
|
139
|
+
const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
|
|
140
|
+
(value) =>
|
|
141
|
+
typeof value === 'object' && value !== null && !Object.hasOwn(value, RESERVED_META_KEY),
|
|
142
|
+
{ message: `meta may not use the reserved key ${RESERVED_META_KEY}` },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// `z.record` bounds key and value shape but not how many entries a record may
|
|
146
|
+
// carry, so the count is checked separately.
|
|
147
|
+
const metaSchema = metaKeysAreSafe.pipe(
|
|
148
|
+
z
|
|
149
|
+
.record(
|
|
150
|
+
z.string().min(1).max(FIELD_LIMITS.identifier),
|
|
151
|
+
z.union([z.string().max(FIELD_LIMITS.shortText), z.number(), z.boolean()]),
|
|
152
|
+
)
|
|
153
|
+
.refine((entries) => Object.keys(entries).length <= FIELD_LIMITS.metaEntries, {
|
|
154
|
+
message: `meta may carry at most ${FIELD_LIMITS.metaEntries} entries`,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Catch-all for everything else the shopper did. `type` is an open vocabulary
|
|
160
|
+
* on purpose — 'scroll_depth', 'wishlist', 'filter_applied', whatever the host
|
|
161
|
+
* already emits — so hosts do not have to map their events onto ours.
|
|
162
|
+
*/
|
|
163
|
+
export const interactionSchema = z.strictObject({
|
|
164
|
+
type: identifier(),
|
|
165
|
+
sku: optionalIdentifier(),
|
|
166
|
+
category: optionalIdentifier(),
|
|
167
|
+
at: epochMs(),
|
|
168
|
+
value: z.union([z.string().max(FIELD_LIMITS.shortText), z.number(), z.boolean()]).optional(),
|
|
169
|
+
meta: metaSchema.optional(),
|
|
170
|
+
});
|
|
171
|
+
export type Interaction = z.infer<typeof interactionSchema>;
|
|
172
|
+
|
|
173
|
+
/** Where on the site this component is being rendered. */
|
|
174
|
+
export const renderContextSchema = z.strictObject({
|
|
175
|
+
/** 'pdp', 'home', 'cart', 'search', or any host-defined surface. */
|
|
176
|
+
surface: identifier(),
|
|
177
|
+
/** Named placement, e.g. 'below-fold-recommendations'. */
|
|
178
|
+
slot: z.string().min(1).max(FIELD_LIMITS.identifier).default('recommendations'),
|
|
179
|
+
currentSku: optionalIdentifier(),
|
|
180
|
+
currentCategory: optionalIdentifier(),
|
|
181
|
+
searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
|
|
182
|
+
locale: z.string().min(2).max(35).default('en-US'),
|
|
183
|
+
/** Upper bound on products across the whole generated component. */
|
|
184
|
+
maxItems: z.number().int().min(1).max(12).default(4),
|
|
185
|
+
});
|
|
186
|
+
export type RenderContext = z.infer<typeof renderContextSchema>;
|
|
187
|
+
|
|
188
|
+
const signalArray = <Schema extends z.ZodType>(schema: Schema) =>
|
|
189
|
+
z.array(schema).max(FIELD_LIMITS.signalsPerCategory).default([]);
|
|
190
|
+
|
|
191
|
+
export const trackingSignalsSchema = z.strictObject({
|
|
192
|
+
likes: signalArray(skuSignalSchema),
|
|
193
|
+
dislikes: signalArray(skuSignalSchema),
|
|
194
|
+
mostViewed: signalArray(viewSignalSchema),
|
|
195
|
+
lastPurchased: signalArray(purchaseSignalSchema),
|
|
196
|
+
cart: signalArray(skuSignalSchema),
|
|
197
|
+
recentSearches: signalArray(z.string().min(1).max(FIELD_LIMITS.searchQuery)),
|
|
198
|
+
interactions: signalArray(interactionSchema),
|
|
199
|
+
});
|
|
200
|
+
export type TrackingSignals = z.infer<typeof trackingSignalsSchema>;
|
|
201
|
+
|
|
202
|
+
/** A set the shop sells together. The model never picks or invents one. */
|
|
203
|
+
export const bundleSchema = z.strictObject({
|
|
204
|
+
id: identifier(),
|
|
205
|
+
skus: z
|
|
206
|
+
.array(identifier())
|
|
207
|
+
.min(2)
|
|
208
|
+
.max(FIELD_LIMITS.productsPerBundle)
|
|
209
|
+
.refine((skus) => new Set(skus).size === skus.length, {
|
|
210
|
+
message: 'a bundle must not list the same product twice',
|
|
211
|
+
}),
|
|
212
|
+
// The shop's price for the set — not derived from the parts, so the set says
|
|
213
|
+
// which money it is in rather than borrowing it from a member.
|
|
214
|
+
price: z.number().nonnegative(),
|
|
215
|
+
currency: z
|
|
216
|
+
.string()
|
|
217
|
+
.regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
|
|
218
|
+
.default('USD'),
|
|
219
|
+
label: z.string().min(1).max(FIELD_LIMITS.shortText).optional(),
|
|
220
|
+
});
|
|
221
|
+
export type Bundle = z.infer<typeof bundleSchema>;
|
|
222
|
+
|
|
223
|
+
export const trackingInputSchema = z
|
|
224
|
+
.strictObject({
|
|
225
|
+
schemaVersion: z.literal('1').default('1'),
|
|
226
|
+
user: z.strictObject({
|
|
227
|
+
id: identifier(),
|
|
228
|
+
segment: optionalIdentifier(),
|
|
229
|
+
isReturning: z.boolean().optional(),
|
|
230
|
+
}),
|
|
231
|
+
context: renderContextSchema,
|
|
232
|
+
// A payload with no `signals` block at all is the cold-start case, not an
|
|
233
|
+
// error. Every category defaults to empty, so a first-time visitor needs no
|
|
234
|
+
// special handling from the host.
|
|
235
|
+
signals: trackingSignalsSchema.prefault({}),
|
|
236
|
+
/**
|
|
237
|
+
* The only products the generated component may place. Merchandising rules
|
|
238
|
+
* belong here: whatever the host leaves out cannot be recommended, which is
|
|
239
|
+
* what makes it impossible to surface a product that does not exist or is not
|
|
240
|
+
* merchandised for this shopper.
|
|
241
|
+
*
|
|
242
|
+
* SKUs must be unique — a duplicate is a host bug that spends prompt budget
|
|
243
|
+
* twice and invites the same product in two slots.
|
|
244
|
+
*/
|
|
245
|
+
candidates: z
|
|
246
|
+
.array(productSchema)
|
|
247
|
+
.min(1)
|
|
248
|
+
.max(FIELD_LIMITS.candidates)
|
|
249
|
+
.refine(
|
|
250
|
+
(products) => new Set(products.map((product) => product.sku)).size === products.length,
|
|
251
|
+
{
|
|
252
|
+
message: 'candidates must have unique SKUs',
|
|
253
|
+
},
|
|
254
|
+
),
|
|
255
|
+
// Ids must be unique — the renderer looks a bundle up by id alone, so a
|
|
256
|
+
// duplicate would let it draw the wrong set at the wrong price.
|
|
257
|
+
bundles: z
|
|
258
|
+
.array(bundleSchema)
|
|
259
|
+
.max(FIELD_LIMITS.bundles)
|
|
260
|
+
.refine((bundles) => new Set(bundles.map((bundle) => bundle.id)).size === bundles.length, {
|
|
261
|
+
message: 'bundles must have unique ids',
|
|
262
|
+
})
|
|
263
|
+
.default([]),
|
|
264
|
+
})
|
|
265
|
+
.refine(
|
|
266
|
+
(input) => {
|
|
267
|
+
const candidateSkus = new Set(input.candidates.map((product) => product.sku));
|
|
268
|
+
for (const bundle of input.bundles) {
|
|
269
|
+
for (const sku of bundle.skus) {
|
|
270
|
+
if (!candidateSkus.has(sku)) return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
},
|
|
275
|
+
{ message: 'every product in a bundle must also be a candidate' },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
export type TrackingInput = z.infer<typeof trackingInputSchema>;
|
|
279
|
+
|
|
280
|
+
/** The shape a caller passes in, before defaults are applied. */
|
|
281
|
+
export type TrackingInputDraft = z.input<typeof trackingInputSchema>;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The result of a non-throwing parse. Exported so a consumer can type a
|
|
285
|
+
* validation failure without depending on zod directly.
|
|
286
|
+
*/
|
|
287
|
+
export type TrackingInputResult = z.ZodSafeParseResult<TrackingInput>;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Validates a payload, throwing a `ZodError` if it does not satisfy the
|
|
291
|
+
* contract. An invalid payload is a caller bug, and it should be loud.
|
|
292
|
+
*/
|
|
293
|
+
export function parseTrackingInput(value: unknown): TrackingInput {
|
|
294
|
+
return trackingInputSchema.parse(value);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Non-throwing variant, for callers that want to inspect the failure. */
|
|
298
|
+
export function safeParseTrackingInput(value: unknown): TrackingInputResult {
|
|
299
|
+
return trackingInputSchema.safeParse(value);
|
|
300
|
+
}
|