@rudra-js/core 0.4.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 +139 -15
- 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 +33 -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 +6 -4
- 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 +13 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +53 -21
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +1 -9
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -4
- 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 +15 -12
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +150 -340
- 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 -2
- package/src/claim-screening.ts +242 -0
- package/src/component-generator.ts +38 -123
- package/src/component-spec.ts +5 -18
- package/src/fallback-component.ts +3 -14
- package/src/fit-to-shopper.ts +11 -4
- package/src/index.ts +1 -0
- package/src/model-prompt.ts +58 -23
- package/src/product-selection.ts +2 -13
- package/src/provider.ts +1 -8
- package/src/reconciliation.ts +178 -359
- 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,25 +1,12 @@
|
|
|
1
|
+
import { verify } from '@rudra-js/attested';
|
|
2
|
+
import { ALLOWED_PHRASES, claimIn, hostFacts } from './claim-screening.js';
|
|
3
|
+
import { engagedCategories } from './signal-digest.js';
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* spec can still name a product that does not exist, one the shopper told us
|
|
6
|
-
* they dislike, one that sold out since the candidate set was assembled, or
|
|
7
|
-
* claim the shopper viewed something they never saw. This pass is where those
|
|
8
|
-
* become impossible.
|
|
9
|
-
*
|
|
10
|
-
* Nothing here trusts the model. A generation that survives every rule and
|
|
11
|
-
* still has nothing to show degrades to `isUsable: false`, and the caller renders
|
|
12
|
-
* the deterministic component instead.
|
|
13
|
-
*
|
|
14
|
-
* Repair, not rejection, is the default. A slightly clipped headline is a better
|
|
15
|
-
* outcome for the shopper than a discarded generation, so text is truncated and
|
|
16
|
-
* unverifiable claims are downgraded. Only an empty result fails outright.
|
|
17
|
-
*/
|
|
18
|
-
/**
|
|
19
|
-
* Length ceilings, applied by truncation. These live here rather than in the
|
|
20
|
-
* schema because a provider's strict structured-output mode rejects string
|
|
21
|
-
* 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.
|
|
22
8
|
*/
|
|
9
|
+
/** Truncation ceilings. Not in the schema — a provider's strict mode rejects length bounds. See `component-spec.ts`. */
|
|
23
10
|
const CLAMP = {
|
|
24
11
|
headline: 90,
|
|
25
12
|
subheadline: 140,
|
|
@@ -33,19 +20,17 @@ const CLAMP = {
|
|
|
33
20
|
// Not rendered anywhere — this is the SKU as it reads back in a violation.
|
|
34
21
|
violationSku: 32,
|
|
35
22
|
};
|
|
36
|
-
/**
|
|
37
|
-
* More than this and the component stops being a component.
|
|
38
|
-
*
|
|
39
|
-
* Exported because the fill pass has to read the same blocks this module will:
|
|
40
|
-
* one past the cap never renders, so nothing is worth reserving for it.
|
|
41
|
-
*/
|
|
23
|
+
/** More than this and the component stops being a component. */
|
|
42
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
|
+
}
|
|
43
29
|
function clamp(value, limit) {
|
|
44
30
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
45
31
|
if (collapsed.length <= limit)
|
|
46
32
|
return collapsed;
|
|
47
|
-
// The ellipsis counts against the limit, so leave room for it.
|
|
48
|
-
// 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.
|
|
49
34
|
const cut = collapsed.slice(0, limit - 1);
|
|
50
35
|
const lastSpace = cut.lastIndexOf(' ');
|
|
51
36
|
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
@@ -57,45 +42,55 @@ function clampNullable(value, limit) {
|
|
|
57
42
|
const clamped = clamp(value, limit);
|
|
58
43
|
return clamped.length === 0 ? null : clamped;
|
|
59
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
|
+
}
|
|
60
62
|
/**
|
|
61
63
|
* SKUs that must never be recommended, whatever chose them.
|
|
62
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
|
+
*
|
|
63
68
|
* Exported because the deterministic selector applies the same rule when it
|
|
64
69
|
* picks. Two copies of "never recommend these" would drift, and the pair that
|
|
65
70
|
* drifted would be the model path and the fallback path — the two whose
|
|
66
71
|
* comparability the whole evaluation depends on.
|
|
67
72
|
*/
|
|
68
|
-
export function neverRecommend(
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (digest.currentSku)
|
|
75
|
-
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
|
+
}
|
|
76
79
|
return blocked;
|
|
77
80
|
}
|
|
78
|
-
function buildAllowlist(input
|
|
81
|
+
function buildAllowlist(input) {
|
|
79
82
|
const allowed = new Set();
|
|
80
83
|
for (const product of input.candidates) {
|
|
81
|
-
// An out-of-stock candidate is not a recommendation, it is a dead end.
|
|
82
84
|
if (product.isInStock)
|
|
83
85
|
allowed.add(product.sku);
|
|
84
86
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
return {
|
|
88
|
+
allowed,
|
|
89
|
+
blocked: neverRecommend(input),
|
|
90
|
+
blockedInBundle: refusedEverywhere(input),
|
|
91
|
+
};
|
|
88
92
|
}
|
|
89
|
-
|
|
90
|
-
* Checks the model's stated reason for a pick against the shopper's actual
|
|
91
|
-
* signals.
|
|
92
|
-
*
|
|
93
|
-
* `basis` is a factual claim — "you viewed this", "this goes with your cart" —
|
|
94
|
-
* and the model has every incentive to reach for the most flattering one. A
|
|
95
|
-
* claim we cannot support becomes `popular`, which asserts nothing, and the
|
|
96
|
-
* prose that stated it is dropped along with it.
|
|
97
|
-
*/
|
|
98
|
-
function verifyBasis(basis, product, digest) {
|
|
93
|
+
function verifyBasis(basis, product, digest, engaged) {
|
|
99
94
|
switch (basis) {
|
|
100
95
|
case 'most_viewed':
|
|
101
96
|
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
@@ -104,7 +99,11 @@ function verifyBasis(basis, product, digest) {
|
|
|
104
99
|
case 'complements_purchase':
|
|
105
100
|
return digest.purchasedSkus.length > 0;
|
|
106
101
|
case 'liked_category':
|
|
107
|
-
|
|
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));
|
|
108
107
|
case 'similar_to_current':
|
|
109
108
|
return digest.currentCategory === product.category;
|
|
110
109
|
case 'popular':
|
|
@@ -112,221 +111,18 @@ function verifyBasis(basis, product, digest) {
|
|
|
112
111
|
return true;
|
|
113
112
|
}
|
|
114
113
|
}
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
* component is cached and served again later. `verifyBasis` checks the basis a
|
|
121
|
-
* pick claims; nothing checks the sentences around it, so every string the
|
|
122
|
-
* model writes is checked here — a heading is not a safer place for a claim
|
|
123
|
-
* than the small print under it.
|
|
124
|
-
*
|
|
125
|
-
* A claim is about money, a customer score, when it arrives, or how many are
|
|
126
|
-
* left. A specification is not a claim, even when it has a number or a
|
|
127
|
-
* percentage in it: "100% recycled nylon", "a comfort rating of -5C" and
|
|
128
|
-
* "arrives flat-packed" are all things a shop can say about the product itself,
|
|
129
|
-
* and they stay. That is why almost every rule below needs a second word beside
|
|
130
|
-
* the first — "20% off", not "20%"; "rated 4.8", not "rated". A careful
|
|
131
|
-
* rewording will get past this, and that is the trade we want: missing one
|
|
132
|
-
* claim is better than deleting honest copy on every page.
|
|
133
|
-
*
|
|
134
|
-
* One rule per line, because each line is a separate judgement about where the
|
|
135
|
-
* boundary sits and each one wants its own reason written next to it.
|
|
136
|
-
*/
|
|
137
|
-
const CLAIM_PATTERNS = [
|
|
138
|
-
{
|
|
139
|
-
// Customers scoring the product. "rated for winter use", "rated to -10C"
|
|
140
|
-
// and "an IPX7 water rating" are about what the product can take.
|
|
141
|
-
kind: 'rating',
|
|
142
|
-
patterns: [
|
|
143
|
-
/\breviews?\b|\breviewed\b/,
|
|
144
|
-
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
145
|
-
/\bstars?[\s-]?ratings?\b/,
|
|
146
|
-
// A rating somebody gave it, rather than one it was built to.
|
|
147
|
-
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
148
|
-
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
149
|
-
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
150
|
-
// The same score with the word "rating" nowhere near it: "4.8 out of 5".
|
|
151
|
-
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
152
|
-
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
153
|
-
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
154
|
-
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
155
|
-
// How many other people bought or liked it is a customer claim too.
|
|
156
|
-
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
157
|
-
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
158
|
-
],
|
|
159
|
-
},
|
|
160
|
-
{
|
|
161
|
-
kind: 'price',
|
|
162
|
-
patterns: [
|
|
163
|
-
/[$£€¥₹]\s?\d/,
|
|
164
|
-
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
165
|
-
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
166
|
-
/\b(?:usd|eur|gbp)\s?\d/,
|
|
167
|
-
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
168
|
-
/\bkr\s?\d|\d\s?kr\b/,
|
|
169
|
-
/\bpric(?:e|es|ed|ing)\b/,
|
|
170
|
-
// A before-and-after is a price claim even with no currency on it.
|
|
171
|
-
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
172
|
-
// "does not feel cheap" is about quality. Only the comparison is money.
|
|
173
|
-
/\bcheap(?:er|est)\b/,
|
|
174
|
-
/\baffordable\b|\bbargain\b/,
|
|
175
|
-
// "at no cost to comfort" is not money. "costs less" is.
|
|
176
|
-
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
177
|
-
/\blow(?:er)?[\s-]cost\b/,
|
|
178
|
-
// "saves weight" and "saves on weight" are both about grams.
|
|
179
|
-
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
180
|
-
],
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
// A percentage in outdoor copy is a specification far more often than it is
|
|
184
|
-
// money off, so it only counts beside a money word: "20% off", not
|
|
185
|
-
// "100% recycled".
|
|
186
|
-
kind: 'discount',
|
|
187
|
-
patterns: [
|
|
188
|
-
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
189
|
-
/\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
|
|
190
|
-
/\bdiscount(?:s|ed)?\b/,
|
|
191
|
-
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
192
|
-
/\bhalf[\s-]?(?:price|off)\b/,
|
|
193
|
-
// Money off with no percent sign anywhere on it: "save 20 off".
|
|
194
|
-
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
195
|
-
// "extra clearance for thick socks" is room inside the shoe.
|
|
196
|
-
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
197
|
-
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
198
|
-
// reduced price, or a reduction with a date on it, is money off.
|
|
199
|
-
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
200
|
-
],
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
// When it turns up, not what turns up. "ships in a recycled box" and
|
|
204
|
-
// "arrives flat-packed" describe the thing, so the verb alone is not enough
|
|
205
|
-
// — it needs a day or a date beside it.
|
|
206
|
-
kind: 'delivery',
|
|
207
|
-
patterns: [
|
|
208
|
-
/\bdelivery\b|\bshipping\b/,
|
|
209
|
-
/\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
|
|
210
|
-
/\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/,
|
|
211
|
-
/\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
|
|
212
|
-
/\bin time for\b/,
|
|
213
|
-
],
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
kind: 'stock',
|
|
217
|
-
patterns: [
|
|
218
|
-
/\b(?:in|out of|low on) stock\b/,
|
|
219
|
-
/\blimited stock\b/,
|
|
220
|
-
/\brestocked?\b|\bsold out\b/,
|
|
221
|
-
// "the last few miles" is a distance, so a count needs "left" after it.
|
|
222
|
-
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
223
|
-
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
224
|
-
],
|
|
225
|
-
},
|
|
226
|
-
];
|
|
227
|
-
/**
|
|
228
|
-
* Letters that render as a Latin letter but are not one. A Cyrillic о in
|
|
229
|
-
* "in stоck" would otherwise carry a stock claim straight past the rule written
|
|
230
|
-
* for it, and the shopper would read the claim anyway.
|
|
231
|
-
*/
|
|
232
|
-
const CONFUSABLES = {
|
|
233
|
-
а: 'a',
|
|
234
|
-
в: 'b',
|
|
235
|
-
е: 'e',
|
|
236
|
-
к: 'k',
|
|
237
|
-
м: 'm',
|
|
238
|
-
н: 'h',
|
|
239
|
-
о: 'o',
|
|
240
|
-
р: 'p',
|
|
241
|
-
с: 'c',
|
|
242
|
-
т: 't',
|
|
243
|
-
х: 'x',
|
|
244
|
-
у: 'y',
|
|
245
|
-
ѕ: 's',
|
|
246
|
-
і: 'i',
|
|
247
|
-
ј: 'j',
|
|
248
|
-
ԁ: 'd',
|
|
249
|
-
һ: 'h',
|
|
250
|
-
ӏ: 'l',
|
|
251
|
-
α: 'a',
|
|
252
|
-
ε: 'e',
|
|
253
|
-
ι: 'i',
|
|
254
|
-
κ: 'k',
|
|
255
|
-
ν: 'v',
|
|
256
|
-
ο: 'o',
|
|
257
|
-
ρ: 'p',
|
|
258
|
-
τ: 't',
|
|
259
|
-
υ: 'u',
|
|
260
|
-
χ: 'x',
|
|
261
|
-
ϲ: 'c',
|
|
262
|
-
};
|
|
263
|
-
/**
|
|
264
|
-
* Characters that render as nothing: format characters, default-ignorable code
|
|
265
|
-
* points and the controls. Cf and Default_Ignorable each hold characters the
|
|
266
|
-
* other does not.
|
|
267
|
-
*
|
|
268
|
-
* The same class @rudra-js/attested strips in its own `hidden.ts`. A second copy,
|
|
269
|
-
* because core carries no dependencies and is not about to take one — which means
|
|
270
|
-
* widening one and not the other is the mistake to watch for.
|
|
271
|
-
*/
|
|
272
|
-
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
273
|
-
/**
|
|
274
|
-
* The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
|
|
275
|
-
*
|
|
276
|
-
* No test pins this line, and none can: clamping collapses every run of whitespace
|
|
277
|
-
* before a claim is screened, so nothing here ever sees one. It is kept so the class
|
|
278
|
-
* reads the same in both copies.
|
|
279
|
-
*/
|
|
280
|
-
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
281
|
-
/**
|
|
282
|
-
* Marks that hang on the character before them instead of taking a column of their
|
|
283
|
-
* own. Dropped after composition, so an accented e keeps its accent while an
|
|
284
|
-
* overline, which composes with nothing, cannot hide a word from its own rule.
|
|
285
|
-
*/
|
|
286
|
-
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
287
|
-
/**
|
|
288
|
-
* One shape for the text before the patterns read it, so a word that renders as
|
|
289
|
-
* "in stock" is read as "in stock" however it was spelled.
|
|
290
|
-
*
|
|
291
|
-
* Only the reading is normalised. What renders is what the model wrote.
|
|
292
|
-
*/
|
|
293
|
-
function normaliseForClaims(text) {
|
|
294
|
-
const lowered = text
|
|
295
|
-
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
296
|
-
.normalize('NFKC')
|
|
297
|
-
.toLowerCase()
|
|
298
|
-
.normalize('NFC')
|
|
299
|
-
.replace(MARKS, '');
|
|
300
|
-
let folded = '';
|
|
301
|
-
for (const char of lowered)
|
|
302
|
-
folded += CONFUSABLES[char] ?? char;
|
|
303
|
-
return folded;
|
|
304
|
-
}
|
|
305
|
-
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
306
|
-
function claimIn(text) {
|
|
307
|
-
const normalised = normaliseForClaims(text);
|
|
308
|
-
for (const claim of CLAIM_PATTERNS) {
|
|
309
|
-
for (const pattern of claim.patterns) {
|
|
310
|
-
if (pattern.test(normalised))
|
|
311
|
-
return claim.kind;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* The running state of one reconciliation pass: what has been placed, how much
|
|
318
|
-
* of the item budget is left, and what was changed along the way.
|
|
319
|
-
*
|
|
320
|
-
* This is deliberately one named thing rather than three parameters threaded
|
|
321
|
-
* through every function. The budget and the de-duplication set are global to a
|
|
322
|
-
* spec, not to a block, which is the part that is easy to get wrong.
|
|
323
|
-
*/
|
|
324
|
-
function createPlacementTracker(maxItems) {
|
|
114
|
+
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
115
|
+
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
116
|
+
const NO_FACTS = [];
|
|
117
|
+
/** The budget and the de-duplication set are global to a spec, not to a block. */
|
|
118
|
+
function createPlacementTracker(maxItems, facts) {
|
|
325
119
|
const placedSkus = new Set();
|
|
326
120
|
const violations = [];
|
|
327
121
|
let remaining = maxItems;
|
|
328
122
|
return {
|
|
329
123
|
violations,
|
|
124
|
+
facts: facts.pooled,
|
|
125
|
+
factsFor: (sku) => facts.bySku.get(sku) ?? NO_FACTS,
|
|
330
126
|
get remaining() {
|
|
331
127
|
return remaining;
|
|
332
128
|
},
|
|
@@ -341,39 +137,53 @@ function createPlacementTracker(maxItems) {
|
|
|
341
137
|
};
|
|
342
138
|
}
|
|
343
139
|
/**
|
|
344
|
-
*
|
|
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.
|
|
345
142
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* and the
|
|
143
|
+
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
144
|
+
* is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
|
|
145
|
+
* is a second denylist and the weakest, so it answers last.
|
|
349
146
|
*/
|
|
350
|
-
function screenClaim(value, field, tracker) {
|
|
147
|
+
function screenClaim(value, limit, field, tracker, facts = tracker.facts) {
|
|
351
148
|
if (value === null)
|
|
352
149
|
return null;
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
return
|
|
356
|
-
|
|
357
|
-
|
|
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);
|
|
155
|
+
if (kind !== null) {
|
|
156
|
+
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
|
|
160
|
+
// and reading the fact list to prove that again per field is most of what this costs.
|
|
161
|
+
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
162
|
+
const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
163
|
+
if (!result.quantity.supported) {
|
|
164
|
+
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (!result.wording.supported) {
|
|
168
|
+
tracker.record(`unverifiable-claim:wording:${field}`);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return clamped;
|
|
358
172
|
}
|
|
359
173
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* 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.
|
|
364
177
|
*/
|
|
365
|
-
function screenRequired(value, field, tracker) {
|
|
366
|
-
return screenClaim(value, field, tracker) ?? '';
|
|
178
|
+
function screenRequired(value, limit, field, tracker) {
|
|
179
|
+
return screenClaim(value, limit, field, tracker) ?? '';
|
|
367
180
|
}
|
|
368
181
|
/**
|
|
369
|
-
* Decides whether one SKU may be placed, and names the reason when it may not.
|
|
370
182
|
* Does not consume budget — the caller does that once it commits.
|
|
371
183
|
*
|
|
372
|
-
* Order matters. The budget is checked last because it is the least specific
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* often the model invents products. These strings are the evaluation signal,
|
|
376
|
-
* 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.
|
|
377
187
|
*/
|
|
378
188
|
function rejectionFor(sku, allowlist, tracker) {
|
|
379
189
|
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
@@ -389,7 +199,7 @@ function rejectionFor(sku, allowlist, tracker) {
|
|
|
389
199
|
return `budget:dropped:${named}`;
|
|
390
200
|
return null;
|
|
391
201
|
}
|
|
392
|
-
function reconcileItems(items, allowlist, candidatesBySku, digest, tracker,
|
|
202
|
+
function reconcileItems(items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons) {
|
|
393
203
|
const kept = [];
|
|
394
204
|
for (const item of items) {
|
|
395
205
|
const rejection = rejectionFor(item.sku, allowlist, tracker);
|
|
@@ -402,24 +212,30 @@ function reconcileItems(items, allowlist, candidatesBySku, digest, tracker, host
|
|
|
402
212
|
if (!product)
|
|
403
213
|
continue;
|
|
404
214
|
tracker.place(item.sku);
|
|
405
|
-
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
215
|
+
const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
|
|
406
216
|
if (!hasSupportedBasis)
|
|
407
217
|
tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
408
|
-
|
|
218
|
+
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
219
|
+
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
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
|
+
}
|
|
409
234
|
kept.push({
|
|
410
235
|
sku: item.sku,
|
|
411
236
|
basis: hasSupportedBasis ? item.basis : 'popular',
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
reason: hasSupportedBasis
|
|
415
|
-
? isHostReason
|
|
416
|
-
? clampNullable(item.reason, CLAMP.reason)
|
|
417
|
-
: screenClaim(clampNullable(item.reason, CLAMP.reason), `reason:${item.sku}`, tracker)
|
|
418
|
-
: null,
|
|
419
|
-
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
420
|
-
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
421
|
-
// read for claims like every other sentence the model writes.
|
|
422
|
-
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
|
|
237
|
+
reason,
|
|
238
|
+
badge,
|
|
423
239
|
emphasis: item.emphasis,
|
|
424
240
|
});
|
|
425
241
|
}
|
|
@@ -437,7 +253,6 @@ function fitOf(bundle, digest, candidatesBySku) {
|
|
|
437
253
|
}
|
|
438
254
|
return fit;
|
|
439
255
|
}
|
|
440
|
-
/** In the cart beats recently viewed, which beats the category being looked at. */
|
|
441
256
|
function isBetterFit(fit, best) {
|
|
442
257
|
if (!best)
|
|
443
258
|
return true;
|
|
@@ -451,16 +266,14 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
451
266
|
let best;
|
|
452
267
|
let bestFit;
|
|
453
268
|
for (const bundle of bundles) {
|
|
454
|
-
// A bundle needs room for every product at once.
|
|
455
269
|
if (bundle.skus.length > tracker.remaining)
|
|
456
270
|
continue;
|
|
457
271
|
let isPlaceable = true;
|
|
458
272
|
for (const sku of bundle.skus) {
|
|
459
273
|
if (!allowlist.allowed.has(sku))
|
|
460
274
|
isPlaceable = false;
|
|
461
|
-
if (
|
|
275
|
+
if (allowlist.blockedInBundle.has(sku))
|
|
462
276
|
isPlaceable = false;
|
|
463
|
-
// Already shown by an earlier block — twice on a page looks broken.
|
|
464
277
|
if (tracker.hasPlaced(sku))
|
|
465
278
|
isPlaceable = false;
|
|
466
279
|
}
|
|
@@ -486,9 +299,9 @@ function chooseBundle(bundles, allowlist, digest, candidatesBySku, tracker) {
|
|
|
486
299
|
* a set is only pre-chosen if reconciliation could still reach for it.
|
|
487
300
|
*/
|
|
488
301
|
export function bundleForShopper(input, digest, spokenFor) {
|
|
489
|
-
const allowlist = buildAllowlist(input
|
|
302
|
+
const allowlist = buildAllowlist(input);
|
|
490
303
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
491
|
-
const tracker = createPlacementTracker(digest.maxItems);
|
|
304
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
492
305
|
for (const sku of spokenFor)
|
|
493
306
|
tracker.place(sku);
|
|
494
307
|
return chooseBundle(input.bundles, allowlist, digest, candidatesBySku, tracker);
|
|
@@ -501,8 +314,8 @@ export function bundleForShopper(input, digest, spokenFor) {
|
|
|
501
314
|
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
502
315
|
* counted here either.
|
|
503
316
|
*/
|
|
504
|
-
export function placeableHeroSkus(blocks, input
|
|
505
|
-
const allowlist = buildAllowlist(input
|
|
317
|
+
export function placeableHeroSkus(blocks, input) {
|
|
318
|
+
const allowlist = buildAllowlist(input);
|
|
506
319
|
const skus = [];
|
|
507
320
|
for (const block of blocks) {
|
|
508
321
|
if (block.kind !== 'hero')
|
|
@@ -511,14 +324,13 @@ export function placeableHeroSkus(blocks, input, digest) {
|
|
|
511
324
|
continue;
|
|
512
325
|
if (!allowlist.allowed.has(block.sku) || allowlist.blocked.has(block.sku))
|
|
513
326
|
continue;
|
|
514
|
-
// Two heroes naming the same product: only the first one gets to place it.
|
|
515
327
|
if (skus.includes(block.sku))
|
|
516
328
|
continue;
|
|
517
329
|
skus.push(block.sku);
|
|
518
330
|
}
|
|
519
331
|
return skus;
|
|
520
332
|
}
|
|
521
|
-
function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, tracker,
|
|
333
|
+
function reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, bundles, tracker, ourReasons) {
|
|
522
334
|
switch (block.kind) {
|
|
523
335
|
case 'hero': {
|
|
524
336
|
let sku = block.sku;
|
|
@@ -533,10 +345,8 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
533
345
|
tracker.place(sku);
|
|
534
346
|
}
|
|
535
347
|
}
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
// it would otherwise render above real content.
|
|
539
|
-
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);
|
|
540
350
|
if (headline.length === 0 && sku === null) {
|
|
541
351
|
tracker.record('empty-block:hero');
|
|
542
352
|
return null;
|
|
@@ -544,39 +354,39 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
544
354
|
return {
|
|
545
355
|
kind: 'hero',
|
|
546
356
|
headline,
|
|
547
|
-
body: screenClaim(
|
|
357
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
|
|
548
358
|
sku,
|
|
549
|
-
ctaLabel: screenClaim(
|
|
359
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
|
|
550
360
|
};
|
|
551
361
|
}
|
|
552
362
|
case 'grid': {
|
|
553
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker,
|
|
363
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
554
364
|
if (items.length === 0) {
|
|
555
365
|
tracker.record('empty-block:grid');
|
|
556
366
|
return null;
|
|
557
367
|
}
|
|
558
368
|
return {
|
|
559
369
|
kind: 'grid',
|
|
560
|
-
title: screenClaim(
|
|
370
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
|
|
561
371
|
// Never leave a grid wider than it has items to fill.
|
|
562
372
|
columns: Math.min(block.columns, Math.max(2, items.length)),
|
|
563
373
|
items,
|
|
564
374
|
};
|
|
565
375
|
}
|
|
566
376
|
case 'carousel': {
|
|
567
|
-
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, tracker,
|
|
377
|
+
const items = reconcileItems(block.items, allowlist, candidatesBySku, digest, engaged, tracker, ourReasons);
|
|
568
378
|
if (items.length === 0) {
|
|
569
379
|
tracker.record('empty-block:carousel');
|
|
570
380
|
return null;
|
|
571
381
|
}
|
|
572
382
|
return {
|
|
573
383
|
kind: 'carousel',
|
|
574
|
-
title: screenClaim(
|
|
384
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
|
|
575
385
|
items,
|
|
576
386
|
};
|
|
577
387
|
}
|
|
578
388
|
case 'banner': {
|
|
579
|
-
const text = screenRequired(
|
|
389
|
+
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
|
|
580
390
|
if (text.length === 0) {
|
|
581
391
|
tracker.record('empty-block:banner');
|
|
582
392
|
return null;
|
|
@@ -585,18 +395,18 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
585
395
|
kind: 'banner',
|
|
586
396
|
tone: block.tone,
|
|
587
397
|
text,
|
|
588
|
-
ctaLabel: screenClaim(
|
|
398
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
|
|
589
399
|
};
|
|
590
400
|
}
|
|
591
401
|
case 'copy': {
|
|
592
|
-
const body = screenRequired(
|
|
402
|
+
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
|
|
593
403
|
if (body.length === 0) {
|
|
594
404
|
tracker.record('empty-block:copy');
|
|
595
405
|
return null;
|
|
596
406
|
}
|
|
597
407
|
return {
|
|
598
408
|
kind: 'copy',
|
|
599
|
-
title: screenClaim(
|
|
409
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
|
|
600
410
|
body,
|
|
601
411
|
};
|
|
602
412
|
}
|
|
@@ -610,52 +420,52 @@ function reconcileBlock(block, allowlist, candidatesBySku, digest, bundles, trac
|
|
|
610
420
|
tracker.place(sku);
|
|
611
421
|
return {
|
|
612
422
|
kind: 'bundle',
|
|
613
|
-
title: screenClaim(
|
|
614
|
-
body: screenClaim(
|
|
615
|
-
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),
|
|
616
426
|
// The model's bundleId is ignored on purpose.
|
|
617
427
|
bundleId: chosen.id,
|
|
618
428
|
};
|
|
619
429
|
}
|
|
620
430
|
}
|
|
621
431
|
}
|
|
622
|
-
/** True when a spec contains at least one block that actually shows a product. */
|
|
623
432
|
function showsAnyProduct(blocks) {
|
|
624
433
|
return blocks.some((block) => (block.kind === 'grid' && block.items.length > 0) ||
|
|
625
434
|
(block.kind === 'carousel' && block.items.length > 0) ||
|
|
626
435
|
(block.kind === 'hero' && block.sku !== null) ||
|
|
627
|
-
// A bundle shows products too, so it counts the same as grid/carousel/hero.
|
|
628
436
|
(block.kind === 'bundle' && block.bundleId !== null));
|
|
629
437
|
}
|
|
630
438
|
export function reconcileSpec(generated, input, digest,
|
|
631
439
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
440
|
+
* The reason this request wrote itself, by SKU: the host's own `reason` on a
|
|
441
|
+
* candidate, or the sentence the selector wrote when there was none. Neither is the
|
|
442
|
+
* model's words, and the deterministic component renders the same sentence unscreened.
|
|
443
|
+
*
|
|
444
|
+
* Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
634
445
|
* reason is the model's, including one that happens to read the same.
|
|
635
446
|
*/
|
|
636
|
-
|
|
637
|
-
const allowlist = buildAllowlist(input
|
|
447
|
+
ourReasons = new Map()) {
|
|
448
|
+
const allowlist = buildAllowlist(input);
|
|
638
449
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
639
|
-
const
|
|
450
|
+
const engaged = engagedCategories(input);
|
|
451
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
640
452
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
641
453
|
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
642
454
|
}
|
|
643
455
|
const blocks = [];
|
|
644
|
-
for (const block of generated.blocks
|
|
645
|
-
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, input.bundles, tracker,
|
|
456
|
+
for (const block of capBlocks(generated.blocks)) {
|
|
457
|
+
const reconciled = reconcileBlock(block, allowlist, candidatesBySku, digest, engaged, input.bundles, tracker, ourReasons);
|
|
646
458
|
if (reconciled !== null)
|
|
647
459
|
blocks.push(reconciled);
|
|
648
460
|
}
|
|
649
461
|
const spec = {
|
|
650
462
|
tone: generated.tone,
|
|
651
|
-
headline: screenRequired(
|
|
652
|
-
subheadline: screenClaim(
|
|
463
|
+
headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
|
|
464
|
+
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
|
|
653
465
|
blocks,
|
|
654
|
-
rationale: screenRequired(
|
|
466
|
+
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
|
|
655
467
|
};
|
|
656
|
-
//
|
|
657
|
-
// Recorded separately: "no products survived" and "the model returned no
|
|
658
|
-
// headline" are different failures and want different fixes.
|
|
468
|
+
// Recorded separately: no products and no headline are different failures.
|
|
659
469
|
if (!showsAnyProduct(blocks))
|
|
660
470
|
tracker.record('unusable:no-products');
|
|
661
471
|
if (spec.headline.length === 0)
|