@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,675 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Block,
|
|
3
|
+
GeneratedSpec,
|
|
4
|
+
ProductReference,
|
|
5
|
+
RecommendationBasis,
|
|
6
|
+
} from './component-spec.js';
|
|
7
|
+
import type { SignalDigest } from './signal-digest.js';
|
|
8
|
+
import type { Bundle, Product, TrackingInput } from './tracking-input.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reconciliation — the boundary between what the model said and what renders.
|
|
12
|
+
*
|
|
13
|
+
* Schema validation guarantees shape. It cannot guarantee truth: a well-formed
|
|
14
|
+
* spec can still name a product that does not exist, one the shopper told us
|
|
15
|
+
* they dislike, one that sold out since the candidate set was assembled, or
|
|
16
|
+
* claim the shopper viewed something they never saw. This pass is where those
|
|
17
|
+
* become impossible.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here trusts the model. A generation that survives every rule and
|
|
20
|
+
* still has nothing to show degrades to `isUsable: false`, and the caller renders
|
|
21
|
+
* the deterministic component instead.
|
|
22
|
+
*
|
|
23
|
+
* Repair, not rejection, is the default. A slightly clipped headline is a better
|
|
24
|
+
* outcome for the shopper than a discarded generation, so text is truncated and
|
|
25
|
+
* unverifiable claims are downgraded. Only an empty result fails outright.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Length ceilings, applied by truncation. These live here rather than in the
|
|
30
|
+
* schema because a provider's strict structured-output mode rejects string
|
|
31
|
+
* length bounds — see the note in `component-spec.ts`.
|
|
32
|
+
*/
|
|
33
|
+
const CLAMP = {
|
|
34
|
+
headline: 90,
|
|
35
|
+
subheadline: 140,
|
|
36
|
+
blockTitle: 80,
|
|
37
|
+
reason: 120,
|
|
38
|
+
badge: 24,
|
|
39
|
+
ctaLabel: 32,
|
|
40
|
+
bannerText: 160,
|
|
41
|
+
copyBody: 420,
|
|
42
|
+
rationale: 300,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* More than this and the component stops being a component.
|
|
47
|
+
*
|
|
48
|
+
* Exported because the fill pass has to read the same blocks this module will:
|
|
49
|
+
* one past the cap never renders, so nothing is worth reserving for it.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_BLOCKS = 4;
|
|
52
|
+
|
|
53
|
+
export interface ReconcileResult {
|
|
54
|
+
spec: GeneratedSpec;
|
|
55
|
+
/** True when something survived that is worth rendering. */
|
|
56
|
+
isUsable: boolean;
|
|
57
|
+
/** Machine-readable notes on what was removed or changed, for evaluation. */
|
|
58
|
+
violations: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function clamp(value: string, limit: number): string {
|
|
62
|
+
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
63
|
+
if (collapsed.length <= limit) return collapsed;
|
|
64
|
+
|
|
65
|
+
// The ellipsis counts against the limit, so leave room for it. Otherwise a
|
|
66
|
+
// clamped string is one character longer than the cap it was clamped to.
|
|
67
|
+
const cut = collapsed.slice(0, limit - 1);
|
|
68
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
69
|
+
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
70
|
+
return `${base.replace(/[.,;:!?-]+$/, '')}…`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clampNullable(value: string | null, limit: number): string | null {
|
|
74
|
+
if (value === null) return null;
|
|
75
|
+
const clamped = clamp(value, limit);
|
|
76
|
+
return clamped.length === 0 ? null : clamped;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Allowlist {
|
|
80
|
+
/** SKUs the model may place. */
|
|
81
|
+
allowed: Set<string>;
|
|
82
|
+
/** SKUs that must never be placed, whatever the model decided. */
|
|
83
|
+
blocked: Set<string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SKUs that must never be recommended, whatever chose them.
|
|
88
|
+
*
|
|
89
|
+
* Exported because the deterministic selector applies the same rule when it
|
|
90
|
+
* picks. Two copies of "never recommend these" would drift, and the pair that
|
|
91
|
+
* drifted would be the model path and the fallback path — the two whose
|
|
92
|
+
* comparability the whole evaluation depends on.
|
|
93
|
+
*/
|
|
94
|
+
export function neverRecommend(digest: SignalDigest): Set<string> {
|
|
95
|
+
const blocked = new Set<string>([
|
|
96
|
+
...digest.dislikedSkus,
|
|
97
|
+
...digest.purchasedSkus,
|
|
98
|
+
...digest.cartSkus,
|
|
99
|
+
]);
|
|
100
|
+
if (digest.currentSku) blocked.add(digest.currentSku);
|
|
101
|
+
return blocked;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildAllowlist(input: TrackingInput, digest: SignalDigest): Allowlist {
|
|
105
|
+
const allowed = new Set<string>();
|
|
106
|
+
for (const product of input.candidates) {
|
|
107
|
+
// An out-of-stock candidate is not a recommendation, it is a dead end.
|
|
108
|
+
if (product.isInStock) allowed.add(product.sku);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Blocked structurally rather than by asking the model nicely. The prompt
|
|
112
|
+
// says not to place these; this is what makes it true when it ignores us.
|
|
113
|
+
return { allowed, blocked: neverRecommend(digest) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks the model's stated reason for a pick against the shopper's actual
|
|
118
|
+
* signals.
|
|
119
|
+
*
|
|
120
|
+
* `basis` is a factual claim — "you viewed this", "this goes with your cart" —
|
|
121
|
+
* and the model has every incentive to reach for the most flattering one. A
|
|
122
|
+
* claim we cannot support becomes `popular`, which asserts nothing, and the
|
|
123
|
+
* prose that stated it is dropped along with it.
|
|
124
|
+
*/
|
|
125
|
+
function verifyBasis(basis: RecommendationBasis, product: Product, digest: SignalDigest): boolean {
|
|
126
|
+
switch (basis) {
|
|
127
|
+
case 'most_viewed':
|
|
128
|
+
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
129
|
+
case 'complements_cart':
|
|
130
|
+
return digest.cartSkus.length > 0;
|
|
131
|
+
case 'complements_purchase':
|
|
132
|
+
return digest.purchasedSkus.length > 0;
|
|
133
|
+
case 'liked_category':
|
|
134
|
+
return digest.categoryAffinity.some((affinity) => affinity.category === product.category);
|
|
135
|
+
case 'similar_to_current':
|
|
136
|
+
return digest.currentCategory === product.category;
|
|
137
|
+
case 'popular':
|
|
138
|
+
// Makes no claim about this shopper, so there is nothing to check.
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Free text that states something the renderer cannot check.
|
|
145
|
+
*
|
|
146
|
+
* The prompt bans prices, discounts, delivery dates, stock levels and ratings
|
|
147
|
+
* because every one of them moves after the words are written, and a cohort
|
|
148
|
+
* component is cached and served again later. `verifyBasis` checks the basis a
|
|
149
|
+
* pick claims; nothing checks the sentences around it, so every string the
|
|
150
|
+
* model writes is checked here — a heading is not a safer place for a claim
|
|
151
|
+
* than the small print under it.
|
|
152
|
+
*
|
|
153
|
+
* A claim is about money, a customer score, when it arrives, or how many are
|
|
154
|
+
* left. A specification is not a claim, even when it has a number or a
|
|
155
|
+
* percentage in it: "100% recycled nylon", "a comfort rating of -5C" and
|
|
156
|
+
* "arrives flat-packed" are all things a shop can say about the product itself,
|
|
157
|
+
* and they stay. That is why almost every rule below needs a second word beside
|
|
158
|
+
* the first — "20% off", not "20%"; "rated 4.8", not "rated". A careful
|
|
159
|
+
* rewording will get past this, and that is the trade we want: missing one
|
|
160
|
+
* claim is better than deleting honest copy on every page.
|
|
161
|
+
*
|
|
162
|
+
* One rule per line, because each line is a separate judgement about where the
|
|
163
|
+
* boundary sits and each one wants its own reason written next to it.
|
|
164
|
+
*/
|
|
165
|
+
const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
166
|
+
{
|
|
167
|
+
// Customers scoring the product. "rated for winter use", "rated to -10C"
|
|
168
|
+
// and "an IPX7 water rating" are about what the product can take.
|
|
169
|
+
kind: 'rating',
|
|
170
|
+
patterns: [
|
|
171
|
+
/\breviews?\b|\breviewed\b/,
|
|
172
|
+
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
173
|
+
/\bstars?[\s-]?ratings?\b/,
|
|
174
|
+
// A rating somebody gave it, rather than one it was built to.
|
|
175
|
+
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
176
|
+
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
177
|
+
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
178
|
+
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
179
|
+
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
180
|
+
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
181
|
+
// How many other people bought or liked it is a customer claim too.
|
|
182
|
+
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
183
|
+
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
kind: 'price',
|
|
188
|
+
patterns: [
|
|
189
|
+
/[$£€¥]\s?\d/,
|
|
190
|
+
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
191
|
+
/\bpric(?:e|es|ed|ing)\b/,
|
|
192
|
+
// A before-and-after is a price claim even with no currency on it.
|
|
193
|
+
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
194
|
+
// "does not feel cheap" is about quality. Only the comparison is money.
|
|
195
|
+
/\bcheap(?:er|est)\b/,
|
|
196
|
+
/\baffordable\b|\bbargain\b/,
|
|
197
|
+
// "at no cost to comfort" is not money. "costs less" is.
|
|
198
|
+
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
199
|
+
/\blow(?:er)?[\s-]cost\b/,
|
|
200
|
+
// "saves weight" and "saves on weight" are both about grams.
|
|
201
|
+
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
// A percentage in outdoor copy is a specification far more often than it is
|
|
206
|
+
// money off, so it only counts beside a money word: "20% off", not
|
|
207
|
+
// "100% recycled".
|
|
208
|
+
kind: 'discount',
|
|
209
|
+
patterns: [
|
|
210
|
+
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
211
|
+
/\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
|
|
212
|
+
/\bdiscount(?:s|ed)?\b/,
|
|
213
|
+
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
214
|
+
/\bhalf[\s-]?(?:price|off)\b/,
|
|
215
|
+
// "extra clearance for thick socks" is room inside the shoe.
|
|
216
|
+
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
217
|
+
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
218
|
+
// reduced price, or a reduction with a date on it, is money off.
|
|
219
|
+
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
// When it turns up, not what turns up. "ships in a recycled box" and
|
|
224
|
+
// "arrives flat-packed" describe the thing, so the verb alone is not enough
|
|
225
|
+
// — it needs a day or a date beside it.
|
|
226
|
+
kind: 'delivery',
|
|
227
|
+
patterns: [
|
|
228
|
+
/\bdelivery\b|\bshipping\b/,
|
|
229
|
+
/\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
|
|
230
|
+
/\b(?:ships?|arrives?|arriving|delivered|by|before|in time for)\s+(?:on\s+)?(?:today|tomorrow|tonight|this week|next week|the weekend|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/,
|
|
231
|
+
/\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
|
|
232
|
+
/\bin time for\b/,
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
kind: 'stock',
|
|
237
|
+
patterns: [
|
|
238
|
+
/\b(?:in|out of|low on) stock\b/,
|
|
239
|
+
/\brestocked?\b|\bsold out\b/,
|
|
240
|
+
// "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|remaining)\b/,
|
|
242
|
+
/\blast (?:one|few)\s+(?:left|remaining|in stock)\b/,
|
|
243
|
+
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
249
|
+
function claimIn(text: string): string | null {
|
|
250
|
+
const lower = text.toLowerCase();
|
|
251
|
+
for (const claim of CLAIM_PATTERNS) {
|
|
252
|
+
for (const pattern of claim.patterns) {
|
|
253
|
+
if (pattern.test(lower)) return claim.kind;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The running state of one reconciliation pass: what has been placed, how much
|
|
261
|
+
* of the item budget is left, and what was changed along the way.
|
|
262
|
+
*
|
|
263
|
+
* This is deliberately one named thing rather than three parameters threaded
|
|
264
|
+
* through every function. The budget and the de-duplication set are global to a
|
|
265
|
+
* spec, not to a block, which is the part that is easy to get wrong.
|
|
266
|
+
*/
|
|
267
|
+
function createPlacementTracker(maxItems: number) {
|
|
268
|
+
const placedSkus = new Set<string>();
|
|
269
|
+
const violations: string[] = [];
|
|
270
|
+
let remaining = maxItems;
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
violations,
|
|
274
|
+
get remaining() {
|
|
275
|
+
return remaining;
|
|
276
|
+
},
|
|
277
|
+
hasPlaced: (sku: string) => placedSkus.has(sku),
|
|
278
|
+
record(violation: string) {
|
|
279
|
+
violations.push(violation);
|
|
280
|
+
},
|
|
281
|
+
place(sku: string) {
|
|
282
|
+
placedSkus.add(sku);
|
|
283
|
+
remaining -= 1;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Drops text that makes a claim we cannot check, and names what it claimed.
|
|
292
|
+
*
|
|
293
|
+
* Runs after clamping, so what is screened is exactly what would have rendered.
|
|
294
|
+
* Dropping means what it means everywhere else here: this field becomes null
|
|
295
|
+
* and the rest of the block carries on.
|
|
296
|
+
*/
|
|
297
|
+
function screenClaim(
|
|
298
|
+
value: string | null,
|
|
299
|
+
field: string,
|
|
300
|
+
tracker: PlacementTracker,
|
|
301
|
+
): string | null {
|
|
302
|
+
if (value === null) return null;
|
|
303
|
+
|
|
304
|
+
const kind = claimIn(value);
|
|
305
|
+
if (kind === null) return value;
|
|
306
|
+
|
|
307
|
+
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The same screen for a field that cannot be null. Emptying it hands the field
|
|
313
|
+
* to the rule that already drops a block, or a whole generation, whose text
|
|
314
|
+
* clamps to nothing — so a banner reading "20% off" disappears rather than
|
|
315
|
+
* rendering blank.
|
|
316
|
+
*/
|
|
317
|
+
function screenRequired(value: string, field: string, tracker: PlacementTracker): string {
|
|
318
|
+
return screenClaim(value, field, tracker) ?? '';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Decides whether one SKU may be placed, and names the reason when it may not.
|
|
323
|
+
* Does not consume budget — the caller does that once it commits.
|
|
324
|
+
*
|
|
325
|
+
* Order matters. The budget is checked last because it is the least specific
|
|
326
|
+
* cause: a hallucinated SKU that arrives after the budget is spent is still a
|
|
327
|
+
* hallucination, and reporting it as `budget:dropped` would understate how
|
|
328
|
+
* often the model invents products. These strings are the evaluation signal,
|
|
329
|
+
* so each one has to name the fault that actually fired.
|
|
330
|
+
*/
|
|
331
|
+
function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
|
|
332
|
+
// Either hallucinated or out of stock. Either way it cannot render.
|
|
333
|
+
if (!allowlist.allowed.has(sku)) return `unknown-sku:${sku}`;
|
|
334
|
+
if (allowlist.blocked.has(sku)) return `blocked-sku:${sku}`;
|
|
335
|
+
if (tracker.hasPlaced(sku)) return `duplicate-sku:${sku}`;
|
|
336
|
+
if (tracker.remaining <= 0) return `budget:dropped:${sku}`;
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function reconcileItems(
|
|
341
|
+
items: ProductReference[],
|
|
342
|
+
allowlist: Allowlist,
|
|
343
|
+
candidatesBySku: Map<string, Product>,
|
|
344
|
+
digest: SignalDigest,
|
|
345
|
+
tracker: PlacementTracker,
|
|
346
|
+
): ProductReference[] {
|
|
347
|
+
const kept: ProductReference[] = [];
|
|
348
|
+
|
|
349
|
+
for (const item of items) {
|
|
350
|
+
const rejection = rejectionFor(item.sku, allowlist, tracker);
|
|
351
|
+
if (rejection) {
|
|
352
|
+
tracker.record(rejection);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const product = candidatesBySku.get(item.sku);
|
|
357
|
+
// rejectionFor already proved the SKU is an in-stock candidate.
|
|
358
|
+
if (!product) continue;
|
|
359
|
+
|
|
360
|
+
tracker.place(item.sku);
|
|
361
|
+
|
|
362
|
+
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
363
|
+
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
364
|
+
|
|
365
|
+
kept.push({
|
|
366
|
+
sku: item.sku,
|
|
367
|
+
basis: hasSupportedBasis ? item.basis : 'popular',
|
|
368
|
+
// The prose exists to state the basis. If the basis did not hold, the
|
|
369
|
+
// prose is a claim we just decided is untrue.
|
|
370
|
+
reason: hasSupportedBasis
|
|
371
|
+
? screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
|
|
372
|
+
: null,
|
|
373
|
+
badge: clampNullable(item.badge, CLAMP.badge),
|
|
374
|
+
emphasis: item.emphasis,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return kept;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Three separate counts, not one score — cart beats views beats category
|
|
382
|
+
// regardless of how the counts compare, so they can't be summed.
|
|
383
|
+
interface BundleFit {
|
|
384
|
+
cartHits: number;
|
|
385
|
+
viewedHits: number;
|
|
386
|
+
categoryHits: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function fitOf(
|
|
390
|
+
bundle: Bundle,
|
|
391
|
+
digest: SignalDigest,
|
|
392
|
+
candidatesBySku: Map<string, Product>,
|
|
393
|
+
): BundleFit {
|
|
394
|
+
const fit: BundleFit = { cartHits: 0, viewedHits: 0, categoryHits: 0 };
|
|
395
|
+
|
|
396
|
+
for (const sku of bundle.skus) {
|
|
397
|
+
if (digest.cartSkus.includes(sku)) fit.cartHits += 1;
|
|
398
|
+
else if (digest.topViewed.some((viewed) => viewed.sku === sku)) fit.viewedHits += 1;
|
|
399
|
+
else if (candidatesBySku.get(sku)?.category === digest.currentCategory) fit.categoryHits += 1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return fit;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** In the cart beats recently viewed, which beats the category being looked at. */
|
|
406
|
+
function isBetterFit(fit: BundleFit, best: BundleFit | undefined): boolean {
|
|
407
|
+
if (!best) return true;
|
|
408
|
+
if (fit.cartHits !== best.cartHits) return fit.cartHits > best.cartHits;
|
|
409
|
+
if (fit.viewedHits !== best.viewedHits) return fit.viewedHits > best.viewedHits;
|
|
410
|
+
return fit.categoryHits > best.categoryHits;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function chooseBundle(
|
|
414
|
+
bundles: readonly Bundle[],
|
|
415
|
+
allowlist: Allowlist,
|
|
416
|
+
digest: SignalDigest,
|
|
417
|
+
candidatesBySku: Map<string, Product>,
|
|
418
|
+
tracker: PlacementTracker,
|
|
419
|
+
): Bundle | undefined {
|
|
420
|
+
let best: Bundle | undefined;
|
|
421
|
+
let bestFit: BundleFit | undefined;
|
|
422
|
+
|
|
423
|
+
for (const bundle of bundles) {
|
|
424
|
+
// A bundle needs room for every product at once.
|
|
425
|
+
if (bundle.skus.length > tracker.remaining) continue;
|
|
426
|
+
|
|
427
|
+
let isPlaceable = true;
|
|
428
|
+
for (const sku of bundle.skus) {
|
|
429
|
+
if (!allowlist.allowed.has(sku)) isPlaceable = false;
|
|
430
|
+
if (digest.dislikedSkus.includes(sku)) isPlaceable = false;
|
|
431
|
+
// Already shown by an earlier block — twice on a page looks broken.
|
|
432
|
+
if (tracker.hasPlaced(sku)) isPlaceable = false;
|
|
433
|
+
}
|
|
434
|
+
if (!isPlaceable) continue;
|
|
435
|
+
|
|
436
|
+
const fit = fitOf(bundle, digest, candidatesBySku);
|
|
437
|
+
if (isBetterFit(fit, bestFit)) {
|
|
438
|
+
best = bundle;
|
|
439
|
+
bestFit = fit;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return best;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The set this shopper should get, decided before anything is placed.
|
|
448
|
+
*
|
|
449
|
+
* The generator needs the answer early, so it can keep the set's products out
|
|
450
|
+
* of the grid and keep room for them. `chooseBundle` stays private: this hands
|
|
451
|
+
* out the choice, not the machinery behind it.
|
|
452
|
+
*
|
|
453
|
+
* `spokenFor` is what the blocks above the bundle block will have placed by the
|
|
454
|
+
* time it is reached. Placing it here first is what makes the two choices agree:
|
|
455
|
+
* a set is only pre-chosen if reconciliation could still reach for it.
|
|
456
|
+
*/
|
|
457
|
+
export function bundleForShopper(
|
|
458
|
+
input: TrackingInput,
|
|
459
|
+
digest: SignalDigest,
|
|
460
|
+
spokenFor: readonly string[],
|
|
461
|
+
): Bundle | undefined {
|
|
462
|
+
const allowlist = buildAllowlist(input, digest);
|
|
463
|
+
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
464
|
+
const tracker = createPlacementTracker(digest.maxItems);
|
|
465
|
+
|
|
466
|
+
for (const sku of spokenFor) tracker.place(sku);
|
|
467
|
+
|
|
468
|
+
return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The hero products these blocks will really place.
|
|
473
|
+
*
|
|
474
|
+
* A hero keeps the product the model named — its headline was written about
|
|
475
|
+
* that product — so it spends a slot of the item budget the grid cannot have.
|
|
476
|
+
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
477
|
+
* counted here either.
|
|
478
|
+
*/
|
|
479
|
+
export function placeableHeroSkus(
|
|
480
|
+
blocks: readonly Block[],
|
|
481
|
+
input: TrackingInput,
|
|
482
|
+
digest: SignalDigest,
|
|
483
|
+
): string[] {
|
|
484
|
+
const allowlist = buildAllowlist(input, digest);
|
|
485
|
+
|
|
486
|
+
const skus: string[] = [];
|
|
487
|
+
for (const block of blocks) {
|
|
488
|
+
if (block.kind !== 'hero') continue;
|
|
489
|
+
if (block.sku === null) continue;
|
|
490
|
+
if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku)) continue;
|
|
491
|
+
// Two heroes naming the same product: only the first one gets to place it.
|
|
492
|
+
if (skus.includes(block.sku)) continue;
|
|
493
|
+
skus.push(block.sku);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return skus;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function reconcileBlock(
|
|
500
|
+
block: Block,
|
|
501
|
+
allowlist: Allowlist,
|
|
502
|
+
candidatesBySku: Map<string, Product>,
|
|
503
|
+
digest: SignalDigest,
|
|
504
|
+
bundles: readonly Bundle[],
|
|
505
|
+
tracker: PlacementTracker,
|
|
506
|
+
): Block | null {
|
|
507
|
+
switch (block.kind) {
|
|
508
|
+
case 'hero': {
|
|
509
|
+
let sku = block.sku;
|
|
510
|
+
if (sku !== null) {
|
|
511
|
+
const rejection = rejectionFor(sku, allowlist, tracker);
|
|
512
|
+
if (rejection) {
|
|
513
|
+
tracker.record(rejection);
|
|
514
|
+
// A hero without its product is still a legitimate headline.
|
|
515
|
+
sku = null;
|
|
516
|
+
} else {
|
|
517
|
+
tracker.place(sku);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Every other block kind disappears when its content clamps to nothing.
|
|
521
|
+
// A hero with no headline and no product is the same empty region, and
|
|
522
|
+
// it would otherwise render above real content.
|
|
523
|
+
const headline = screenRequired(
|
|
524
|
+
clamp(block.headline, CLAMP.headline),
|
|
525
|
+
'hero-headline',
|
|
526
|
+
tracker,
|
|
527
|
+
);
|
|
528
|
+
if (headline.length === 0 && sku === null) {
|
|
529
|
+
tracker.record('empty-block:hero');
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
kind: 'hero',
|
|
534
|
+
headline,
|
|
535
|
+
body: screenClaim(clampNullable(block.body, CLAMP.subheadline), 'hero-body', tracker),
|
|
536
|
+
sku,
|
|
537
|
+
ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'hero-cta', tracker),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
case 'grid': {
|
|
542
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
|
|
543
|
+
if (items.length === 0) {
|
|
544
|
+
tracker.record('empty-block:grid');
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
kind: 'grid',
|
|
549
|
+
title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'grid-title', tracker),
|
|
550
|
+
// Never leave a grid wider than it has items to fill.
|
|
551
|
+
columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
|
|
552
|
+
items,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
case 'carousel': {
|
|
557
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker);
|
|
558
|
+
if (items.length === 0) {
|
|
559
|
+
tracker.record('empty-block:carousel');
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
kind: 'carousel',
|
|
564
|
+
title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'carousel-title', tracker),
|
|
565
|
+
items,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
case 'banner': {
|
|
570
|
+
const text = screenRequired(clamp(block.text, CLAMP.bannerText), 'banner-text', tracker);
|
|
571
|
+
if (text.length === 0) {
|
|
572
|
+
tracker.record('empty-block:banner');
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
kind: 'banner',
|
|
577
|
+
tone: block.tone,
|
|
578
|
+
text,
|
|
579
|
+
ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'banner-cta', tracker),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case 'copy': {
|
|
584
|
+
const body = screenRequired(clamp(block.body, CLAMP.copyBody), 'copy-body', tracker);
|
|
585
|
+
if (body.length === 0) {
|
|
586
|
+
tracker.record('empty-block:copy');
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
kind: 'copy',
|
|
591
|
+
title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'copy-title', tracker),
|
|
592
|
+
body,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
case 'bundle': {
|
|
597
|
+
const chosen = chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker);
|
|
598
|
+
if (!chosen) {
|
|
599
|
+
tracker.record('no-bundle');
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const sku of chosen.skus) tracker.place(sku);
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
kind: 'bundle',
|
|
607
|
+
title: screenClaim(clampNullable(block.title, CLAMP.blockTitle), 'bundle-title', tracker),
|
|
608
|
+
body: screenClaim(clampNullable(block.body, CLAMP.subheadline), 'bundle-body', tracker),
|
|
609
|
+
ctaLabel: screenClaim(clampNullable(block.ctaLabel, CLAMP.ctaLabel), 'bundle-cta', tracker),
|
|
610
|
+
// The model's bundleId is ignored on purpose.
|
|
611
|
+
bundleId: chosen.id,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** True when a spec contains at least one block that actually shows a product. */
|
|
618
|
+
function showsAnyProduct(blocks: Block[]): boolean {
|
|
619
|
+
return blocks.some(
|
|
620
|
+
(block) =>
|
|
621
|
+
(block.kind === 'grid' && block.items.length > 0) ||
|
|
622
|
+
(block.kind === 'carousel' && block.items.length > 0) ||
|
|
623
|
+
(block.kind === 'hero' && block.sku !== null) ||
|
|
624
|
+
// A bundle shows products too, so it counts the same as grid/carousel/hero.
|
|
625
|
+
(block.kind === 'bundle' && block.bundleId !== null),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function reconcileSpec(
|
|
630
|
+
generated: GeneratedSpec,
|
|
631
|
+
input: TrackingInput,
|
|
632
|
+
digest: SignalDigest,
|
|
633
|
+
): ReconcileResult {
|
|
634
|
+
const allowlist = buildAllowlist(input, digest);
|
|
635
|
+
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
636
|
+
const tracker = createPlacementTracker(digest.maxItems);
|
|
637
|
+
|
|
638
|
+
if (generated.blocks.length > MAX_BLOCKS) {
|
|
639
|
+
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const blocks: Block[] = [];
|
|
643
|
+
for (const block of generated.blocks.slice(0, MAX_BLOCKS)) {
|
|
644
|
+
const reconciled = reconcileBlock(
|
|
645
|
+
block,
|
|
646
|
+
allowlist,
|
|
647
|
+
candidatesBySku,
|
|
648
|
+
digest,
|
|
649
|
+
input.bundles,
|
|
650
|
+
tracker,
|
|
651
|
+
);
|
|
652
|
+
if (reconciled !== null) blocks.push(reconciled);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const spec: GeneratedSpec = {
|
|
656
|
+
tone: generated.tone,
|
|
657
|
+
headline: screenRequired(clamp(generated.headline, CLAMP.headline), 'headline', tracker),
|
|
658
|
+
subheadline: screenClaim(
|
|
659
|
+
clampNullable(generated.subheadline, CLAMP.subheadline),
|
|
660
|
+
'subheadline',
|
|
661
|
+
tracker,
|
|
662
|
+
),
|
|
663
|
+
blocks,
|
|
664
|
+
rationale: clamp(generated.rationale, CLAMP.rationale),
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// A component that recommends nothing is worse than no component at all.
|
|
668
|
+
// Recorded separately: "no products survived" and "the model returned no
|
|
669
|
+
// headline" are different failures and want different fixes.
|
|
670
|
+
if (!showsAnyProduct(blocks)) tracker.record('unusable:no-products');
|
|
671
|
+
if (spec.headline.length === 0) tracker.record('unusable:no-headline');
|
|
672
|
+
const isUsable = showsAnyProduct(blocks) && spec.headline.length > 0;
|
|
673
|
+
|
|
674
|
+
return { spec, isUsable, violations: tracker.violations };
|
|
675
|
+
}
|