@rudra-js/core 0.5.0 → 0.7.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 +64 -16
- package/dist/claim-screening.d.ts +10 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +157 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts +0 -70
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +27 -135
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +4 -159
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +0 -92
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +0 -6
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +0 -21
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +5 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +5 -14
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +1 -30
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +19 -78
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -29
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -22
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +0 -55
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +0 -12
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +6 -61
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +76 -466
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +1 -18
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +27 -66
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +8 -70
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +5 -68
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +0 -49
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +5 -105
- package/dist/tracking-input.js.map +1 -1
- package/package.json +7 -6
- package/src/claim-screening.ts +190 -0
- package/src/component-generator.ts +45 -240
- package/src/component-spec.ts +17 -118
- package/src/fallback-component.ts +0 -22
- package/src/fit-to-shopper.ts +12 -16
- package/src/index.ts +1 -8
- package/src/model-prompt.ts +25 -84
- package/src/product-selection.ts +6 -52
- package/src/provider.ts +4 -60
- package/src/reconciliation.ts +104 -499
- package/src/signal-digest.ts +34 -83
- package/src/spec-cache.ts +10 -97
- package/src/tracking-input.ts +17 -111
package/dist/reconciliation.js
CHANGED
|
@@ -1,27 +1,6 @@
|
|
|
1
1
|
import { verify } from '@rudra-js/attested';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
* Reconciliation — the boundary between what the model said and what renders.
|
|
5
|
-
*
|
|
6
|
-
* Schema validation guarantees shape. It cannot guarantee truth: a well-formed
|
|
7
|
-
* spec can still name a product that does not exist, one the shopper told us
|
|
8
|
-
* they dislike, one that sold out since the candidate set was assembled, or
|
|
9
|
-
* claim the shopper viewed something they never saw. This pass is where those
|
|
10
|
-
* become impossible.
|
|
11
|
-
*
|
|
12
|
-
* Nothing here trusts the model. A generation that survives every rule and
|
|
13
|
-
* still has nothing to show degrades to `isUsable: false`, and the caller renders
|
|
14
|
-
* the deterministic component instead.
|
|
15
|
-
*
|
|
16
|
-
* Repair, not rejection, is the default. A slightly clipped headline is a better
|
|
17
|
-
* outcome for the shopper than a discarded generation, so text is truncated and
|
|
18
|
-
* unverifiable claims are downgraded. Only an empty result fails outright.
|
|
19
|
-
*/
|
|
20
|
-
/**
|
|
21
|
-
* Length ceilings, applied by truncation. These live here rather than in the
|
|
22
|
-
* schema because a provider's strict structured-output mode rejects string
|
|
23
|
-
* length bounds — see the note in `component-spec.ts`.
|
|
24
|
-
*/
|
|
2
|
+
import { ALLOWED_PHRASES, claimIn, hostFacts } from './claim-screening.js';
|
|
3
|
+
import { engagedCategories } from './signal-digest.js';
|
|
25
4
|
const CLAMP = {
|
|
26
5
|
headline: 90,
|
|
27
6
|
subheadline: 140,
|
|
@@ -32,72 +11,50 @@ const CLAMP = {
|
|
|
32
11
|
bannerText: 160,
|
|
33
12
|
copyBody: 420,
|
|
34
13
|
rationale: 300,
|
|
35
|
-
// Not rendered anywhere — this is the SKU as it reads back in a violation.
|
|
36
14
|
violationSku: 32,
|
|
37
15
|
};
|
|
38
|
-
/**
|
|
39
|
-
* More than this and the component stops being a component.
|
|
40
|
-
*
|
|
41
|
-
* Exported because the fill pass has to read the same blocks this module will:
|
|
42
|
-
* one past the cap never renders, so nothing is worth reserving for it.
|
|
43
|
-
*/
|
|
44
16
|
export const MAX_BLOCKS = 4;
|
|
17
|
+
export function capBlocks(blocks) {
|
|
18
|
+
return blocks.slice(0, MAX_BLOCKS);
|
|
19
|
+
}
|
|
45
20
|
function clamp(value, limit) {
|
|
46
21
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
47
22
|
if (collapsed.length <= limit)
|
|
48
23
|
return collapsed;
|
|
49
|
-
// The ellipsis counts against the limit, so leave room for it. Otherwise a
|
|
50
|
-
// clamped string is one character longer than the cap it was clamped to.
|
|
51
24
|
const cut = collapsed.slice(0, limit - 1);
|
|
52
25
|
const lastSpace = cut.lastIndexOf(' ');
|
|
53
26
|
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
54
27
|
return `${base.replace(/[.,;:!?-]+$/, '')}…`;
|
|
55
28
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
29
|
+
function refusedEverywhere(input) {
|
|
30
|
+
const refused = new Set();
|
|
31
|
+
for (const signal of input.signals.dislikes)
|
|
32
|
+
refused.add(signal.sku);
|
|
33
|
+
if (input.context.currentSku)
|
|
34
|
+
refused.add(input.context.currentSku);
|
|
35
|
+
return refused;
|
|
61
36
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* comparability the whole evaluation depends on.
|
|
69
|
-
*/
|
|
70
|
-
export function neverRecommend(digest) {
|
|
71
|
-
const blocked = new Set([
|
|
72
|
-
...digest.dislikedSkus,
|
|
73
|
-
...digest.purchasedSkus,
|
|
74
|
-
...digest.cartSkus,
|
|
75
|
-
]);
|
|
76
|
-
if (digest.currentSku)
|
|
77
|
-
blocked.add(digest.currentSku);
|
|
37
|
+
export function neverRecommend(input) {
|
|
38
|
+
const { signals } = input;
|
|
39
|
+
const blocked = refusedEverywhere(input);
|
|
40
|
+
for (const signal of [...signals.lastPurchased, ...signals.cart]) {
|
|
41
|
+
blocked.add(signal.sku);
|
|
42
|
+
}
|
|
78
43
|
return blocked;
|
|
79
44
|
}
|
|
80
|
-
function buildAllowlist(input
|
|
45
|
+
function buildAllowlist(input) {
|
|
81
46
|
const allowed = new Set();
|
|
82
47
|
for (const product of input.candidates) {
|
|
83
|
-
// An out-of-stock candidate is not a recommendation, it is a dead end.
|
|
84
48
|
if (product.isInStock)
|
|
85
49
|
allowed.add(product.sku);
|
|
86
50
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
51
|
+
return {
|
|
52
|
+
allowed,
|
|
53
|
+
blocked: neverRecommend(input),
|
|
54
|
+
blockedInBundle: refusedEverywhere(input),
|
|
55
|
+
};
|
|
90
56
|
}
|
|
91
|
-
|
|
92
|
-
* Checks the model's stated reason for a pick against the shopper's actual
|
|
93
|
-
* signals.
|
|
94
|
-
*
|
|
95
|
-
* `basis` is a factual claim — "you viewed this", "this goes with your cart" —
|
|
96
|
-
* and the model has every incentive to reach for the most flattering one. A
|
|
97
|
-
* claim we cannot support becomes `popular`, which asserts nothing, and the
|
|
98
|
-
* prose that stated it is dropped along with it.
|
|
99
|
-
*/
|
|
100
|
-
function verifyBasis(basis, product, digest) {
|
|
57
|
+
function verifyBasis(basis, product, digest, engaged) {
|
|
101
58
|
switch (basis) {
|
|
102
59
|
case 'most_viewed':
|
|
103
60
|
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
@@ -106,289 +63,16 @@ function verifyBasis(basis, product, digest) {
|
|
|
106
63
|
case 'complements_purchase':
|
|
107
64
|
return digest.purchasedSkus.length > 0;
|
|
108
65
|
case 'liked_category':
|
|
109
|
-
return
|
|
66
|
+
return (engaged.has(product.category) &&
|
|
67
|
+
digest.categoryAffinity.some((affinity) => affinity.category === product.category));
|
|
110
68
|
case 'similar_to_current':
|
|
111
69
|
return digest.currentCategory === product.category;
|
|
112
70
|
case 'popular':
|
|
113
|
-
// Makes no claim about this shopper, so there is nothing to check.
|
|
114
71
|
return true;
|
|
115
72
|
}
|
|
116
73
|
}
|
|
117
|
-
/**
|
|
118
|
-
* Free text that states something the renderer cannot check.
|
|
119
|
-
*
|
|
120
|
-
* The prompt bans prices, discounts, delivery dates, stock levels and ratings
|
|
121
|
-
* because every one of them moves after the words are written, and a cohort
|
|
122
|
-
* component is cached and served again later. `verifyBasis` checks the basis a
|
|
123
|
-
* pick claims; nothing checks the sentences around it, so every string the
|
|
124
|
-
* model writes is checked here — a heading is not a safer place for a claim
|
|
125
|
-
* than the small print under it.
|
|
126
|
-
*
|
|
127
|
-
* These patterns are the first of three passes and the only one that names which
|
|
128
|
-
* of the five kinds a sentence claimed, so they read the domain: "20% off", not
|
|
129
|
-
* "20%"; "rated 4.8", not "rated". A specification walks past all of them and is
|
|
130
|
-
* then weighed by @rudra-js/attested, which reads no domain at all and asks only
|
|
131
|
-
* whether the shop supplied the number.
|
|
132
|
-
*
|
|
133
|
-
* One rule per line, because each line is a separate judgement about where the
|
|
134
|
-
* boundary sits and each one wants its own reason written next to it.
|
|
135
|
-
*/
|
|
136
|
-
const CLAIM_PATTERNS = [
|
|
137
|
-
{
|
|
138
|
-
// Customers scoring the product. "rated for winter use", "rated to -10C"
|
|
139
|
-
// and "an IPX7 water rating" are about what the product can take.
|
|
140
|
-
kind: 'rating',
|
|
141
|
-
patterns: [
|
|
142
|
-
/\breviews?\b|\breviewed\b/,
|
|
143
|
-
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
144
|
-
/\bstars?[\s-]?ratings?\b/,
|
|
145
|
-
// A rating somebody gave it, rather than one it was built to.
|
|
146
|
-
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
147
|
-
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
148
|
-
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
149
|
-
// The same score with the word "rating" nowhere near it: "4.8 out of 5".
|
|
150
|
-
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
151
|
-
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
152
|
-
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
153
|
-
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
154
|
-
// How many other people bought or liked it is a customer claim too.
|
|
155
|
-
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
156
|
-
// The words a model reaches for when "best" is banned. Each branch carries its own
|
|
157
|
-
// \b — one in front of the group demands a word character before the #, never there.
|
|
158
|
-
/(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
|
|
159
|
-
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
{
|
|
163
|
-
kind: 'price',
|
|
164
|
-
patterns: [
|
|
165
|
-
// No digit beside it: asking for one let "$thirty-nine and it is yours" through.
|
|
166
|
-
/[$£€¥₹]/,
|
|
167
|
-
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
168
|
-
// The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
|
|
169
|
-
/\bdollars?\b|\beuros?\b/,
|
|
170
|
-
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
171
|
-
/\b(?:usd|eur|gbp)\s?\d/,
|
|
172
|
-
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
173
|
-
/\bkr\s?\d|\d\s?kr\b/,
|
|
174
|
-
/\bpric(?:e|es|ed|ing)\b/,
|
|
175
|
-
// A before-and-after is a price claim even with no currency on it.
|
|
176
|
-
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
177
|
-
// "does not feel cheap" is about quality. Only the comparison is money.
|
|
178
|
-
/\bcheap(?:er|est)\b/,
|
|
179
|
-
/\baffordable\b|\bbargain\b/,
|
|
180
|
-
// "at no cost to comfort" is not money. "costs less" is.
|
|
181
|
-
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
182
|
-
/\blow(?:er)?[\s-]cost\b/,
|
|
183
|
-
// "saves weight" and "saves on weight" are both about grams.
|
|
184
|
-
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
185
|
-
],
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
// A percentage in outdoor copy is a specification far more often than it is
|
|
189
|
-
// money off, so it only counts beside a money word: "20% off", not
|
|
190
|
-
// "100% recycled".
|
|
191
|
-
kind: 'discount',
|
|
192
|
-
patterns: [
|
|
193
|
-
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
194
|
-
/\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
|
|
195
|
-
/\bdiscount(?:s|ed)?\b/,
|
|
196
|
-
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
197
|
-
/\bhalf[\s-]?(?:price|off)\b/,
|
|
198
|
-
// Money off with no percent sign anywhere on it: "save 20 off".
|
|
199
|
-
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
200
|
-
// "extra clearance for thick socks" is room inside the shoe.
|
|
201
|
-
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
202
|
-
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
203
|
-
// reduced price, or a reduction with a date on it, is money off.
|
|
204
|
-
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
205
|
-
],
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
// When it turns up, not what turns up. "ships in a recycled box" and
|
|
209
|
-
// "arrives flat-packed" describe the thing, so the verb alone is not enough
|
|
210
|
-
// — it needs a day or a date beside it.
|
|
211
|
-
kind: 'delivery',
|
|
212
|
-
patterns: [
|
|
213
|
-
/\bdelivery\b|\bshipping\b/,
|
|
214
|
-
/\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
|
|
215
|
-
/\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/,
|
|
216
|
-
/\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
|
|
217
|
-
/\bin time for\b/,
|
|
218
|
-
],
|
|
219
|
-
},
|
|
220
|
-
{
|
|
221
|
-
kind: 'stock',
|
|
222
|
-
patterns: [
|
|
223
|
-
/\b(?:in|out of|low on) stock\b/,
|
|
224
|
-
/\blimited stock\b/,
|
|
225
|
-
/\brestocked?\b|\bsold out\b/,
|
|
226
|
-
// "the last few miles" is a distance, so a count needs "left" after it.
|
|
227
|
-
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
228
|
-
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
229
|
-
],
|
|
230
|
-
},
|
|
231
|
-
];
|
|
232
|
-
/**
|
|
233
|
-
* Letters that render as a Latin letter but are not one. A Cyrillic о in
|
|
234
|
-
* "in stоck" would otherwise carry a stock claim straight past the rule written
|
|
235
|
-
* for it, and the shopper would read the claim anyway.
|
|
236
|
-
*/
|
|
237
|
-
const CONFUSABLES = {
|
|
238
|
-
а: 'a',
|
|
239
|
-
в: 'b',
|
|
240
|
-
е: 'e',
|
|
241
|
-
к: 'k',
|
|
242
|
-
м: 'm',
|
|
243
|
-
н: 'h',
|
|
244
|
-
о: 'o',
|
|
245
|
-
р: 'p',
|
|
246
|
-
с: 'c',
|
|
247
|
-
т: 't',
|
|
248
|
-
х: 'x',
|
|
249
|
-
у: 'y',
|
|
250
|
-
ѕ: 's',
|
|
251
|
-
і: 'i',
|
|
252
|
-
ј: 'j',
|
|
253
|
-
ԁ: 'd',
|
|
254
|
-
һ: 'h',
|
|
255
|
-
ӏ: 'l',
|
|
256
|
-
α: 'a',
|
|
257
|
-
ε: 'e',
|
|
258
|
-
ι: 'i',
|
|
259
|
-
κ: 'k',
|
|
260
|
-
ν: 'v',
|
|
261
|
-
ο: 'o',
|
|
262
|
-
ρ: 'p',
|
|
263
|
-
τ: 't',
|
|
264
|
-
υ: 'u',
|
|
265
|
-
χ: 'x',
|
|
266
|
-
ϲ: 'c',
|
|
267
|
-
};
|
|
268
|
-
/**
|
|
269
|
-
* Characters that render as nothing: format characters, default-ignorable code
|
|
270
|
-
* points and the controls. Cf and Default_Ignorable each hold characters the
|
|
271
|
-
* other does not.
|
|
272
|
-
*
|
|
273
|
-
* The same class @rudra-js/attested strips in its own `hidden.ts`. The second copy stays
|
|
274
|
-
* now that core calls attested, because it feeds core's own patterns and those reach two
|
|
275
|
-
* things attested's phrase list does not hold: "20% οff" spelled with a Greek omicron,
|
|
276
|
-
* and "PRİCED to move". Widening one and not the other is still the mistake to watch for.
|
|
277
|
-
*/
|
|
278
|
-
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
279
|
-
/**
|
|
280
|
-
* The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
|
|
281
|
-
*
|
|
282
|
-
* No test pins this line, and none can: clamping collapses every run of whitespace
|
|
283
|
-
* before a claim is screened, so nothing here ever sees one. It is kept so the class
|
|
284
|
-
* reads the same in both copies.
|
|
285
|
-
*/
|
|
286
|
-
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
287
|
-
/**
|
|
288
|
-
* Marks that hang on the character before them instead of taking a column of their
|
|
289
|
-
* own. Dropped after composition, so an accented e keeps its accent while an
|
|
290
|
-
* overline, which composes with nothing, cannot hide a word from its own rule.
|
|
291
|
-
*/
|
|
292
|
-
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
293
|
-
/**
|
|
294
|
-
* One shape for the text before the patterns read it, so a word that renders as
|
|
295
|
-
* "in stock" is read as "in stock" however it was spelled.
|
|
296
|
-
*
|
|
297
|
-
* Only the reading is normalised. What renders is what the model wrote.
|
|
298
|
-
*/
|
|
299
|
-
function normaliseForClaims(text) {
|
|
300
|
-
const lowered = text
|
|
301
|
-
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
302
|
-
.normalize('NFKC')
|
|
303
|
-
.toLowerCase()
|
|
304
|
-
.normalize('NFC')
|
|
305
|
-
.replace(MARKS, '');
|
|
306
|
-
let folded = '';
|
|
307
|
-
for (const char of lowered)
|
|
308
|
-
folded += CONFUSABLES[char] ?? char;
|
|
309
|
-
return folded;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Wording attested bans that this project has already ruled is not a claim. An allowance
|
|
313
|
-
* forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
|
|
314
|
-
* clearance for" — forgives whatever follows it. Exported so a test can hold the list
|
|
315
|
-
* against attested's own.
|
|
316
|
-
*/
|
|
317
|
-
export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
|
|
318
|
-
/** Only a string with one of these in it can stand behind a numeral. */
|
|
319
|
-
const DIGIT = /\p{Nd}/u;
|
|
320
|
-
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
321
74
|
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
322
75
|
const NO_FACTS = [];
|
|
323
|
-
/** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
|
|
324
|
-
const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
|
|
325
|
-
/** Wide enough for every finite JavaScript number, and for anything a shop sells. */
|
|
326
|
-
const MAX_EXPONENT = 1000;
|
|
327
|
-
// `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
|
|
328
|
-
// attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
|
|
329
|
-
function canLayOut(fact) {
|
|
330
|
-
const match = BARE_EXPONENT.exec(fact.trim());
|
|
331
|
-
if (match === null)
|
|
332
|
-
return true;
|
|
333
|
-
return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* The numbers the shop published about one product: its category and tags, only the strings
|
|
337
|
-
* carrying a digit. Exported so a test can pin what never reaches attested.
|
|
338
|
-
*/
|
|
339
|
-
export function productFacts(product) {
|
|
340
|
-
const facts = [];
|
|
341
|
-
if (DIGIT.test(product.category) && canLayOut(product.category))
|
|
342
|
-
facts.push(product.category);
|
|
343
|
-
for (const tag of product.tags) {
|
|
344
|
-
if (DIGIT.test(tag) && canLayOut(tag))
|
|
345
|
-
facts.push(tag);
|
|
346
|
-
}
|
|
347
|
-
return facts;
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* The shop's own numbers for this request, for attested to check numerals against.
|
|
351
|
-
*
|
|
352
|
-
* `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
|
|
353
|
-
* means a product the model never saw. A title and a rating it is shown but told never to
|
|
354
|
-
* repeat, so a numeral out of one is one it was told not to write. The browsed category comes
|
|
355
|
-
* from the request rather than the catalogue, so a host passing a URL segment through would
|
|
356
|
-
* hand this list to whoever types the URL.
|
|
357
|
-
*
|
|
358
|
-
* `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
|
|
359
|
-
* written about another. A `reason` and a `badge` read `bySku`, because they sit under a
|
|
360
|
-
* named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
|
|
361
|
-
*/
|
|
362
|
-
function hostFacts(input) {
|
|
363
|
-
const pooled = new Set();
|
|
364
|
-
const bySku = new Map();
|
|
365
|
-
for (const product of offeredCandidates(input)) {
|
|
366
|
-
const own = productFacts(product);
|
|
367
|
-
bySku.set(product.sku, own);
|
|
368
|
-
for (const fact of own)
|
|
369
|
-
pooled.add(fact);
|
|
370
|
-
}
|
|
371
|
-
return { pooled: [...pooled], bySku };
|
|
372
|
-
}
|
|
373
|
-
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
374
|
-
function claimIn(text) {
|
|
375
|
-
const normalised = normaliseForClaims(text);
|
|
376
|
-
for (const claim of CLAIM_PATTERNS) {
|
|
377
|
-
for (const pattern of claim.patterns) {
|
|
378
|
-
if (pattern.test(normalised))
|
|
379
|
-
return claim.kind;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
return null;
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* The running state of one reconciliation pass: what has been placed, how much
|
|
386
|
-
* of the item budget is left, and what was changed along the way.
|
|
387
|
-
*
|
|
388
|
-
* This is deliberately one named thing rather than three parameters threaded
|
|
389
|
-
* through every function. The budget and the de-duplication set are global to a
|
|
390
|
-
* spec, not to a block, which is the part that is easy to get wrong.
|
|
391
|
-
*/
|
|
392
76
|
function createPlacementTracker(maxItems, facts) {
|
|
393
77
|
const placedSkus = new Set();
|
|
394
78
|
const violations = [];
|
|
@@ -410,30 +94,19 @@ function createPlacementTracker(maxItems, facts) {
|
|
|
410
94
|
},
|
|
411
95
|
};
|
|
412
96
|
}
|
|
413
|
-
|
|
414
|
-
* Drops text that makes a claim we cannot check, and names what it claimed.
|
|
415
|
-
*
|
|
416
|
-
* Runs after clamping, so what is screened is exactly what would have rendered.
|
|
417
|
-
* Dropping means what it means everywhere else here: this field becomes null
|
|
418
|
-
* and the rest of the block carries on.
|
|
419
|
-
*
|
|
420
|
-
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
421
|
-
* is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
|
|
422
|
-
* is a second denylist and the weakest, so it answers last. `facts` defaults to the pooled
|
|
423
|
-
* list, and a field that names one product passes that product's own.
|
|
424
|
-
*/
|
|
425
|
-
function screenClaim(value, field, tracker, facts = tracker.facts) {
|
|
97
|
+
function screenClaim(value, limit, field, tracker, facts = tracker.facts) {
|
|
426
98
|
if (value === null)
|
|
427
99
|
return null;
|
|
428
|
-
const
|
|
100
|
+
const clamped = clamp(value, limit);
|
|
101
|
+
if (clamped.length === 0)
|
|
102
|
+
return null;
|
|
103
|
+
const kind = claimIn(value) ?? claimIn(clamped);
|
|
429
104
|
if (kind !== null) {
|
|
430
105
|
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
431
106
|
return null;
|
|
432
107
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const weighed = NUMERAL.test(value) ? facts : NO_FACTS;
|
|
436
|
-
const result = verify(value, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
108
|
+
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
109
|
+
const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
437
110
|
if (!result.quantity.supported) {
|
|
438
111
|
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
439
112
|
return null;
|
|
@@ -442,31 +115,13 @@ function screenClaim(value, field, tracker, facts = tracker.facts) {
|
|
|
442
115
|
tracker.record(`unverifiable-claim:wording:${field}`);
|
|
443
116
|
return null;
|
|
444
117
|
}
|
|
445
|
-
return
|
|
118
|
+
return clamped;
|
|
446
119
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
* to the rule that already drops a block, or a whole generation, whose text
|
|
450
|
-
* clamps to nothing — so a banner reading "20% off" disappears rather than
|
|
451
|
-
* rendering blank.
|
|
452
|
-
*/
|
|
453
|
-
function screenRequired(value, field, tracker) {
|
|
454
|
-
return screenClaim(value, field, tracker) ?? '';
|
|
120
|
+
function screenRequired(value, limit, field, tracker) {
|
|
121
|
+
return screenClaim(value, limit, field, tracker) ?? '';
|
|
455
122
|
}
|
|
456
|
-
/**
|
|
457
|
-
* Decides whether one SKU may be placed, and names the reason when it may not.
|
|
458
|
-
* Does not consume budget — the caller does that once it commits.
|
|
459
|
-
*
|
|
460
|
-
* Order matters. The budget is checked last because it is the least specific
|
|
461
|
-
* cause: a hallucinated SKU that arrives after the budget is spent is still a
|
|
462
|
-
* hallucination, and reporting it as `budget:dropped` would understate how
|
|
463
|
-
* often the model invents products. These strings are the evaluation signal,
|
|
464
|
-
* so each one has to name the fault that actually fired.
|
|
465
|
-
*/
|
|
466
123
|
function rejectionFor(sku, allowlist, tracker) {
|
|
467
|
-
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
468
124
|
const named = clamp(sku, CLAMP.violationSku);
|
|
469
|
-
// Either hallucinated or out of stock. Either way it cannot render.
|
|
470
125
|
if (!allowlist.allowed.has(sku))
|
|
471
126
|
return `unknown-sku:${named}`;
|
|
472
127
|
if (allowlist.blocked.has(sku))
|
|
@@ -477,7 +132,7 @@ function rejectionFor(sku, allowlist, tracker) {
|
|
|
477
132
|
return `budget:dropped:${named}`;
|
|
478
133
|
return null;
|
|
479
134
|
}
|
|
480
|
-
function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourReasons) {
|
|
135
|
+
function reconcileItems(items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons) {
|
|
481
136
|
const kept = [];
|
|
482
137
|
for (const item of items) {
|
|
483
138
|
const rejection = rejectionFor(item.sku, allowlist, tracker);
|
|
@@ -486,30 +141,27 @@ function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourR
|
|
|
486
141
|
continue;
|
|
487
142
|
}
|
|
488
143
|
const product = candidatesBySku.get(item.sku);
|
|
489
|
-
// rejectionFor already proved the SKU is an in-stock candidate.
|
|
490
144
|
if (!product)
|
|
491
145
|
continue;
|
|
492
146
|
tracker.place(item.sku);
|
|
493
|
-
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
147
|
+
const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
|
|
494
148
|
if (!hasSupportedBasis)
|
|
495
149
|
tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
496
|
-
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
497
150
|
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
498
151
|
const own = tracker.factsFor(item.sku);
|
|
152
|
+
let reason = null;
|
|
153
|
+
let badge = null;
|
|
154
|
+
if (hasSupportedBasis) {
|
|
155
|
+
reason = isOurs
|
|
156
|
+
? clamp(item.reason ?? '', CLAMP.reason) || null
|
|
157
|
+
: screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
|
|
158
|
+
badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
|
|
159
|
+
}
|
|
499
160
|
kept.push({
|
|
500
161
|
sku: item.sku,
|
|
501
162
|
basis: hasSupportedBasis ? item.basis : 'popular',
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
reason: hasSupportedBasis
|
|
505
|
-
? isOurs
|
|
506
|
-
? clampNullable(item.reason, CLAMP.reason)
|
|
507
|
-
: screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker, own)
|
|
508
|
-
: null,
|
|
509
|
-
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
510
|
-
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
511
|
-
// read for claims like every other sentence the model writes.
|
|
512
|
-
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
|
|
163
|
+
reason,
|
|
164
|
+
badge,
|
|
513
165
|
emphasis: item.emphasis,
|
|
514
166
|
});
|
|
515
167
|
}
|
|
@@ -527,7 +179,6 @@ function fitOf(bundle, digest, candidatesBySku) {
|
|
|
527
179
|
}
|
|
528
180
|
return fit;
|
|
529
181
|
}
|
|
530
|
-
/** In the cart beats recently viewed, which beats the category being looked at. */
|
|
531
182
|
function isBetterFit(fit, best) {
|
|
532
183
|
if (!best)
|
|
533
184
|
return true;
|
|
@@ -541,16 +192,14 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
541
192
|
let best;
|
|
542
193
|
let bestFit;
|
|
543
194
|
for (const bundle of bundles) {
|
|
544
|
-
// A bundle needs room for every product at once.
|
|
545
195
|
if (bundle.skus.length > tracker.remaining)
|
|
546
196
|
continue;
|
|
547
197
|
let isPlaceable = true;
|
|
548
198
|
for (const sku of bundle.skus) {
|
|
549
199
|
if (!allowlist.allowed.has(sku))
|
|
550
200
|
isPlaceable = false;
|
|
551
|
-
if (
|
|
201
|
+
if (allowlist.blockedInBundle.has(sku))
|
|
552
202
|
isPlaceable = false;
|
|
553
|
-
// Already shown by an earlier block — twice on a page looks broken.
|
|
554
203
|
if (tracker.hasPlaced(sku))
|
|
555
204
|
isPlaceable = false;
|
|
556
205
|
}
|
|
@@ -564,35 +213,16 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
564
213
|
}
|
|
565
214
|
return best;
|
|
566
215
|
}
|
|
567
|
-
/**
|
|
568
|
-
* The set this shopper should get, decided before anything is placed.
|
|
569
|
-
*
|
|
570
|
-
* The generator needs the answer early, so it can keep the set's products out
|
|
571
|
-
* of the grid and keep room for them. `chooseBundle` stays private: this hands
|
|
572
|
-
* out the choice, not the machinery behind it.
|
|
573
|
-
*
|
|
574
|
-
* `spokenFor` is what the blocks above the bundle block will have placed by the
|
|
575
|
-
* time it is reached. Placing it here first is what makes the two choices agree:
|
|
576
|
-
* a set is only pre-chosen if reconciliation could still reach for it.
|
|
577
|
-
*/
|
|
578
216
|
export function bundleForShopper(input, digest, spokenFor) {
|
|
579
|
-
const allowlist = buildAllowlist(input
|
|
217
|
+
const allowlist = buildAllowlist(input);
|
|
580
218
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
581
219
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
582
220
|
for (const sku of spokenFor)
|
|
583
221
|
tracker.place(sku);
|
|
584
222
|
return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
|
|
585
223
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
*
|
|
589
|
-
* A hero keeps the product the model named — its headline was written about
|
|
590
|
-
* that product — so it spends a slot of the item budget the grid cannot have.
|
|
591
|
-
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
592
|
-
* counted here either.
|
|
593
|
-
*/
|
|
594
|
-
export function placeableHeroSkus(blocks, input, digest) {
|
|
595
|
-
const allowlist = buildAllowlist(input, digest);
|
|
224
|
+
export function placeableHeroSkus(blocks, input) {
|
|
225
|
+
const allowlist = buildAllowlist(input);
|
|
596
226
|
const skus = [];
|
|
597
227
|
for (const block of blocks) {
|
|
598
228
|
if (block.kind !== 'hero')
|
|
@@ -601,14 +231,13 @@ export function placeableHeroSkus(blocks, input, digest) {
|
|
|
601
231
|
continue;
|
|
602
232
|
if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku))
|
|
603
233
|
continue;
|
|
604
|
-
// Two heroes naming the same product: only the first one gets to place it.
|
|
605
234
|
if (skus.includes(block.sku))
|
|
606
235
|
continue;
|
|
607
236
|
skus.push(block.sku);
|
|
608
237
|
}
|
|
609
238
|
return skus;
|
|
610
239
|
}
|
|
611
|
-
function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, tracker, ourReasons) {
|
|
240
|
+
function reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, bundles, tracker, ourReasons) {
|
|
612
241
|
switch (block.kind) {
|
|
613
242
|
case 'hero': {
|
|
614
243
|
let sku = block.sku;
|
|
@@ -616,17 +245,13 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
616
245
|
const rejection = rejectionFor(sku, allowlist, tracker);
|
|
617
246
|
if (rejection) {
|
|
618
247
|
tracker.record(rejection);
|
|
619
|
-
// A hero without its product is still a legitimate headline.
|
|
620
248
|
sku = null;
|
|
621
249
|
}
|
|
622
250
|
else {
|
|
623
251
|
tracker.place(sku);
|
|
624
252
|
}
|
|
625
253
|
}
|
|
626
|
-
|
|
627
|
-
// A hero with no headline and no product is the same empty region, and
|
|
628
|
-
// it would otherwise render above real content.
|
|
629
|
-
const headline = screenRequired(clamp(block.headline, CLAMP.headline), 'hero-headline', tracker);
|
|
254
|
+
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
|
|
630
255
|
if (headline.length === 0 && sku === null) {
|
|
631
256
|
tracker.record('empty-block:hero');
|
|
632
257
|
return null;
|
|
@@ -634,39 +259,38 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
634
259
|
return {
|
|
635
260
|
kind: 'hero',
|
|
636
261
|
headline,
|
|
637
|
-
body: screenClaim(
|
|
262
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
|
|
638
263
|
sku,
|
|
639
|
-
ctaLabel: screenClaim(
|
|
264
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
|
|
640
265
|
};
|
|
641
266
|
}
|
|
642
267
|
case 'grid': {
|
|
643
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
|
|
268
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
644
269
|
if (items.length === 0) {
|
|
645
270
|
tracker.record('empty-block:grid');
|
|
646
271
|
return null;
|
|
647
272
|
}
|
|
648
273
|
return {
|
|
649
274
|
kind: 'grid',
|
|
650
|
-
title: screenClaim(
|
|
651
|
-
// Never leave a grid wider than it has items to fill.
|
|
275
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
|
|
652
276
|
columns: Math.min(block.columns, Math.max(2, items.length)),
|
|
653
277
|
items,
|
|
654
278
|
};
|
|
655
279
|
}
|
|
656
280
|
case 'carousel': {
|
|
657
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
|
|
281
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
658
282
|
if (items.length === 0) {
|
|
659
283
|
tracker.record('empty-block:carousel');
|
|
660
284
|
return null;
|
|
661
285
|
}
|
|
662
286
|
return {
|
|
663
287
|
kind: 'carousel',
|
|
664
|
-
title: screenClaim(
|
|
288
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
|
|
665
289
|
items,
|
|
666
290
|
};
|
|
667
291
|
}
|
|
668
292
|
case 'banner': {
|
|
669
|
-
const text = screenRequired(
|
|
293
|
+
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
|
|
670
294
|
if (text.length === 0) {
|
|
671
295
|
tracker.record('empty-block:banner');
|
|
672
296
|
return null;
|
|
@@ -675,18 +299,18 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
675
299
|
kind: 'banner',
|
|
676
300
|
tone: block.tone,
|
|
677
301
|
text,
|
|
678
|
-
ctaLabel: screenClaim(
|
|
302
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
|
|
679
303
|
};
|
|
680
304
|
}
|
|
681
305
|
case 'copy': {
|
|
682
|
-
const body = screenRequired(
|
|
306
|
+
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
|
|
683
307
|
if (body.length === 0) {
|
|
684
308
|
tracker.record('empty-block:copy');
|
|
685
309
|
return null;
|
|
686
310
|
}
|
|
687
311
|
return {
|
|
688
312
|
kind: 'copy',
|
|
689
|
-
title: screenClaim(
|
|
313
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
|
|
690
314
|
body,
|
|
691
315
|
};
|
|
692
316
|
}
|
|
@@ -700,55 +324,41 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
700
324
|
tracker.place(sku);
|
|
701
325
|
return {
|
|
702
326
|
kind: 'bundle',
|
|
703
|
-
title: screenClaim(
|
|
704
|
-
body: screenClaim(
|
|
705
|
-
ctaLabel: screenClaim(
|
|
706
|
-
// The model's bundleId is ignored on purpose.
|
|
327
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
|
|
328
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
|
|
329
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
|
|
707
330
|
bundleId: chosen.id,
|
|
708
331
|
};
|
|
709
332
|
}
|
|
710
333
|
}
|
|
711
334
|
}
|
|
712
|
-
/** True when a spec contains at least one block that actually shows a product. */
|
|
713
335
|
function showsAnyProduct(blocks) {
|
|
714
336
|
return blocks.some((block) => (block.kind === 'grid' && block.items.length > 0) ||
|
|
715
337
|
(block.kind === 'carousel' && block.items.length > 0) ||
|
|
716
338
|
(block.kind === 'hero' && block.sku !== null) ||
|
|
717
|
-
// A bundle shows products too, so it counts the same as grid/carousel/hero.
|
|
718
339
|
(block.kind === 'bundle' && block.bundleId !== null));
|
|
719
340
|
}
|
|
720
|
-
export function reconcileSpec(generated, input, digest,
|
|
721
|
-
|
|
722
|
-
* The reason this request wrote itself, by SKU: the host's own `reason` on a
|
|
723
|
-
* candidate, or the sentence the selector wrote when there was none. Neither is the
|
|
724
|
-
* model's words, and the deterministic component renders the same sentence unscreened.
|
|
725
|
-
*
|
|
726
|
-
* Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
727
|
-
* reason is the model's, including one that happens to read the same.
|
|
728
|
-
*/
|
|
729
|
-
ourReasons = new Map()) {
|
|
730
|
-
const allowlist = buildAllowlist(input, digest);
|
|
341
|
+
export function reconcileSpec(generated, input, digest, ourReasons = new Map()) {
|
|
342
|
+
const allowlist = buildAllowlist(input);
|
|
731
343
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
344
|
+
const engaged = engagedCategories(input);
|
|
732
345
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
733
346
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
734
347
|
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
735
348
|
}
|
|
736
349
|
const blocks = [];
|
|
737
|
-
for (const block of generated.blocks
|
|
738
|
-
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, input.bundles, tracker, ourReasons);
|
|
350
|
+
for (const block of capBlocks(generated.blocks)) {
|
|
351
|
+
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, input.bundles, tracker, ourReasons);
|
|
739
352
|
if (reconciled !== null)
|
|
740
353
|
blocks.push(reconciled);
|
|
741
354
|
}
|
|
742
355
|
const spec = {
|
|
743
356
|
tone: generated.tone,
|
|
744
|
-
headline: screenRequired(
|
|
745
|
-
subheadline: screenClaim(
|
|
357
|
+
headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
|
|
358
|
+
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
|
|
746
359
|
blocks,
|
|
747
|
-
rationale: screenRequired(
|
|
360
|
+
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
|
|
748
361
|
};
|
|
749
|
-
// A component that recommends nothing is worse than no component at all.
|
|
750
|
-
// Recorded separately: "no products survived" and "the model returned no
|
|
751
|
-
// headline" are different failures and want different fixes.
|
|
752
362
|
if (!showsAnyProduct(blocks))
|
|
753
363
|
tracker.record('unusable:no-products');
|
|
754
364
|
if (spec.headline.length === 0)
|