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