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