@rudra-js/core 0.5.0 → 0.6.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 +51 -4
- package/dist/claim-screening.d.ts +33 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +226 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +32 -98
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -49
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -17
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +3 -0
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +3 -13
- 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 +4 -3
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +1 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +38 -18
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -6
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -3
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +1 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +1 -8
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +10 -22
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +116 -399
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +10 -0
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +38 -28
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +14 -2
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +14 -24
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +15 -13
- package/dist/tracking-input.js.map +1 -1
- package/package.json +4 -4
- package/src/claim-screening.ts +242 -0
- package/src/component-generator.ts +37 -123
- package/src/component-spec.ts +5 -18
- package/src/fallback-component.ts +3 -14
- package/src/fit-to-shopper.ts +9 -4
- package/src/index.ts +1 -0
- package/src/model-prompt.ts +42 -20
- package/src/product-selection.ts +1 -9
- package/src/provider.ts +1 -8
- package/src/reconciliation.ts +135 -430
- package/src/signal-digest.ts +41 -31
- package/src/spec-cache.ts +27 -24
- package/src/tracking-input.ts +16 -13
package/dist/reconciliation.js
CHANGED
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import { verify } from '@rudra-js/attested';
|
|
2
|
-
import {
|
|
2
|
+
import { ALLOWED_PHRASES, claimIn, hostFacts } from './claim-screening.js';
|
|
3
|
+
import { engagedCategories } from './signal-digest.js';
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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`.
|
|
5
|
+
* Nothing here trusts the model. Repair, not rejection, is the default: text is
|
|
6
|
+
* truncated and unverifiable claims are downgraded, and only an empty result
|
|
7
|
+
* fails outright.
|
|
24
8
|
*/
|
|
9
|
+
/** Truncation ceilings. Not in the schema — a provider's strict mode rejects length bounds. See `component-spec.ts`. */
|
|
25
10
|
const CLAMP = {
|
|
26
11
|
headline: 90,
|
|
27
12
|
subheadline: 140,
|
|
@@ -35,19 +20,17 @@ const CLAMP = {
|
|
|
35
20
|
// Not rendered anywhere — this is the SKU as it reads back in a violation.
|
|
36
21
|
violationSku: 32,
|
|
37
22
|
};
|
|
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
|
-
*/
|
|
23
|
+
/** More than this and the component stops being a component. */
|
|
44
24
|
export const MAX_BLOCKS = 4;
|
|
25
|
+
/** The fill pass calls this too: one past the cap never renders, so nothing is worth reserving for it. */
|
|
26
|
+
export function capBlocks(blocks) {
|
|
27
|
+
return blocks.slice(0, MAX_BLOCKS);
|
|
28
|
+
}
|
|
45
29
|
function clamp(value, limit) {
|
|
46
30
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
47
31
|
if (collapsed.length <= limit)
|
|
48
32
|
return collapsed;
|
|
49
|
-
// The ellipsis counts against the limit, so leave room for it.
|
|
50
|
-
// clamped string is one character longer than the cap it was clamped to.
|
|
33
|
+
// The ellipsis counts against the limit, so leave room for it.
|
|
51
34
|
const cut = collapsed.slice(0, limit - 1);
|
|
52
35
|
const lastSpace = cut.lastIndexOf(' ');
|
|
53
36
|
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
@@ -59,45 +42,55 @@ function clampNullable(value, limit) {
|
|
|
59
42
|
const clamped = clamp(value, limit);
|
|
60
43
|
return clamped.length === 0 ? null : clamped;
|
|
61
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* A thumbs-down, and the product on the screen right now. No block may hold one
|
|
47
|
+
* of these, a bundle included.
|
|
48
|
+
*
|
|
49
|
+
* Read from the payload rather than the digest on purpose. `DIGEST_LIMITS`
|
|
50
|
+
* trims each history to what fits in a prompt, and a shopper with thirteen
|
|
51
|
+
* dislikes would otherwise be shown the thirteenth. The cap exists to bound
|
|
52
|
+
* what the model is told, not what the shopper may be shown.
|
|
53
|
+
*/
|
|
54
|
+
function refusedEverywhere(input) {
|
|
55
|
+
const refused = new Set();
|
|
56
|
+
for (const signal of input.signals.dislikes)
|
|
57
|
+
refused.add(signal.sku);
|
|
58
|
+
if (input.context.currentSku)
|
|
59
|
+
refused.add(input.context.currentSku);
|
|
60
|
+
return refused;
|
|
61
|
+
}
|
|
62
62
|
/**
|
|
63
63
|
* SKUs that must never be recommended, whatever chose them.
|
|
64
64
|
*
|
|
65
|
+
* Everything above plus what the shopper already has, read from the payload for
|
|
66
|
+
* the same reason: a shopper with nine purchases must not be sold the ninth back.
|
|
67
|
+
*
|
|
65
68
|
* Exported because the deterministic selector applies the same rule when it
|
|
66
69
|
* picks. Two copies of "never recommend these" would drift, and the pair that
|
|
67
70
|
* drifted would be the model path and the fallback path — the two whose
|
|
68
71
|
* comparability the whole evaluation depends on.
|
|
69
72
|
*/
|
|
70
|
-
export function neverRecommend(
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (digest.currentSku)
|
|
77
|
-
blocked.add(digest.currentSku);
|
|
73
|
+
export function neverRecommend(input) {
|
|
74
|
+
const { signals } = input;
|
|
75
|
+
const blocked = refusedEverywhere(input);
|
|
76
|
+
for (const signal of [...signals.lastPurchased, ...signals.cart]) {
|
|
77
|
+
blocked.add(signal.sku);
|
|
78
|
+
}
|
|
78
79
|
return blocked;
|
|
79
80
|
}
|
|
80
|
-
function buildAllowlist(input
|
|
81
|
+
function buildAllowlist(input) {
|
|
81
82
|
const allowed = new Set();
|
|
82
83
|
for (const product of input.candidates) {
|
|
83
|
-
// An out-of-stock candidate is not a recommendation, it is a dead end.
|
|
84
84
|
if (product.isInStock)
|
|
85
85
|
allowed.add(product.sku);
|
|
86
86
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
return {
|
|
88
|
+
allowed,
|
|
89
|
+
blocked: neverRecommend(input),
|
|
90
|
+
blockedInBundle: refusedEverywhere(input),
|
|
91
|
+
};
|
|
90
92
|
}
|
|
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) {
|
|
93
|
+
function verifyBasis(basis, product, digest, engaged) {
|
|
101
94
|
switch (basis) {
|
|
102
95
|
case 'most_viewed':
|
|
103
96
|
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
@@ -106,7 +99,11 @@ function verifyBasis(basis, product, digest) {
|
|
|
106
99
|
case 'complements_purchase':
|
|
107
100
|
return digest.purchasedSkus.length > 0;
|
|
108
101
|
case 'liked_category':
|
|
109
|
-
|
|
102
|
+
// Affinity alone is not enough: it scores the category the shopper is
|
|
103
|
+
// standing in, and standing somewhere is not liking it. That claim is
|
|
104
|
+
// `similar_to_current`, which says so honestly.
|
|
105
|
+
return (engaged.has(product.category) &&
|
|
106
|
+
digest.categoryAffinity.some((affinity) => affinity.category === product.category));
|
|
110
107
|
case 'similar_to_current':
|
|
111
108
|
return digest.currentCategory === product.category;
|
|
112
109
|
case 'popular':
|
|
@@ -114,281 +111,10 @@ function verifyBasis(basis, product, digest) {
|
|
|
114
111
|
return true;
|
|
115
112
|
}
|
|
116
113
|
}
|
|
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
114
|
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
321
115
|
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
322
116
|
const NO_FACTS = [];
|
|
323
|
-
/**
|
|
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
|
-
*/
|
|
117
|
+
/** The budget and the de-duplication set are global to a spec, not to a block. */
|
|
392
118
|
function createPlacementTracker(maxItems, facts) {
|
|
393
119
|
const placedSkus = new Set();
|
|
394
120
|
const violations = [];
|
|
@@ -411,29 +137,29 @@ function createPlacementTracker(maxItems, facts) {
|
|
|
411
137
|
};
|
|
412
138
|
}
|
|
413
139
|
/**
|
|
414
|
-
*
|
|
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.
|
|
140
|
+
* Clamps, then screens. The cap has to happen here: clamping first can cut the word that
|
|
141
|
+
* makes a claim a claim, so "Ridge picks, only 2 left!" renders past the stock pattern.
|
|
419
142
|
*
|
|
420
143
|
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
421
144
|
* 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.
|
|
423
|
-
* list, and a field that names one product passes that product's own.
|
|
145
|
+
* is a second denylist and the weakest, so it answers last.
|
|
424
146
|
*/
|
|
425
|
-
function screenClaim(value, field, tracker, facts = tracker.facts) {
|
|
147
|
+
function screenClaim(value, limit, field, tracker, facts = tracker.facts) {
|
|
426
148
|
if (value === null)
|
|
427
149
|
return null;
|
|
428
|
-
const
|
|
150
|
+
const clamped = clamp(value, limit);
|
|
151
|
+
if (clamped.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
// Both ways round: clamping collapses whitespace, so "in stock" only reads as a claim after.
|
|
154
|
+
const kind = claimIn(value) ?? claimIn(clamped);
|
|
429
155
|
if (kind !== null) {
|
|
430
156
|
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
431
157
|
return null;
|
|
432
158
|
}
|
|
433
159
|
// Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
|
|
434
160
|
// and reading the fact list to prove that again per field is most of what this costs.
|
|
435
|
-
const weighed = NUMERAL.test(
|
|
436
|
-
const result = verify(
|
|
161
|
+
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
162
|
+
const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
437
163
|
if (!result.quantity.supported) {
|
|
438
164
|
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
439
165
|
return null;
|
|
@@ -442,26 +168,22 @@ function screenClaim(value, field, tracker, facts = tracker.facts) {
|
|
|
442
168
|
tracker.record(`unverifiable-claim:wording:${field}`);
|
|
443
169
|
return null;
|
|
444
170
|
}
|
|
445
|
-
return
|
|
171
|
+
return clamped;
|
|
446
172
|
}
|
|
447
173
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* rendering blank.
|
|
174
|
+
* Emptying a field that cannot be null hands it to the rule that already drops a block,
|
|
175
|
+
* or a whole generation, whose text clamps to nothing — so a banner reading "20% off"
|
|
176
|
+
* disappears rather than rendering blank.
|
|
452
177
|
*/
|
|
453
|
-
function screenRequired(value, field, tracker) {
|
|
454
|
-
return screenClaim(value, field, tracker) ?? '';
|
|
178
|
+
function screenRequired(value, limit, field, tracker) {
|
|
179
|
+
return screenClaim(value, limit, field, tracker) ?? '';
|
|
455
180
|
}
|
|
456
181
|
/**
|
|
457
|
-
* Decides whether one SKU may be placed, and names the reason when it may not.
|
|
458
182
|
* Does not consume budget — the caller does that once it commits.
|
|
459
183
|
*
|
|
460
|
-
* Order matters. The budget is checked last because it is the least specific
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* often the model invents products. These strings are the evaluation signal,
|
|
464
|
-
* so each one has to name the fault that actually fired.
|
|
184
|
+
* Order matters. The budget is checked last because it is the least specific cause:
|
|
185
|
+
* these strings are the evaluation signal, and reporting a hallucinated SKU as
|
|
186
|
+
* `budget:dropped` would understate how often the model invents products.
|
|
465
187
|
*/
|
|
466
188
|
function rejectionFor(sku, allowlist, tracker) {
|
|
467
189
|
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
@@ -477,7 +199,7 @@ function rejectionFor(sku, allowlist, tracker) {
|
|
|
477
199
|
return `budget:dropped:${named}`;
|
|
478
200
|
return null;
|
|
479
201
|
}
|
|
480
|
-
function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourReasons) {
|
|
202
|
+
function reconcileItems(items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons) {
|
|
481
203
|
const kept = [];
|
|
482
204
|
for (const item of items) {
|
|
483
205
|
const rejection = rejectionFor(item.sku, allowlist, tracker);
|
|
@@ -490,26 +212,30 @@ function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, ourR
|
|
|
490
212
|
if (!product)
|
|
491
213
|
continue;
|
|
492
214
|
tracker.place(item.sku);
|
|
493
|
-
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
215
|
+
const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
|
|
494
216
|
if (!hasSupportedBasis)
|
|
495
217
|
tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
496
218
|
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
497
219
|
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
498
220
|
const own = tracker.factsFor(item.sku);
|
|
221
|
+
const clampedReason = clampNullable(item.reason, CLAMP.reason);
|
|
222
|
+
// The prose exists to state the basis. If the basis did not hold, the prose
|
|
223
|
+
// is untrue — and the badge is prose too. "You viewed this" beside a
|
|
224
|
+
// downgraded pick says the same thing the dropped sentence did.
|
|
225
|
+
let reason = null;
|
|
226
|
+
let badge = null;
|
|
227
|
+
if (hasSupportedBasis) {
|
|
228
|
+
reason = isOurs
|
|
229
|
+
? clampedReason
|
|
230
|
+
: screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
|
|
231
|
+
// The schema's own example badge was "Back in stock" — a stock claim, and it renders.
|
|
232
|
+
badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
|
|
233
|
+
}
|
|
499
234
|
kept.push({
|
|
500
235
|
sku: item.sku,
|
|
501
236
|
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),
|
|
237
|
+
reason,
|
|
238
|
+
badge,
|
|
513
239
|
emphasis: item.emphasis,
|
|
514
240
|
});
|
|
515
241
|
}
|
|
@@ -527,7 +253,6 @@ function fitOf(bundle, digest, candidatesBySku) {
|
|
|
527
253
|
}
|
|
528
254
|
return fit;
|
|
529
255
|
}
|
|
530
|
-
/** In the cart beats recently viewed, which beats the category being looked at. */
|
|
531
256
|
function isBetterFit(fit, best) {
|
|
532
257
|
if (!best)
|
|
533
258
|
return true;
|
|
@@ -541,16 +266,14 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
541
266
|
let best;
|
|
542
267
|
let bestFit;
|
|
543
268
|
for (const bundle of bundles) {
|
|
544
|
-
// A bundle needs room for every product at once.
|
|
545
269
|
if (bundle.skus.length > tracker.remaining)
|
|
546
270
|
continue;
|
|
547
271
|
let isPlaceable = true;
|
|
548
272
|
for (const sku of bundle.skus) {
|
|
549
273
|
if (!allowlist.allowed.has(sku))
|
|
550
274
|
isPlaceable = false;
|
|
551
|
-
if (
|
|
275
|
+
if (allowlist.blockedInBundle.has(sku))
|
|
552
276
|
isPlaceable = false;
|
|
553
|
-
// Already shown by an earlier block — twice on a page looks broken.
|
|
554
277
|
if (tracker.hasPlaced(sku))
|
|
555
278
|
isPlaceable = false;
|
|
556
279
|
}
|
|
@@ -576,7 +299,7 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
576
299
|
* a set is only pre-chosen if reconciliation could still reach for it.
|
|
577
300
|
*/
|
|
578
301
|
export function bundleForShopper(input, digest, spokenFor) {
|
|
579
|
-
const allowlist = buildAllowlist(input
|
|
302
|
+
const allowlist = buildAllowlist(input);
|
|
580
303
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
581
304
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
582
305
|
for (const sku of spokenFor)
|
|
@@ -591,8 +314,8 @@ export function bundleForShopper(input, digest, spokenFor) {
|
|
|
591
314
|
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
592
315
|
* counted here either.
|
|
593
316
|
*/
|
|
594
|
-
export function placeableHeroSkus(blocks, input
|
|
595
|
-
const allowlist = buildAllowlist(input
|
|
317
|
+
export function placeableHeroSkus(blocks, input) {
|
|
318
|
+
const allowlist = buildAllowlist(input);
|
|
596
319
|
const skus = [];
|
|
597
320
|
for (const block of blocks) {
|
|
598
321
|
if (block.kind !== 'hero')
|
|
@@ -601,14 +324,13 @@ export function placeableHeroSkus(blocks, input, digest) {
|
|
|
601
324
|
continue;
|
|
602
325
|
if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku))
|
|
603
326
|
continue;
|
|
604
|
-
// Two heroes naming the same product: only the first one gets to place it.
|
|
605
327
|
if (skus.includes(block.sku))
|
|
606
328
|
continue;
|
|
607
329
|
skus.push(block.sku);
|
|
608
330
|
}
|
|
609
331
|
return skus;
|
|
610
332
|
}
|
|
611
|
-
function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, tracker, ourReasons) {
|
|
333
|
+
function reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, bundles, tracker, ourReasons) {
|
|
612
334
|
switch (block.kind) {
|
|
613
335
|
case 'hero': {
|
|
614
336
|
let sku = block.sku;
|
|
@@ -623,10 +345,8 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
623
345
|
tracker.place(sku);
|
|
624
346
|
}
|
|
625
347
|
}
|
|
626
|
-
//
|
|
627
|
-
|
|
628
|
-
// it would otherwise render above real content.
|
|
629
|
-
const headline = screenRequired(clamp(block.headline, CLAMP.headline), 'hero-headline', tracker);
|
|
348
|
+
// A hero with no headline and no product is the empty region every other kind drops for.
|
|
349
|
+
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
|
|
630
350
|
if (headline.length === 0 && sku === null) {
|
|
631
351
|
tracker.record('empty-block:hero');
|
|
632
352
|
return null;
|
|
@@ -634,39 +354,39 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
634
354
|
return {
|
|
635
355
|
kind: 'hero',
|
|
636
356
|
headline,
|
|
637
|
-
body: screenClaim(
|
|
357
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
|
|
638
358
|
sku,
|
|
639
|
-
ctaLabel: screenClaim(
|
|
359
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
|
|
640
360
|
};
|
|
641
361
|
}
|
|
642
362
|
case 'grid': {
|
|
643
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
|
|
363
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
644
364
|
if (items.length === 0) {
|
|
645
365
|
tracker.record('empty-block:grid');
|
|
646
366
|
return null;
|
|
647
367
|
}
|
|
648
368
|
return {
|
|
649
369
|
kind: 'grid',
|
|
650
|
-
title: screenClaim(
|
|
370
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
|
|
651
371
|
// Never leave a grid wider than it has items to fill.
|
|
652
372
|
columns: Math.min(block.columns, Math.max(2, items.length)),
|
|
653
373
|
items,
|
|
654
374
|
};
|
|
655
375
|
}
|
|
656
376
|
case 'carousel': {
|
|
657
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker, ourReasons);
|
|
377
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
658
378
|
if (items.length === 0) {
|
|
659
379
|
tracker.record('empty-block:carousel');
|
|
660
380
|
return null;
|
|
661
381
|
}
|
|
662
382
|
return {
|
|
663
383
|
kind: 'carousel',
|
|
664
|
-
title: screenClaim(
|
|
384
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
|
|
665
385
|
items,
|
|
666
386
|
};
|
|
667
387
|
}
|
|
668
388
|
case 'banner': {
|
|
669
|
-
const text = screenRequired(
|
|
389
|
+
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
|
|
670
390
|
if (text.length === 0) {
|
|
671
391
|
tracker.record('empty-block:banner');
|
|
672
392
|
return null;
|
|
@@ -675,18 +395,18 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
675
395
|
kind: 'banner',
|
|
676
396
|
tone: block.tone,
|
|
677
397
|
text,
|
|
678
|
-
ctaLabel: screenClaim(
|
|
398
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
|
|
679
399
|
};
|
|
680
400
|
}
|
|
681
401
|
case 'copy': {
|
|
682
|
-
const body = screenRequired(
|
|
402
|
+
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
|
|
683
403
|
if (body.length === 0) {
|
|
684
404
|
tracker.record('empty-block:copy');
|
|
685
405
|
return null;
|
|
686
406
|
}
|
|
687
407
|
return {
|
|
688
408
|
kind: 'copy',
|
|
689
|
-
title: screenClaim(
|
|
409
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
|
|
690
410
|
body,
|
|
691
411
|
};
|
|
692
412
|
}
|
|
@@ -700,21 +420,19 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
700
420
|
tracker.place(sku);
|
|
701
421
|
return {
|
|
702
422
|
kind: 'bundle',
|
|
703
|
-
title: screenClaim(
|
|
704
|
-
body: screenClaim(
|
|
705
|
-
ctaLabel: screenClaim(
|
|
423
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
|
|
424
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
|
|
425
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
|
|
706
426
|
// The model's bundleId is ignored on purpose.
|
|
707
427
|
bundleId: chosen.id,
|
|
708
428
|
};
|
|
709
429
|
}
|
|
710
430
|
}
|
|
711
431
|
}
|
|
712
|
-
/** True when a spec contains at least one block that actually shows a product. */
|
|
713
432
|
function showsAnyProduct(blocks) {
|
|
714
433
|
return blocks.some((block) => (block.kind === 'grid' && block.items.length > 0) ||
|
|
715
434
|
(block.kind === 'carousel' && block.items.length > 0) ||
|
|
716
435
|
(block.kind === 'hero' && block.sku !== null) ||
|
|
717
|
-
// A bundle shows products too, so it counts the same as grid/carousel/hero.
|
|
718
436
|
(block.kind === 'bundle' && block.bundleId !== null));
|
|
719
437
|
}
|
|
720
438
|
export function reconcileSpec(generated, input, digest,
|
|
@@ -727,28 +445,27 @@ export function reconcileSpec(generated, input, digest,
|
|
|
727
445
|
* reason is the model's, including one that happens to read the same.
|
|
728
446
|
*/
|
|
729
447
|
ourReasons = new Map()) {
|
|
730
|
-
const allowlist = buildAllowlist(input
|
|
448
|
+
const allowlist = buildAllowlist(input);
|
|
731
449
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
450
|
+
const engaged = engagedCategories(input);
|
|
732
451
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
733
452
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
734
453
|
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
735
454
|
}
|
|
736
455
|
const blocks = [];
|
|
737
|
-
for (const block of generated.blocks
|
|
738
|
-
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, input.bundles, tracker, ourReasons);
|
|
456
|
+
for (const block of capBlocks(generated.blocks)) {
|
|
457
|
+
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, input.bundles, tracker, ourReasons);
|
|
739
458
|
if (reconciled !== null)
|
|
740
459
|
blocks.push(reconciled);
|
|
741
460
|
}
|
|
742
461
|
const spec = {
|
|
743
462
|
tone: generated.tone,
|
|
744
|
-
headline: screenRequired(
|
|
745
|
-
subheadline: screenClaim(
|
|
463
|
+
headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
|
|
464
|
+
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
|
|
746
465
|
blocks,
|
|
747
|
-
rationale: screenRequired(
|
|
466
|
+
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
|
|
748
467
|
};
|
|
749
|
-
//
|
|
750
|
-
// Recorded separately: "no products survived" and "the model returned no
|
|
751
|
-
// headline" are different failures and want different fixes.
|
|
468
|
+
// Recorded separately: no products and no headline are different failures.
|
|
752
469
|
if (!showsAnyProduct(blocks))
|
|
753
470
|
tracker.record('unusable:no-products');
|
|
754
471
|
if (spec.headline.length === 0)
|