@rudra-js/core 0.2.0 → 0.4.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 +186 -135
- package/dist/component-generator.d.ts +9 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +15 -9
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.js +1 -1
- 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 +3 -1
- package/dist/fit-to-shopper.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 +100 -10
- package/dist/reconciliation.js.map +1 -1
- package/dist/tracking-input.d.ts +3 -0
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +7 -0
- package/dist/tracking-input.js.map +1 -1
- package/package.json +1 -1
- package/src/component-generator.ts +25 -9
- package/src/component-spec.ts +1 -1
- package/src/fallback-component.ts +7 -3
- package/src/fit-to-shopper.ts +2 -0
- package/src/product-selection.ts +29 -5
- package/src/reconciliation.ts +120 -6
- package/src/tracking-input.ts +7 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracking-input.js","sourceRoot":"","sources":["../src/tracking-input.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;;GAeG;AAEH;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,UAAU,EAAE,GAAG;IACf,SAAS,EAAE,GAAG;IACd,WAAW,EAAE,GAAG;IAChB,GAAG,EAAE,EAAE;IACP,cAAc,EAAE,EAAE;IAClB,WAAW,EAAE,EAAE;IACf,kBAAkB,EAAE,GAAG;IACvB,UAAU,EAAE,GAAG;IACf,iBAAiB,EAAE,CAAC;IACpB,OAAO,EAAE,EAAE;IACX,SAAS,EAAE,EAAE;IACb,QAAQ,EAAE,EAAE;
|
|
1
|
+
{"version":3,"file":"tracking-input.js","sourceRoot":"","sources":["../src/tracking-input.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
placeableHeroSkus,
|
|
18
18
|
reconcileSpec,
|
|
19
19
|
} from './reconciliation.js';
|
|
20
|
-
import { selectProducts, type ProductPick } from './product-selection.js';
|
|
20
|
+
import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
|
|
21
21
|
import { fitToShopper } from './fit-to-shopper.js';
|
|
22
22
|
import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
|
|
23
23
|
import {
|
|
@@ -113,6 +113,14 @@ export interface ComponentGeneratorOptions {
|
|
|
113
113
|
* 'cohort'.
|
|
114
114
|
*/
|
|
115
115
|
generation?: 'cohort' | 'per-shopper';
|
|
116
|
+
/**
|
|
117
|
+
* How the products are ordered. 'signals' scores each candidate from this
|
|
118
|
+
* shopper's signals. 'given' keeps the order you sent, for a shop whose own
|
|
119
|
+
* ranking is better than four weights. Either way the exclusions and the
|
|
120
|
+
* stock check still apply, and each product still carries a basis
|
|
121
|
+
* reconciliation can verify. Defaults to 'signals'.
|
|
122
|
+
*/
|
|
123
|
+
rank?: RankOrder;
|
|
116
124
|
/** Observability. Never allowed to break a render. */
|
|
117
125
|
onEvent?: (event: GenerationEvent) => void;
|
|
118
126
|
}
|
|
@@ -242,8 +250,10 @@ function fitCohortSpec(
|
|
|
242
250
|
spec: GeneratedSpec,
|
|
243
251
|
input: TrackingInput,
|
|
244
252
|
digest: SignalDigest,
|
|
253
|
+
rank: RankOrder,
|
|
254
|
+
hostReasonSkus: Set<string>,
|
|
245
255
|
): GeneratedSpec {
|
|
246
|
-
const picks = selectProducts(input, digest);
|
|
256
|
+
const picks = selectProducts(input, digest, { rank });
|
|
247
257
|
// Blocks past the cap never render, so a set is not worth reserving for one.
|
|
248
258
|
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
249
259
|
|
|
@@ -256,26 +266,26 @@ function fitCohortSpec(
|
|
|
256
266
|
}
|
|
257
267
|
aboveBundle.push(block);
|
|
258
268
|
}
|
|
259
|
-
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
269
|
+
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
260
270
|
|
|
261
271
|
// Only the heroes above the bundle block are placed when it is reached, so
|
|
262
272
|
// they are all the choice may account for.
|
|
263
273
|
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
|
|
264
|
-
if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
|
|
274
|
+
if (!chosen) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
265
275
|
|
|
266
276
|
const spokenFor = new Set<string>(chosen.skus);
|
|
267
277
|
for (const sku of placeableHeroSkus(blocks, input, digest)) spokenFor.add(sku);
|
|
268
278
|
|
|
269
279
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
270
280
|
// A set is worth showing, but not at the cost of an empty grid.
|
|
271
|
-
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
281
|
+
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems, hostReasonSkus);
|
|
272
282
|
|
|
273
283
|
const forGrid: ProductPick[] = [];
|
|
274
284
|
for (const pick of picks) {
|
|
275
285
|
if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
|
|
276
286
|
}
|
|
277
287
|
|
|
278
|
-
return fitToShopper(spec, forGrid, roomLeft);
|
|
288
|
+
return fitToShopper(spec, forGrid, roomLeft, hostReasonSkus);
|
|
279
289
|
}
|
|
280
290
|
|
|
281
291
|
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
@@ -292,6 +302,7 @@ export function createComponentGenerator(
|
|
|
292
302
|
const provider = options.provider ?? null;
|
|
293
303
|
const cache = options.cache ?? createMemorySpecCache();
|
|
294
304
|
const generation = options.generation ?? 'cohort';
|
|
305
|
+
const rank = options.rank ?? 'signals';
|
|
295
306
|
const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
|
|
296
307
|
const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
|
|
297
308
|
const singleFlight = createSingleFlight<ModelCall>();
|
|
@@ -330,7 +341,7 @@ export function createComponentGenerator(
|
|
|
330
341
|
degradedReason,
|
|
331
342
|
});
|
|
332
343
|
|
|
333
|
-
return withProvenance(buildFallbackSpec(input, digest), {
|
|
344
|
+
return withProvenance(buildFallbackSpec(input, digest, { rank }), {
|
|
334
345
|
slot: digest.slot,
|
|
335
346
|
source: 'fallback',
|
|
336
347
|
generatedAt: finishedAt,
|
|
@@ -500,10 +511,15 @@ export function createComponentGenerator(
|
|
|
500
511
|
// One place where anything is served, whichever side of the cache it came
|
|
501
512
|
// from, and always against the facts of the shopper asking now.
|
|
502
513
|
// A cohort spec names products chosen for whoever asked first.
|
|
514
|
+
// Only the cohort path writes a host reason into a spec, so in
|
|
515
|
+
// per-shopper mode this stays empty and every reason is screened.
|
|
516
|
+
const hostReasonSkus = new Set<string>();
|
|
503
517
|
const served =
|
|
504
|
-
generation === 'cohort'
|
|
518
|
+
generation === 'cohort'
|
|
519
|
+
? fitCohortSpec(answer.spec, input, digest, rank, hostReasonSkus)
|
|
520
|
+
: answer.spec;
|
|
505
521
|
|
|
506
|
-
const reconciled = reconcileSpec(served, input, digest);
|
|
522
|
+
const reconciled = reconcileSpec(served, input, digest, hostReasonSkus);
|
|
507
523
|
if (!reconciled.isUsable) {
|
|
508
524
|
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|
|
509
525
|
calledModel,
|
package/src/component-spec.ts
CHANGED
|
@@ -92,7 +92,7 @@ export const productReferenceSchema = z.object({
|
|
|
92
92
|
* true, but the prose asserting that reason must not render.
|
|
93
93
|
*/
|
|
94
94
|
reason: z.string().nullable(),
|
|
95
|
-
/** Short accent label, e.g. "
|
|
95
|
+
/** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
|
|
96
96
|
badge: z.string().nullable(),
|
|
97
97
|
emphasis: z.enum(EMPHASIS),
|
|
98
98
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GeneratedSpec, ProductReference } from './component-spec.js';
|
|
2
2
|
import type { SignalDigest } from './signal-digest.js';
|
|
3
|
-
import { selectProducts, type ProductPick } from './product-selection.js';
|
|
3
|
+
import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
|
|
4
4
|
import type { TrackingInput } from './tracking-input.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -57,8 +57,12 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
|
|
|
57
57
|
* the renderer treats as "render nothing" — an empty recommendation region is
|
|
58
58
|
* worse than none.
|
|
59
59
|
*/
|
|
60
|
-
export function buildFallbackSpec(
|
|
61
|
-
|
|
60
|
+
export function buildFallbackSpec(
|
|
61
|
+
input: TrackingInput,
|
|
62
|
+
digest: SignalDigest,
|
|
63
|
+
options: SelectOptions = {},
|
|
64
|
+
): GeneratedSpec {
|
|
65
|
+
const picks = selectProducts(input, digest, options).slice(0, digest.maxItems);
|
|
62
66
|
const { headline, subheadline } = headlineFor(digest);
|
|
63
67
|
const items = picks.map((pick, index) => toProductReference(pick, index, picks.length));
|
|
64
68
|
|
package/src/fit-to-shopper.ts
CHANGED
|
@@ -7,6 +7,7 @@ export function fitToShopper(
|
|
|
7
7
|
spec: GeneratedSpec,
|
|
8
8
|
picks: readonly ProductPick[],
|
|
9
9
|
maxItems: number,
|
|
10
|
+
hostReasonSkus?: Set<string>,
|
|
10
11
|
): GeneratedSpec {
|
|
11
12
|
// Picks are ordered best first. Every slot takes the next one.
|
|
12
13
|
let next = 0;
|
|
@@ -24,6 +25,7 @@ export function fitToShopper(
|
|
|
24
25
|
for (const item of block.items) {
|
|
25
26
|
if (next >= limit) break; // shrink, never pad
|
|
26
27
|
const chosen = picks[next]!;
|
|
28
|
+
if (chosen.reasonFromHost) hostReasonSkus?.add(chosen.product.sku);
|
|
27
29
|
items.push({
|
|
28
30
|
sku: chosen.product.sku,
|
|
29
31
|
basis: chosen.basis,
|
package/src/product-selection.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface ProductPick {
|
|
|
23
23
|
basis: RecommendationBasis;
|
|
24
24
|
/** How the basis reads to a shopper. */
|
|
25
25
|
reason: string;
|
|
26
|
+
/** True when `reason` is the host's own sentence from the candidate. */
|
|
27
|
+
reasonFromHost: boolean;
|
|
26
28
|
/** Unnormalised. Only the ordering is meaningful. */
|
|
27
29
|
score: number;
|
|
28
30
|
}
|
|
@@ -95,9 +97,6 @@ function basisFor(
|
|
|
95
97
|
if (evidence.hasCart) {
|
|
96
98
|
return { basis: 'complements_cart', reason: 'Goes with what is in your cart' };
|
|
97
99
|
}
|
|
98
|
-
if ((product.rating ?? 0) >= 4.5) {
|
|
99
|
-
return { basis: 'popular', reason: 'Highly rated' };
|
|
100
|
-
}
|
|
101
100
|
return { basis: 'popular', reason: `Popular in ${product.category}` };
|
|
102
101
|
}
|
|
103
102
|
|
|
@@ -107,7 +106,24 @@ function basisFor(
|
|
|
107
106
|
* Ties break on SKU so the order is total: two runs over the same payload
|
|
108
107
|
* produce the same list, which is what makes the control arm reproducible.
|
|
109
108
|
*/
|
|
110
|
-
|
|
109
|
+
/** How the picks are ordered once the unplaceable ones are gone. */
|
|
110
|
+
export type RankOrder = 'signals' | 'given';
|
|
111
|
+
|
|
112
|
+
export interface SelectOptions {
|
|
113
|
+
/**
|
|
114
|
+
* `signals` scores each candidate and orders by that score. `given` keeps the
|
|
115
|
+
* order you sent, for a shop whose own ranking is better than four weights.
|
|
116
|
+
* Either way the exclusions and the stock check still apply, and every pick
|
|
117
|
+
* still carries a basis reconciliation can verify.
|
|
118
|
+
*/
|
|
119
|
+
rank?: RankOrder;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function selectProducts(
|
|
123
|
+
input: TrackingInput,
|
|
124
|
+
digest: SignalDigest,
|
|
125
|
+
options: SelectOptions = {},
|
|
126
|
+
): ProductPick[] {
|
|
111
127
|
const affinityByCategory = new Map(
|
|
112
128
|
digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
|
|
113
129
|
);
|
|
@@ -144,9 +160,17 @@ export function selectProducts(input: TrackingInput, digest: SignalDigest): Prod
|
|
|
144
160
|
hasCart,
|
|
145
161
|
});
|
|
146
162
|
|
|
147
|
-
picks.push({
|
|
163
|
+
picks.push({
|
|
164
|
+
product,
|
|
165
|
+
basis,
|
|
166
|
+
reason: product.reason ?? reason,
|
|
167
|
+
reasonFromHost: product.reason !== undefined,
|
|
168
|
+
score,
|
|
169
|
+
});
|
|
148
170
|
}
|
|
149
171
|
|
|
172
|
+
if (options.rank === 'given') return picks;
|
|
173
|
+
|
|
150
174
|
return picks.toSorted(
|
|
151
175
|
(left, right) => right.score - left.score || left.product.sku.localeCompare(right.product.sku),
|
|
152
176
|
);
|
package/src/reconciliation.ts
CHANGED
|
@@ -255,12 +255,95 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
255
255
|
},
|
|
256
256
|
];
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Letters that render as a Latin letter but are not one. A Cyrillic о in
|
|
260
|
+
* "in stоck" would otherwise carry a stock claim straight past the rule written
|
|
261
|
+
* for it, and the shopper would read the claim anyway.
|
|
262
|
+
*/
|
|
263
|
+
const CONFUSABLES: Record<string, string> = {
|
|
264
|
+
а: 'a',
|
|
265
|
+
в: 'b',
|
|
266
|
+
е: 'e',
|
|
267
|
+
к: 'k',
|
|
268
|
+
м: 'm',
|
|
269
|
+
н: 'h',
|
|
270
|
+
о: 'o',
|
|
271
|
+
р: 'p',
|
|
272
|
+
с: 'c',
|
|
273
|
+
т: 't',
|
|
274
|
+
х: 'x',
|
|
275
|
+
у: 'y',
|
|
276
|
+
ѕ: 's',
|
|
277
|
+
і: 'i',
|
|
278
|
+
ј: 'j',
|
|
279
|
+
ԁ: 'd',
|
|
280
|
+
һ: 'h',
|
|
281
|
+
ӏ: 'l',
|
|
282
|
+
α: 'a',
|
|
283
|
+
ε: 'e',
|
|
284
|
+
ι: 'i',
|
|
285
|
+
κ: 'k',
|
|
286
|
+
ν: 'v',
|
|
287
|
+
ο: 'o',
|
|
288
|
+
ρ: 'p',
|
|
289
|
+
τ: 't',
|
|
290
|
+
υ: 'u',
|
|
291
|
+
χ: 'x',
|
|
292
|
+
ϲ: 'c',
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Characters that render as nothing: format characters, default-ignorable code
|
|
297
|
+
* points and the controls. Cf and Default_Ignorable each hold characters the
|
|
298
|
+
* other does not.
|
|
299
|
+
*
|
|
300
|
+
* The same class @rudra-js/attested strips in its own `hidden.ts`. A second copy,
|
|
301
|
+
* because core carries no dependencies and is not about to take one — which means
|
|
302
|
+
* widening one and not the other is the mistake to watch for.
|
|
303
|
+
*/
|
|
304
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
|
|
308
|
+
*
|
|
309
|
+
* No test pins this line, and none can: clamping collapses every run of whitespace
|
|
310
|
+
* before a claim is screened, so nothing here ever sees one. It is kept so the class
|
|
311
|
+
* reads the same in both copies.
|
|
312
|
+
*/
|
|
313
|
+
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Marks that hang on the character before them instead of taking a column of their
|
|
317
|
+
* own. Dropped after composition, so an accented e keeps its accent while an
|
|
318
|
+
* overline, which composes with nothing, cannot hide a word from its own rule.
|
|
319
|
+
*/
|
|
320
|
+
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* One shape for the text before the patterns read it, so a word that renders as
|
|
324
|
+
* "in stock" is read as "in stock" however it was spelled.
|
|
325
|
+
*
|
|
326
|
+
* Only the reading is normalised. What renders is what the model wrote.
|
|
327
|
+
*/
|
|
328
|
+
function normaliseForClaims(text: string): string {
|
|
329
|
+
const lowered = text
|
|
330
|
+
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
331
|
+
.normalize('NFKC')
|
|
332
|
+
.toLowerCase()
|
|
333
|
+
.normalize('NFC')
|
|
334
|
+
.replace(MARKS, '');
|
|
335
|
+
|
|
336
|
+
let folded = '';
|
|
337
|
+
for (const char of lowered) folded += CONFUSABLES[char] ?? char;
|
|
338
|
+
return folded;
|
|
339
|
+
}
|
|
340
|
+
|
|
258
341
|
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
259
342
|
function claimIn(text: string): string | null {
|
|
260
|
-
const
|
|
343
|
+
const normalised = normaliseForClaims(text);
|
|
261
344
|
for (const claim of CLAIM_PATTERNS) {
|
|
262
345
|
for (const pattern of claim.patterns) {
|
|
263
|
-
if (pattern.test(
|
|
346
|
+
if (pattern.test(normalised)) return claim.kind;
|
|
264
347
|
}
|
|
265
348
|
}
|
|
266
349
|
return null;
|
|
@@ -356,6 +439,7 @@ function reconcileItems(
|
|
|
356
439
|
candidatesBySku: Map<string, Product>,
|
|
357
440
|
digest: SignalDigest,
|
|
358
441
|
tracker: PlacementTracker,
|
|
442
|
+
hostReasonSkus: ReadonlySet<string>,
|
|
359
443
|
): ProductReference[] {
|
|
360
444
|
const kept: ProductReference[] = [];
|
|
361
445
|
|
|
@@ -375,15 +459,23 @@ function reconcileItems(
|
|
|
375
459
|
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
376
460
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
377
461
|
|
|
462
|
+
const isHostReason =
|
|
463
|
+
item.reason !== null && hostReasonSkus.has(item.sku) && item.reason === product.reason;
|
|
464
|
+
|
|
378
465
|
kept.push({
|
|
379
466
|
sku: item.sku,
|
|
380
467
|
basis: hasSupportedBasis ? item.basis : 'popular',
|
|
381
468
|
// The prose exists to state the basis. If the basis did not hold, the
|
|
382
469
|
// prose is a claim we just decided is untrue.
|
|
383
470
|
reason: hasSupportedBasis
|
|
384
|
-
?
|
|
471
|
+
? isHostReason
|
|
472
|
+
? clampNullable(item.reason, CLAMP.reason)
|
|
473
|
+
: screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
|
|
385
474
|
: null,
|
|
386
|
-
badge
|
|
475
|
+
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
476
|
+
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
477
|
+
// read for claims like every other sentence the model writes.
|
|
478
|
+
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
|
|
387
479
|
emphasis: item.emphasis,
|
|
388
480
|
});
|
|
389
481
|
}
|
|
@@ -516,6 +608,7 @@ function reconcileBlock(
|
|
|
516
608
|
digest: SignalDigest,
|
|
517
609
|
bundles: readonly Bundle[],
|
|
518
610
|
tracker: PlacementTracker,
|
|
611
|
+
hostReasonSkus: ReadonlySet<string>,
|
|
519
612
|
): Block | null {
|
|
520
613
|
switch (block.kind) {
|
|
521
614
|
case 'hero': {
|
|
@@ -552,7 +645,14 @@ function reconcileBlock(
|
|
|
552
645
|
}
|
|
553
646
|
|
|
554
647
|
case 'grid': {
|
|
555
|
-
const items = reconcileItems(
|
|
648
|
+
const items = reconcileItems(
|
|
649
|
+
block.items,
|
|
650
|
+
allowlist,
|
|
651
|
+
candidatesBySku,
|
|
652
|
+
digest,
|
|
653
|
+
tracker,
|
|
654
|
+
hostReasonSkus,
|
|
655
|
+
);
|
|
556
656
|
if (items.length === 0) {
|
|
557
657
|
tracker.record('empty-block:grid');
|
|
558
658
|
return null;
|
|
@@ -567,7 +667,14 @@ function reconcileBlock(
|
|
|
567
667
|
}
|
|
568
668
|
|
|
569
669
|
case 'carousel': {
|
|
570
|
-
const items = reconcileItems(
|
|
670
|
+
const items = reconcileItems(
|
|
671
|
+
block.items,
|
|
672
|
+
allowlist,
|
|
673
|
+
candidatesBySku,
|
|
674
|
+
digest,
|
|
675
|
+
tracker,
|
|
676
|
+
hostReasonSkus,
|
|
677
|
+
);
|
|
571
678
|
if (items.length === 0) {
|
|
572
679
|
tracker.record('empty-block:carousel');
|
|
573
680
|
return null;
|
|
@@ -643,6 +750,12 @@ export function reconcileSpec(
|
|
|
643
750
|
generated: GeneratedSpec,
|
|
644
751
|
input: TrackingInput,
|
|
645
752
|
digest: SignalDigest,
|
|
753
|
+
/**
|
|
754
|
+
* SKUs whose reason this request wrote from the host's own candidate. Only
|
|
755
|
+
* `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
756
|
+
* reason is the model's, including one that happens to read the same.
|
|
757
|
+
*/
|
|
758
|
+
hostReasonSkus: ReadonlySet<string> = new Set(),
|
|
646
759
|
): ReconcileResult {
|
|
647
760
|
const allowlist = buildAllowlist(input, digest);
|
|
648
761
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
@@ -661,6 +774,7 @@ export function reconcileSpec(
|
|
|
661
774
|
digest,
|
|
662
775
|
input.bundles,
|
|
663
776
|
tracker,
|
|
777
|
+
hostReasonSkus,
|
|
664
778
|
);
|
|
665
779
|
if (reconciled !== null) blocks.push(reconciled);
|
|
666
780
|
}
|
package/src/tracking-input.ts
CHANGED
|
@@ -44,6 +44,9 @@ export const FIELD_LIMITS = {
|
|
|
44
44
|
bundles: 20,
|
|
45
45
|
localeTag: 35,
|
|
46
46
|
maxItems: 12,
|
|
47
|
+
// Matches CLAMP.reason in reconciliation: a host reason is rendered in the
|
|
48
|
+
// same place a model's is, so the same length has to hold.
|
|
49
|
+
reason: 120,
|
|
47
50
|
} as const;
|
|
48
51
|
|
|
49
52
|
/** Assigning this as an object key mutates the prototype instead of the object. */
|
|
@@ -95,6 +98,10 @@ export const productSchema = z.strictObject({
|
|
|
95
98
|
// scheme to abuse.
|
|
96
99
|
imageUrl: imageReference().optional(),
|
|
97
100
|
rating: z.number().min(0).max(5).optional(),
|
|
101
|
+
// Your phrase for why this product is here, when your own ranking already has
|
|
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.
|
|
104
|
+
reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
|
|
98
105
|
isInStock: z.boolean().default(true),
|
|
99
106
|
tags: z
|
|
100
107
|
.array(z.string().min(1).max(FIELD_LIMITS.tag))
|