@rudra-js/core 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -73
- package/dist/component-generator.d.ts +20 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +29 -15
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +7 -7
- package/dist/component-spec.js +8 -8
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +2 -1
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +2 -2
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +4 -2
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -1
- package/dist/product-selection.d.ts +14 -1
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +10 -11
- package/dist/product-selection.js.map +1 -1
- package/dist/reconciliation.d.ts +7 -1
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +40 -16
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +10 -7
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +6 -5
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +7 -0
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +5 -0
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +17 -2
- package/dist/tracking-input.js.map +1 -1
- package/package.json +3 -3
- package/src/component-generator.ts +54 -21
- package/src/component-spec.ts +8 -8
- package/src/fallback-component.ts +7 -3
- package/src/fit-to-shopper.ts +3 -1
- package/src/index.ts +2 -6
- package/src/product-selection.ts +29 -5
- package/src/reconciliation.ts +56 -12
- package/src/signal-digest.ts +10 -7
- package/src/spec-cache.ts +15 -5
- package/src/tracking-input.ts +17 -2
|
@@ -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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Block, GeneratedSpec, ProductReference } from './component-spec.js';
|
|
2
2
|
import type { ProductPick } from './product-selection.js';
|
|
3
3
|
|
|
4
|
-
// The model picks the shape
|
|
4
|
+
// The model picks the shape. Selection picks the grid and carousel products and
|
|
5
5
|
// what may be said about them, so a shared component still fits one shopper.
|
|
6
6
|
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/index.ts
CHANGED
|
@@ -69,10 +69,8 @@ export {
|
|
|
69
69
|
type SpecSource,
|
|
70
70
|
} from './component-spec.js';
|
|
71
71
|
|
|
72
|
-
export {
|
|
72
|
+
export { reconcileSpec, type ReconcileResult } from './reconciliation.js';
|
|
73
73
|
export { selectProducts, type ProductPick } from './product-selection.js';
|
|
74
|
-
export { fitToShopper } from './fit-to-shopper.js';
|
|
75
|
-
export { buildFallbackSpec } from './fallback-component.js';
|
|
76
74
|
|
|
77
75
|
export {
|
|
78
76
|
createFixedSpecProvider,
|
|
@@ -85,14 +83,12 @@ export {
|
|
|
85
83
|
export {
|
|
86
84
|
createMemorySpecCache,
|
|
87
85
|
createNullSpecCache,
|
|
88
|
-
cohortCacheKey,
|
|
89
|
-
specCacheKey,
|
|
90
86
|
type CachedSpec,
|
|
91
87
|
type MemorySpecCacheOptions,
|
|
92
88
|
type SpecCache,
|
|
93
89
|
} from './spec-cache.js';
|
|
94
90
|
|
|
95
|
-
export {
|
|
91
|
+
export { buildPrompt, type PromptPair } from './model-prompt.js';
|
|
96
92
|
|
|
97
93
|
export {
|
|
98
94
|
createComponentGenerator,
|
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
|
@@ -40,6 +40,8 @@ const CLAMP = {
|
|
|
40
40
|
bannerText: 160,
|
|
41
41
|
copyBody: 420,
|
|
42
42
|
rationale: 300,
|
|
43
|
+
// Not rendered anywhere — this is the SKU as it reads back in a violation.
|
|
44
|
+
violationSku: 32,
|
|
43
45
|
} as const;
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -175,6 +177,8 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
175
177
|
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
176
178
|
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
177
179
|
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
180
|
+
// The same score with the word "rating" nowhere near it: "4.8 out of 5".
|
|
181
|
+
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
178
182
|
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
179
183
|
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
180
184
|
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
@@ -186,8 +190,12 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
186
190
|
{
|
|
187
191
|
kind: 'price',
|
|
188
192
|
patterns: [
|
|
189
|
-
/[
|
|
193
|
+
/[$£€¥₹]\s?\d/,
|
|
190
194
|
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
195
|
+
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
196
|
+
/\b(?:usd|eur|gbp)\s?\d/,
|
|
197
|
+
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
198
|
+
/\bkr\s?\d|\d\s?kr\b/,
|
|
191
199
|
/\bpric(?:e|es|ed|ing)\b/,
|
|
192
200
|
// A before-and-after is a price claim even with no currency on it.
|
|
193
201
|
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
@@ -212,6 +220,8 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
212
220
|
/\bdiscount(?:s|ed)?\b/,
|
|
213
221
|
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
214
222
|
/\bhalf[\s-]?(?:price|off)\b/,
|
|
223
|
+
// Money off with no percent sign anywhere on it: "save 20 off".
|
|
224
|
+
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
215
225
|
// "extra clearance for thick socks" is room inside the shoe.
|
|
216
226
|
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
217
227
|
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
@@ -236,10 +246,10 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
236
246
|
kind: 'stock',
|
|
237
247
|
patterns: [
|
|
238
248
|
/\b(?:in|out of|low on) stock\b/,
|
|
249
|
+
/\blimited stock\b/,
|
|
239
250
|
/\brestocked?\b|\bsold out\b/,
|
|
240
251
|
// "the last few miles" is a distance, so a count needs "left" after it.
|
|
241
|
-
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|
|
|
242
|
-
/\blast (?:one|few)\s+(?:left|remaining|in stock)\b/,
|
|
252
|
+
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
243
253
|
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
244
254
|
],
|
|
245
255
|
},
|
|
@@ -329,11 +339,14 @@ function screenRequired(value: string, field: string, tracker: PlacementTracker)
|
|
|
329
339
|
* so each one has to name the fault that actually fired.
|
|
330
340
|
*/
|
|
331
341
|
function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
|
|
342
|
+
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
343
|
+
const named = clamp(sku, CLAMP.violationSku);
|
|
344
|
+
|
|
332
345
|
// Either hallucinated or out of stock. Either way it cannot render.
|
|
333
|
-
if (!allowlist.allowed.has(sku)) return `unknown-sku:${
|
|
334
|
-
if (allowlist.blocked.has(sku)) return `blocked-sku:${
|
|
335
|
-
if (tracker.hasPlaced(sku)) return `duplicate-sku:${
|
|
336
|
-
if (tracker.remaining <= 0) return `budget:dropped:${
|
|
346
|
+
if (!allowlist.allowed.has(sku)) return `unknown-sku:${named}`;
|
|
347
|
+
if (allowlist.blocked.has(sku)) return `blocked-sku:${named}`;
|
|
348
|
+
if (tracker.hasPlaced(sku)) return `duplicate-sku:${named}`;
|
|
349
|
+
if (tracker.remaining <= 0) return `budget:dropped:${named}`;
|
|
337
350
|
return null;
|
|
338
351
|
}
|
|
339
352
|
|
|
@@ -343,6 +356,7 @@ function reconcileItems(
|
|
|
343
356
|
candidatesBySku: Map<string, Product>,
|
|
344
357
|
digest: SignalDigest,
|
|
345
358
|
tracker: PlacementTracker,
|
|
359
|
+
hostReasonSkus: ReadonlySet<string>,
|
|
346
360
|
): ProductReference[] {
|
|
347
361
|
const kept: ProductReference[] = [];
|
|
348
362
|
|
|
@@ -362,15 +376,23 @@ function reconcileItems(
|
|
|
362
376
|
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
363
377
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
364
378
|
|
|
379
|
+
const isHostReason =
|
|
380
|
+
item.reason !== null && hostReasonSkus.has(item.sku) && item.reason === product.reason;
|
|
381
|
+
|
|
365
382
|
kept.push({
|
|
366
383
|
sku: item.sku,
|
|
367
384
|
basis: hasSupportedBasis ? item.basis : 'popular',
|
|
368
385
|
// The prose exists to state the basis. If the basis did not hold, the
|
|
369
386
|
// prose is a claim we just decided is untrue.
|
|
370
387
|
reason: hasSupportedBasis
|
|
371
|
-
?
|
|
388
|
+
? isHostReason
|
|
389
|
+
? clampNullable(item.reason, CLAMP.reason)
|
|
390
|
+
: screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
|
|
372
391
|
: null,
|
|
373
|
-
badge
|
|
392
|
+
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
393
|
+
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
394
|
+
// read for claims like every other sentence the model writes.
|
|
395
|
+
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
|
|
374
396
|
emphasis: item.emphasis,
|
|
375
397
|
});
|
|
376
398
|
}
|
|
@@ -503,6 +525,7 @@ function reconcileBlock(
|
|
|
503
525
|
digest: SignalDigest,
|
|
504
526
|
bundles: readonly Bundle[],
|
|
505
527
|
tracker: PlacementTracker,
|
|
528
|
+
hostReasonSkus: ReadonlySet<string>,
|
|
506
529
|
): Block | null {
|
|
507
530
|
switch (block.kind) {
|
|
508
531
|
case 'hero': {
|
|
@@ -539,7 +562,14 @@ function reconcileBlock(
|
|
|
539
562
|
}
|
|
540
563
|
|
|
541
564
|
case 'grid': {
|
|
542
|
-
const items = reconcileItems(
|
|
565
|
+
const items = reconcileItems(
|
|
566
|
+
block.items,
|
|
567
|
+
allowlist,
|
|
568
|
+
candidatesBySku,
|
|
569
|
+
digest,
|
|
570
|
+
tracker,
|
|
571
|
+
hostReasonSkus,
|
|
572
|
+
);
|
|
543
573
|
if (items.length === 0) {
|
|
544
574
|
tracker.record('empty-block:grid');
|
|
545
575
|
return null;
|
|
@@ -554,7 +584,14 @@ function reconcileBlock(
|
|
|
554
584
|
}
|
|
555
585
|
|
|
556
586
|
case 'carousel': {
|
|
557
|
-
const items = reconcileItems(
|
|
587
|
+
const items = reconcileItems(
|
|
588
|
+
block.items,
|
|
589
|
+
allowlist,
|
|
590
|
+
candidatesBySku,
|
|
591
|
+
digest,
|
|
592
|
+
tracker,
|
|
593
|
+
hostReasonSkus,
|
|
594
|
+
);
|
|
558
595
|
if (items.length === 0) {
|
|
559
596
|
tracker.record('empty-block:carousel');
|
|
560
597
|
return null;
|
|
@@ -630,6 +667,12 @@ export function reconcileSpec(
|
|
|
630
667
|
generated: GeneratedSpec,
|
|
631
668
|
input: TrackingInput,
|
|
632
669
|
digest: SignalDigest,
|
|
670
|
+
/**
|
|
671
|
+
* SKUs whose reason this request wrote from the host's own candidate. Only
|
|
672
|
+
* `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
673
|
+
* reason is the model's, including one that happens to read the same.
|
|
674
|
+
*/
|
|
675
|
+
hostReasonSkus: ReadonlySet<string> = new Set(),
|
|
633
676
|
): ReconcileResult {
|
|
634
677
|
const allowlist = buildAllowlist(input, digest);
|
|
635
678
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
@@ -648,6 +691,7 @@ export function reconcileSpec(
|
|
|
648
691
|
digest,
|
|
649
692
|
input.bundles,
|
|
650
693
|
tracker,
|
|
694
|
+
hostReasonSkus,
|
|
651
695
|
);
|
|
652
696
|
if (reconciled !== null) blocks.push(reconciled);
|
|
653
697
|
}
|
|
@@ -661,7 +705,7 @@ export function reconcileSpec(
|
|
|
661
705
|
tracker,
|
|
662
706
|
),
|
|
663
707
|
blocks,
|
|
664
|
-
rationale: clamp(generated.rationale, CLAMP.rationale),
|
|
708
|
+
rationale: screenRequired(clamp(generated.rationale, CLAMP.rationale), 'rationale', tracker),
|
|
665
709
|
};
|
|
666
710
|
|
|
667
711
|
// A component that recommends nothing is worse than no component at all.
|
package/src/signal-digest.ts
CHANGED
|
@@ -230,14 +230,17 @@ function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
|
|
|
230
230
|
|
|
231
231
|
/** The most-viewed few, as the digest reports them. */
|
|
232
232
|
function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
|
|
233
|
-
|
|
233
|
+
const top = mergeViewsBySku(views)
|
|
234
234
|
.toSorted((left, right) => right.views - left.views)
|
|
235
|
-
.slice(0, DIGEST_LIMITS.viewed)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
.slice(0, DIGEST_LIMITS.viewed);
|
|
236
|
+
|
|
237
|
+
const products: ViewedProduct[] = [];
|
|
238
|
+
for (const merged of top) {
|
|
239
|
+
const product: ViewedProduct = { sku: merged.sku, views: merged.views };
|
|
240
|
+
if (merged.dwellMs !== undefined) product.dwellMs = merged.dwellMs;
|
|
241
|
+
products.push(product);
|
|
242
|
+
}
|
|
243
|
+
return products;
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
/** The open-vocabulary long tail, reduced to "which kinds, and how often". */
|
package/src/spec-cache.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { SPEC_VERSION, type GeneratedSpec } from './component-spec.js';
|
|
3
|
+
import { SYSTEM_PROMPT } from './model-prompt.js';
|
|
3
4
|
import type { SignalDigest } from './signal-digest.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -15,11 +16,6 @@ import type { SignalDigest } from './signal-digest.js';
|
|
|
15
16
|
* instance count.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* Asynchronous on purpose. A synchronous `get` would look simpler and would
|
|
20
|
-
* make the shared store the docs recommend impossible to write, since no Redis
|
|
21
|
-
* or Memcached client can return a value without awaiting.
|
|
22
|
-
*/
|
|
23
19
|
/**
|
|
24
20
|
* A stored generation, with the moment it was produced.
|
|
25
21
|
*
|
|
@@ -33,9 +29,15 @@ export interface CachedSpec {
|
|
|
33
29
|
generatedAt: number;
|
|
34
30
|
}
|
|
35
31
|
|
|
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
|
+
*/
|
|
36
37
|
export interface SpecCache {
|
|
37
38
|
get(key: string): Promise<CachedSpec | undefined>;
|
|
38
39
|
set(key: string, cached: CachedSpec): Promise<void>;
|
|
40
|
+
delete?(key: string): Promise<void>;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface MemorySpecCacheOptions {
|
|
@@ -115,6 +117,10 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
|
|
|
115
117
|
entries.delete(oldest.value);
|
|
116
118
|
}
|
|
117
119
|
},
|
|
120
|
+
|
|
121
|
+
async delete(key) {
|
|
122
|
+
entries.delete(key);
|
|
123
|
+
},
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -148,6 +154,8 @@ function canonicalise(value: unknown): string {
|
|
|
148
154
|
return `{${entries.map(([name, fieldValue]) => `${JSON.stringify(name)}:${canonicalise(fieldValue)}`).join(',')}}`;
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
|
|
158
|
+
|
|
151
159
|
/**
|
|
152
160
|
* Derives the cache key.
|
|
153
161
|
*
|
|
@@ -182,6 +190,7 @@ export function specCacheKey(
|
|
|
182
190
|
// The spec's own version, so a shape change cannot read entries written by
|
|
183
191
|
// the previous shape out of a shared store that outlives a deploy.
|
|
184
192
|
specVersion: SPEC_VERSION,
|
|
193
|
+
prompt: PROMPT_FINGERPRINT,
|
|
185
194
|
provider: providerId,
|
|
186
195
|
digest,
|
|
187
196
|
candidates: candidateSkus.toSorted(),
|
|
@@ -201,6 +210,7 @@ export function cohortCacheKey(
|
|
|
201
210
|
): string {
|
|
202
211
|
const material = canonicalise({
|
|
203
212
|
specVersion: SPEC_VERSION,
|
|
213
|
+
prompt: PROMPT_FINGERPRINT,
|
|
204
214
|
provider: providerId,
|
|
205
215
|
segment: digest.segment ?? null,
|
|
206
216
|
surface: digest.surface,
|
package/src/tracking-input.ts
CHANGED
|
@@ -42,6 +42,11 @@ export const FIELD_LIMITS = {
|
|
|
42
42
|
candidates: 200,
|
|
43
43
|
productsPerBundle: 5,
|
|
44
44
|
bundles: 20,
|
|
45
|
+
localeTag: 35,
|
|
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,
|
|
45
50
|
} as const;
|
|
46
51
|
|
|
47
52
|
/** Assigning this as an object key mutates the prototype instead of the object. */
|
|
@@ -93,6 +98,10 @@ export const productSchema = z.strictObject({
|
|
|
93
98
|
// scheme to abuse.
|
|
94
99
|
imageUrl: imageReference().optional(),
|
|
95
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(),
|
|
96
105
|
isInStock: z.boolean().default(true),
|
|
97
106
|
tags: z
|
|
98
107
|
.array(z.string().min(1).max(FIELD_LIMITS.tag))
|
|
@@ -179,9 +188,15 @@ export const renderContextSchema = z.strictObject({
|
|
|
179
188
|
currentSku: optionalIdentifier(),
|
|
180
189
|
currentCategory: optionalIdentifier(),
|
|
181
190
|
searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
|
|
182
|
-
|
|
191
|
+
// One language tag, not the Accept-Language header it is often copied from:
|
|
192
|
+
// the locale is part of the cohort cache key, so a list makes its own cohort.
|
|
193
|
+
locale: z
|
|
194
|
+
.string()
|
|
195
|
+
.max(FIELD_LIMITS.localeTag)
|
|
196
|
+
.regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, 'expected one language tag, such as en-US')
|
|
197
|
+
.default('en-US'),
|
|
183
198
|
/** Upper bound on products across the whole generated component. */
|
|
184
|
-
maxItems: z.number().int().min(1).max(
|
|
199
|
+
maxItems: z.number().int().min(1).max(FIELD_LIMITS.maxItems).default(4),
|
|
185
200
|
});
|
|
186
201
|
export type RenderContext = z.infer<typeof renderContextSchema>;
|
|
187
202
|
|