@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/src/reconciliation.ts
CHANGED
|
@@ -1,37 +1,21 @@
|
|
|
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
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
13
|
+
* Nothing here trusts the model. Repair, not rejection, is the default: text is
|
|
14
|
+
* truncated and unverifiable claims are downgraded, and only an empty result
|
|
15
|
+
* fails outright.
|
|
28
16
|
*/
|
|
29
17
|
|
|
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
|
-
*/
|
|
18
|
+
/** Truncation ceilings. Not in the schema — a provider's strict mode rejects length bounds. See `component-spec.ts`. */
|
|
35
19
|
const CLAMP = {
|
|
36
20
|
headline: 90,
|
|
37
21
|
subheadline: 140,
|
|
@@ -46,14 +30,14 @@ const CLAMP = {
|
|
|
46
30
|
violationSku: 32,
|
|
47
31
|
} as const;
|
|
48
32
|
|
|
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
|
-
*/
|
|
33
|
+
/** More than this and the component stops being a component. */
|
|
55
34
|
export const MAX_BLOCKS = 4;
|
|
56
35
|
|
|
36
|
+
/** The fill pass calls this too: one past the cap never renders, so nothing is worth reserving for it. */
|
|
37
|
+
export function capBlocks(blocks: Block[]): Block[] {
|
|
38
|
+
return blocks.slice(0, MAX_BLOCKS);
|
|
39
|
+
}
|
|
40
|
+
|
|
57
41
|
export interface ReconcileResult {
|
|
58
42
|
spec: GeneratedSpec;
|
|
59
43
|
/** True when something survived that is worth rendering. */
|
|
@@ -66,8 +50,7 @@ function clamp(value: string, limit: number): string {
|
|
|
66
50
|
const collapsed = value.trim().replace(/\s+/g, ' ');
|
|
67
51
|
if (collapsed.length <= limit) return collapsed;
|
|
68
52
|
|
|
69
|
-
// The ellipsis counts against the limit, so leave room for it.
|
|
70
|
-
// clamped string is one character longer than the cap it was clamped to.
|
|
53
|
+
// The ellipsis counts against the limit, so leave room for it.
|
|
71
54
|
const cut = collapsed.slice(0, limit - 1);
|
|
72
55
|
const lastSpace = cut.lastIndexOf(' ');
|
|
73
56
|
const base = lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
@@ -81,52 +64,70 @@ function clampNullable(value: string | null, limit: number): string | null {
|
|
|
81
64
|
}
|
|
82
65
|
|
|
83
66
|
interface Allowlist {
|
|
84
|
-
/** SKUs the model may place. */
|
|
85
67
|
allowed: Set<string>;
|
|
86
|
-
/** SKUs that must never be placed, whatever the model decided. */
|
|
87
68
|
blocked: Set<string>;
|
|
69
|
+
/** A bundle is placed whole, so it checks this narrower set. See `refusedEverywhere`. */
|
|
70
|
+
blockedInBundle: Set<string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A thumbs-down, and the product on the screen right now. No block may hold one
|
|
75
|
+
* of these, a bundle included.
|
|
76
|
+
*
|
|
77
|
+
* Read from the payload rather than the digest on purpose. `DIGEST_LIMITS`
|
|
78
|
+
* trims each history to what fits in a prompt, and a shopper with thirteen
|
|
79
|
+
* dislikes would otherwise be shown the thirteenth. The cap exists to bound
|
|
80
|
+
* what the model is told, not what the shopper may be shown.
|
|
81
|
+
*/
|
|
82
|
+
function refusedEverywhere(input: TrackingInput): Set<string> {
|
|
83
|
+
const refused = new Set<string>();
|
|
84
|
+
for (const signal of input.signals.dislikes) refused.add(signal.sku);
|
|
85
|
+
if (input.context.currentSku) refused.add(input.context.currentSku);
|
|
86
|
+
|
|
87
|
+
return refused;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* SKUs that must never be recommended, whatever chose them.
|
|
92
92
|
*
|
|
93
|
+
* Everything above plus what the shopper already has, read from the payload for
|
|
94
|
+
* the same reason: a shopper with nine purchases must not be sold the ninth back.
|
|
95
|
+
*
|
|
93
96
|
* Exported because the deterministic selector applies the same rule when it
|
|
94
97
|
* picks. Two copies of "never recommend these" would drift, and the pair that
|
|
95
98
|
* drifted would be the model path and the fallback path — the two whose
|
|
96
99
|
* comparability the whole evaluation depends on.
|
|
97
100
|
*/
|
|
98
|
-
export function neverRecommend(
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
export function neverRecommend(input: TrackingInput): Set<string> {
|
|
102
|
+
const { signals } = input;
|
|
103
|
+
|
|
104
|
+
const blocked = refusedEverywhere(input);
|
|
105
|
+
for (const signal of [...signals.lastPurchased, ...signals.cart]) {
|
|
106
|
+
blocked.add(signal.sku);
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
return blocked;
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
function buildAllowlist(input: TrackingInput
|
|
112
|
+
function buildAllowlist(input: TrackingInput): Allowlist {
|
|
109
113
|
const allowed = new Set<string>();
|
|
110
114
|
for (const product of input.candidates) {
|
|
111
|
-
// An out-of-stock candidate is not a recommendation, it is a dead end.
|
|
112
115
|
if (product.isInStock) allowed.add(product.sku);
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
return {
|
|
119
|
+
allowed,
|
|
120
|
+
blocked: neverRecommend(input),
|
|
121
|
+
blockedInBundle: refusedEverywhere(input),
|
|
122
|
+
};
|
|
118
123
|
}
|
|
119
124
|
|
|
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 {
|
|
125
|
+
function verifyBasis(
|
|
126
|
+
basis: RecommendationBasis,
|
|
127
|
+
product: Product,
|
|
128
|
+
digest: SignalDigest,
|
|
129
|
+
engaged: ReadonlySet<string>,
|
|
130
|
+
): boolean {
|
|
130
131
|
switch (basis) {
|
|
131
132
|
case 'most_viewed':
|
|
132
133
|
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
@@ -135,7 +136,13 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
|
|
|
135
136
|
case 'complements_purchase':
|
|
136
137
|
return digest.purchasedSkus.length > 0;
|
|
137
138
|
case 'liked_category':
|
|
138
|
-
|
|
139
|
+
// Affinity alone is not enough: it scores the category the shopper is
|
|
140
|
+
// standing in, and standing somewhere is not liking it. That claim is
|
|
141
|
+
// `similar_to_current`, which says so honestly.
|
|
142
|
+
return (
|
|
143
|
+
engaged.has(product.category) &&
|
|
144
|
+
digest.categoryAffinity.some((affinity) => affinity.category === product.category)
|
|
145
|
+
);
|
|
139
146
|
case 'similar_to_current':
|
|
140
147
|
return digest.currentCategory === product.category;
|
|
141
148
|
case 'popular':
|
|
@@ -144,299 +151,12 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
|
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
153
|
|
|
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
154
|
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
359
155
|
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
360
156
|
|
|
361
157
|
const NO_FACTS: readonly string[] = [];
|
|
362
158
|
|
|
363
|
-
/**
|
|
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
|
-
*/
|
|
159
|
+
/** The budget and the de-duplication set are global to a spec, not to a block. */
|
|
440
160
|
function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
441
161
|
const placedSkus = new Set<string>();
|
|
442
162
|
const violations: string[] = [];
|
|
@@ -463,26 +183,27 @@ function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
|
463
183
|
type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
464
184
|
|
|
465
185
|
/**
|
|
466
|
-
*
|
|
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.
|
|
186
|
+
* Clamps, then screens. The cap has to happen here: clamping first can cut the word that
|
|
187
|
+
* makes a claim a claim, so "Ridge picks, only 2 left!" renders past the stock pattern.
|
|
471
188
|
*
|
|
472
189
|
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
473
190
|
* 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.
|
|
475
|
-
* list, and a field that names one product passes that product's own.
|
|
191
|
+
* is a second denylist and the weakest, so it answers last.
|
|
476
192
|
*/
|
|
477
193
|
function screenClaim(
|
|
478
194
|
value: string | null,
|
|
195
|
+
limit: number,
|
|
479
196
|
field: string,
|
|
480
197
|
tracker: PlacementTracker,
|
|
481
198
|
facts: readonly string[] = tracker.facts,
|
|
482
199
|
): string | null {
|
|
483
200
|
if (value === null) return null;
|
|
484
201
|
|
|
485
|
-
const
|
|
202
|
+
const clamped = clamp(value, limit);
|
|
203
|
+
if (clamped.length === 0) return null;
|
|
204
|
+
|
|
205
|
+
// Both ways round: clamping collapses whitespace, so "in stock" only reads as a claim after.
|
|
206
|
+
const kind = claimIn(value) ?? claimIn(clamped);
|
|
486
207
|
if (kind !== null) {
|
|
487
208
|
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
488
209
|
return null;
|
|
@@ -490,9 +211,9 @@ function screenClaim(
|
|
|
490
211
|
|
|
491
212
|
// Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
|
|
492
213
|
// and reading the fact list to prove that again per field is most of what this costs.
|
|
493
|
-
const weighed = NUMERAL.test(
|
|
214
|
+
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
494
215
|
|
|
495
|
-
const result = verify(
|
|
216
|
+
const result = verify(clamped, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
496
217
|
if (!result.quantity.supported) {
|
|
497
218
|
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
498
219
|
return null;
|
|
@@ -502,28 +223,29 @@ function screenClaim(
|
|
|
502
223
|
return null;
|
|
503
224
|
}
|
|
504
225
|
|
|
505
|
-
return
|
|
226
|
+
return clamped;
|
|
506
227
|
}
|
|
507
228
|
|
|
508
229
|
/**
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* rendering blank.
|
|
230
|
+
* Emptying a field that cannot be null hands it to the rule that already drops a block,
|
|
231
|
+
* or a whole generation, whose text clamps to nothing — so a banner reading "20% off"
|
|
232
|
+
* disappears rather than rendering blank.
|
|
513
233
|
*/
|
|
514
|
-
function screenRequired(
|
|
515
|
-
|
|
234
|
+
function screenRequired(
|
|
235
|
+
value: string,
|
|
236
|
+
limit: number,
|
|
237
|
+
field: string,
|
|
238
|
+
tracker: PlacementTracker,
|
|
239
|
+
): string {
|
|
240
|
+
return screenClaim(value, limit, field, tracker) ?? '';
|
|
516
241
|
}
|
|
517
242
|
|
|
518
243
|
/**
|
|
519
|
-
* Decides whether one SKU may be placed, and names the reason when it may not.
|
|
520
244
|
* Does not consume budget — the caller does that once it commits.
|
|
521
245
|
*
|
|
522
|
-
* Order matters. The budget is checked last because it is the least specific
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* often the model invents products. These strings are the evaluation signal,
|
|
526
|
-
* so each one has to name the fault that actually fired.
|
|
246
|
+
* Order matters. The budget is checked last because it is the least specific cause:
|
|
247
|
+
* these strings are the evaluation signal, and reporting a hallucinated SKU as
|
|
248
|
+
* `budget:dropped` would understate how often the model invents products.
|
|
527
249
|
*/
|
|
528
250
|
function rejectionFor(sku: string, allowlist: Allowlist, tracker: PlacementTracker): string | null {
|
|
529
251
|
// A rejected SKU is whatever the model wrote, and the schema cannot bound it.
|
|
@@ -542,6 +264,7 @@ function reconcileItems(
|
|
|
542
264
|
allowlist: Allowlist,
|
|
543
265
|
candidatesBySku: Map<string, Product>,
|
|
544
266
|
digest: SignalDigest,
|
|
267
|
+
engaged: ReadonlySet<string>,
|
|
545
268
|
tracker: PlacementTracker,
|
|
546
269
|
ourReasons: ReadonlyMap<string, string>,
|
|
547
270
|
): ProductReference[] {
|
|
@@ -560,33 +283,33 @@ function reconcileItems(
|
|
|
560
283
|
|
|
561
284
|
tracker.place(item.sku);
|
|
562
285
|
|
|
563
|
-
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
286
|
+
const hasSupportedBasis = verifyBasis(item.basis, product, digest, engaged);
|
|
564
287
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
565
288
|
|
|
566
289
|
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
567
290
|
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
568
291
|
|
|
569
292
|
const own = tracker.factsFor(item.sku);
|
|
293
|
+
const clampedReason = clampNullable(item.reason, CLAMP.reason);
|
|
294
|
+
|
|
295
|
+
// The prose exists to state the basis. If the basis did not hold, the prose
|
|
296
|
+
// is untrue — and the badge is prose too. "You viewed this" beside a
|
|
297
|
+
// downgraded pick says the same thing the dropped sentence did.
|
|
298
|
+
let reason: string | null = null;
|
|
299
|
+
let badge: string | null = null;
|
|
300
|
+
if (hasSupportedBasis) {
|
|
301
|
+
reason = isOurs
|
|
302
|
+
? clampedReason
|
|
303
|
+
: screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, tracker, own);
|
|
304
|
+
// The schema's own example badge was "Back in stock" — a stock claim, and it renders.
|
|
305
|
+
badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, tracker, own);
|
|
306
|
+
}
|
|
570
307
|
|
|
571
308
|
kept.push({
|
|
572
309
|
sku: item.sku,
|
|
573
310
|
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),
|
|
311
|
+
reason,
|
|
312
|
+
badge,
|
|
590
313
|
emphasis: item.emphasis,
|
|
591
314
|
});
|
|
592
315
|
}
|
|
@@ -594,8 +317,7 @@ function reconcileItems(
|
|
|
594
317
|
return kept;
|
|
595
318
|
}
|
|
596
319
|
|
|
597
|
-
//
|
|
598
|
-
// regardless of how the counts compare, so they can't be summed.
|
|
320
|
+
// Cart beats views beats category however the counts compare, so they can't be summed.
|
|
599
321
|
interface BundleFit {
|
|
600
322
|
cartHits: number;
|
|
601
323
|
viewedHits: number;
|
|
@@ -618,7 +340,6 @@ function fitOf(
|
|
|
618
340
|
return fit;
|
|
619
341
|
}
|
|
620
342
|
|
|
621
|
-
/** In the cart beats recently viewed, which beats the category being looked at. */
|
|
622
343
|
function isBetterFit(fit: BundleFit, best: BundleFit | undefined): boolean {
|
|
623
344
|
if (!best) return true;
|
|
624
345
|
if (fit.cartHits !== best.cartHits) return fit.cartHits > best.cartHits;
|
|
@@ -637,14 +358,12 @@ function chooseBundle(
|
|
|
637
358
|
let bestFit: BundleFit | undefined;
|
|
638
359
|
|
|
639
360
|
for (const bundle of bundles) {
|
|
640
|
-
// A bundle needs room for every product at once.
|
|
641
361
|
if (bundle.skus.length > tracker.remaining) continue;
|
|
642
362
|
|
|
643
363
|
let isPlaceable = true;
|
|
644
364
|
for (const sku of bundle.skus) {
|
|
645
365
|
if (!allowlist.allowed.has(sku)) isPlaceable = false;
|
|
646
|
-
if (
|
|
647
|
-
// Already shown by an earlier block — twice on a page looks broken.
|
|
366
|
+
if (allowlist.blockedInBundle.has(sku)) isPlaceable = false;
|
|
648
367
|
if (tracker.hasPlaced(sku)) isPlaceable = false;
|
|
649
368
|
}
|
|
650
369
|
if (!isPlaceable) continue;
|
|
@@ -675,7 +394,7 @@ export function bundleForShopper(
|
|
|
675
394
|
digest: SignalDigest,
|
|
676
395
|
spokenFor: readonly string[],
|
|
677
396
|
): Bundle | undefined {
|
|
678
|
-
const allowlist = buildAllowlist(input
|
|
397
|
+
const allowlist = buildAllowlist(input);
|
|
679
398
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
680
399
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
681
400
|
|
|
@@ -692,19 +411,14 @@ export function bundleForShopper(
|
|
|
692
411
|
* One this shopper cannot see is dropped below and spends nothing, so it is not
|
|
693
412
|
* counted here either.
|
|
694
413
|
*/
|
|
695
|
-
export function placeableHeroSkus(
|
|
696
|
-
|
|
697
|
-
input: TrackingInput,
|
|
698
|
-
digest: SignalDigest,
|
|
699
|
-
): string[] {
|
|
700
|
-
const allowlist = buildAllowlist(input, digest);
|
|
414
|
+
export function placeableHeroSkus(blocks: readonly Block[], input: TrackingInput): string[] {
|
|
415
|
+
const allowlist = buildAllowlist(input);
|
|
701
416
|
|
|
702
417
|
const skus: string[] = [];
|
|
703
418
|
for (const block of blocks) {
|
|
704
419
|
if (block.kind !== 'hero') continue;
|
|
705
420
|
if (block.sku === null) continue;
|
|
706
421
|
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
422
|
if (skus.includes(block.sku)) continue;
|
|
709
423
|
skus.push(block.sku);
|
|
710
424
|
}
|
|
@@ -717,6 +431,7 @@ function reconcileBlock(
|
|
|
717
431
|
allowlist: Allowlist,
|
|
718
432
|
candidatesBySku: Map<string, Product>,
|
|
719
433
|
digest: SignalDigest,
|
|
434
|
+
engaged: ReadonlySet<string>,
|
|
720
435
|
bundles: readonly Bundle[],
|
|
721
436
|
tracker: PlacementTracker,
|
|
722
437
|
ourReasons: ReadonlyMap<string, string>,
|
|
@@ -734,14 +449,8 @@ function reconcileBlock(
|
|
|
734
449
|
tracker.place(sku);
|
|
735
450
|
}
|
|
736
451
|
}
|
|
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
|
-
);
|
|
452
|
+
// A hero with no headline and no product is the empty region every other kind drops for.
|
|
453
|
+
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
|
|
745
454
|
if (headline.length === 0 && sku === null) {
|
|
746
455
|
tracker.record('empty-block:hero');
|
|
747
456
|
return null;
|
|
@@ -749,9 +458,9 @@ function reconcileBlock(
|
|
|
749
458
|
return {
|
|
750
459
|
kind: 'hero',
|
|
751
460
|
headline,
|
|
752
|
-
body: screenClaim(
|
|
461
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', tracker),
|
|
753
462
|
sku,
|
|
754
|
-
ctaLabel: screenClaim(
|
|
463
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', tracker),
|
|
755
464
|
};
|
|
756
465
|
}
|
|
757
466
|
|
|
@@ -761,6 +470,7 @@ function reconcileBlock(
|
|
|
761
470
|
allowlist,
|
|
762
471
|
candidatesBySku,
|
|
763
472
|
digest,
|
|
473
|
+
engaged,
|
|
764
474
|
tracker,
|
|
765
475
|
ourReasons,
|
|
766
476
|
);
|
|
@@ -770,7 +480,7 @@ function reconcileBlock(
|
|
|
770
480
|
}
|
|
771
481
|
return {
|
|
772
482
|
kind: 'grid',
|
|
773
|
-
title: screenClaim(
|
|
483
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', tracker),
|
|
774
484
|
// Never leave a grid wider than it has items to fill.
|
|
775
485
|
columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
|
|
776
486
|
items,
|
|
@@ -783,6 +493,7 @@ function reconcileBlock(
|
|
|
783
493
|
allowlist,
|
|
784
494
|
candidatesBySku,
|
|
785
495
|
digest,
|
|
496
|
+
engaged,
|
|
786
497
|
tracker,
|
|
787
498
|
ourReasons,
|
|
788
499
|
);
|
|
@@ -792,13 +503,13 @@ function reconcileBlock(
|
|
|
792
503
|
}
|
|
793
504
|
return {
|
|
794
505
|
kind: 'carousel',
|
|
795
|
-
title: screenClaim(
|
|
506
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', tracker),
|
|
796
507
|
items,
|
|
797
508
|
};
|
|
798
509
|
}
|
|
799
510
|
|
|
800
511
|
case 'banner': {
|
|
801
|
-
const text = screenRequired(
|
|
512
|
+
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', tracker);
|
|
802
513
|
if (text.length === 0) {
|
|
803
514
|
tracker.record('empty-block:banner');
|
|
804
515
|
return null;
|
|
@@ -807,19 +518,19 @@ function reconcileBlock(
|
|
|
807
518
|
kind: 'banner',
|
|
808
519
|
tone: block.tone,
|
|
809
520
|
text,
|
|
810
|
-
ctaLabel: screenClaim(
|
|
521
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', tracker),
|
|
811
522
|
};
|
|
812
523
|
}
|
|
813
524
|
|
|
814
525
|
case 'copy': {
|
|
815
|
-
const body = screenRequired(
|
|
526
|
+
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', tracker);
|
|
816
527
|
if (body.length === 0) {
|
|
817
528
|
tracker.record('empty-block:copy');
|
|
818
529
|
return null;
|
|
819
530
|
}
|
|
820
531
|
return {
|
|
821
532
|
kind: 'copy',
|
|
822
|
-
title: screenClaim(
|
|
533
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', tracker),
|
|
823
534
|
body,
|
|
824
535
|
};
|
|
825
536
|
}
|
|
@@ -835,9 +546,9 @@ function reconcileBlock(
|
|
|
835
546
|
|
|
836
547
|
return {
|
|
837
548
|
kind: 'bundle',
|
|
838
|
-
title: screenClaim(
|
|
839
|
-
body: screenClaim(
|
|
840
|
-
ctaLabel: screenClaim(
|
|
549
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', tracker),
|
|
550
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', tracker),
|
|
551
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', tracker),
|
|
841
552
|
// The model's bundleId is ignored on purpose.
|
|
842
553
|
bundleId: chosen.id,
|
|
843
554
|
};
|
|
@@ -845,14 +556,12 @@ function reconcileBlock(
|
|
|
845
556
|
}
|
|
846
557
|
}
|
|
847
558
|
|
|
848
|
-
/** True when a spec contains at least one block that actually shows a product. */
|
|
849
559
|
function showsAnyProduct(blocks: Block[]): boolean {
|
|
850
560
|
return blocks.some(
|
|
851
561
|
(block) =>
|
|
852
562
|
(block.kind === 'grid' && block.items.length > 0) ||
|
|
853
563
|
(block.kind === 'carousel' && block.items.length > 0) ||
|
|
854
564
|
(block.kind === 'hero' && block.sku !== null) ||
|
|
855
|
-
// A bundle shows products too, so it counts the same as grid/carousel/hero.
|
|
856
565
|
(block.kind === 'bundle' && block.bundleId !== null),
|
|
857
566
|
);
|
|
858
567
|
}
|
|
@@ -871,8 +580,9 @@ export function reconcileSpec(
|
|
|
871
580
|
*/
|
|
872
581
|
ourReasons: ReadonlyMap<string, string> = new Map(),
|
|
873
582
|
): ReconcileResult {
|
|
874
|
-
const allowlist = buildAllowlist(input
|
|
583
|
+
const allowlist = buildAllowlist(input);
|
|
875
584
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
585
|
+
const engaged = engagedCategories(input);
|
|
876
586
|
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
877
587
|
|
|
878
588
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
@@ -880,12 +590,13 @@ export function reconcileSpec(
|
|
|
880
590
|
}
|
|
881
591
|
|
|
882
592
|
const blocks: Block[] = [];
|
|
883
|
-
for (const block of generated.blocks
|
|
593
|
+
for (const block of capBlocks(generated.blocks)) {
|
|
884
594
|
const reconciled = reconcileBlock(
|
|
885
595
|
block,
|
|
886
596
|
allowlist,
|
|
887
597
|
candidatesBySku,
|
|
888
598
|
digest,
|
|
599
|
+
engaged,
|
|
889
600
|
input.bundles,
|
|
890
601
|
tracker,
|
|
891
602
|
ourReasons,
|
|
@@ -895,19 +606,13 @@ export function reconcileSpec(
|
|
|
895
606
|
|
|
896
607
|
const spec: GeneratedSpec = {
|
|
897
608
|
tone: generated.tone,
|
|
898
|
-
headline: screenRequired(
|
|
899
|
-
subheadline: screenClaim(
|
|
900
|
-
clampNullable(generated.subheadline, CLAMP.subheadline),
|
|
901
|
-
'subheadline',
|
|
902
|
-
tracker,
|
|
903
|
-
),
|
|
609
|
+
headline: screenRequired(generated.headline, CLAMP.headline, 'headline', tracker),
|
|
610
|
+
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', tracker),
|
|
904
611
|
blocks,
|
|
905
|
-
rationale: screenRequired(
|
|
612
|
+
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', tracker),
|
|
906
613
|
};
|
|
907
614
|
|
|
908
|
-
//
|
|
909
|
-
// Recorded separately: "no products survived" and "the model returned no
|
|
910
|
-
// headline" are different failures and want different fixes.
|
|
615
|
+
// Recorded separately: no products and no headline are different failures.
|
|
911
616
|
if (!showsAnyProduct(blocks)) tracker.record('unusable:no-products');
|
|
912
617
|
if (spec.headline.length === 0) tracker.record('unusable:no-headline');
|
|
913
618
|
const isUsable = showsAnyProduct(blocks) && spec.headline.length > 0;
|