@rudra-js/core 0.7.3 → 0.7.5
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 +113 -22
- package/dist/claim-screening.d.ts +3 -3
- package/dist/claim-screening.d.ts.map +1 -1
- package/dist/claim-screening.js +37 -102
- package/dist/claim-screening.js.map +1 -1
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +161 -176
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +1 -0
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -0
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +1 -1
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +9 -17
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +19 -19
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +56 -39
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +15 -31
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +3 -3
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +133 -171
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +1 -1
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +51 -52
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +0 -4
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +15 -35
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +16 -19
- package/dist/tracking-input.js.map +1 -1
- package/package.json +3 -3
- package/src/claim-screening.ts +41 -125
- package/src/component-generator.ts +223 -220
- package/src/component-spec.ts +4 -15
- package/src/fallback-component.ts +11 -20
- package/src/fit-to-shopper.ts +19 -21
- package/src/model-prompt.ts +55 -54
- package/src/product-selection.ts +21 -50
- package/src/provider.ts +0 -4
- package/src/reconciliation.ts +159 -268
- package/src/signal-digest.ts +50 -79
- package/src/spec-cache.ts +15 -48
- package/src/tracking-input.ts +21 -36
package/src/reconciliation.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
Block,
|
|
5
|
-
GeneratedSpec,
|
|
6
|
-
ProductReference,
|
|
7
|
-
RecommendationBasis,
|
|
1
|
+
import { hostFacts, NO_FACTS, unbackedClaim, type HostFacts } from './claim-screening.js';
|
|
2
|
+
import {
|
|
3
|
+
columnsFor,
|
|
4
|
+
type Block,
|
|
5
|
+
type GeneratedSpec,
|
|
6
|
+
type ProductReference,
|
|
7
|
+
type RecommendationBasis,
|
|
8
8
|
} from './component-spec.js';
|
|
9
|
-
import {
|
|
10
|
-
import type
|
|
9
|
+
import { categoriesTouchedBySignals, type SignalDigest } from './signal-digest.js';
|
|
10
|
+
import { FIELD_LIMITS, type Bundle, type Product, type TrackingInput } from './tracking-input.js';
|
|
11
11
|
|
|
12
12
|
const CLAMP = {
|
|
13
13
|
headline: 90,
|
|
14
14
|
subheadline: 140,
|
|
15
15
|
blockTitle: 80,
|
|
16
|
-
reason:
|
|
16
|
+
reason: FIELD_LIMITS.reason,
|
|
17
17
|
badge: 24,
|
|
18
18
|
ctaLabel: 32,
|
|
19
19
|
bannerText: 160,
|
|
20
20
|
copyBody: 420,
|
|
21
21
|
rationale: 300,
|
|
22
|
-
|
|
23
22
|
violationSku: 32,
|
|
24
23
|
} as const;
|
|
25
24
|
|
|
@@ -31,9 +30,7 @@ export function capBlocks(blocks: Block[]): Block[] {
|
|
|
31
30
|
|
|
32
31
|
export interface ReconcileResult {
|
|
33
32
|
spec: GeneratedSpec;
|
|
34
|
-
|
|
35
33
|
isUsable: boolean;
|
|
36
|
-
|
|
37
34
|
violations: string[];
|
|
38
35
|
}
|
|
39
36
|
|
|
@@ -47,51 +44,66 @@ function clamp(value: string, limit: number): string {
|
|
|
47
44
|
return `${base.replace(/[.,;:!?-]+$/, '')}…`;
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
blocked
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
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;
|
|
47
|
+
function blockedForBundles(input: TrackingInput): Set<string> {
|
|
48
|
+
const blocked = new Set<string>();
|
|
49
|
+
for (const signal of input.signals.dislikes) blocked.add(signal.sku);
|
|
50
|
+
if (input.context.currentSku) blocked.add(input.context.currentSku);
|
|
51
|
+
return blocked;
|
|
63
52
|
}
|
|
64
53
|
|
|
65
|
-
export function
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
for (const signal of [...signals.lastPurchased, ...signals.cart]) {
|
|
70
|
-
blocked.add(signal.sku);
|
|
71
|
-
}
|
|
72
|
-
|
|
54
|
+
export function blockedForPicks(input: TrackingInput): Set<string> {
|
|
55
|
+
const blocked = blockedForBundles(input);
|
|
56
|
+
for (const signal of input.signals.lastPurchased) blocked.add(signal.sku);
|
|
57
|
+
for (const signal of input.signals.cart) blocked.add(signal.sku);
|
|
73
58
|
return blocked;
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
function
|
|
77
|
-
const
|
|
61
|
+
function inStockSkus(input: TrackingInput): Set<string> {
|
|
62
|
+
const skus = new Set<string>();
|
|
78
63
|
for (const product of input.candidates) {
|
|
79
|
-
if (product.isInStock)
|
|
64
|
+
if (product.isInStock) skus.add(product.sku);
|
|
80
65
|
}
|
|
66
|
+
return skus;
|
|
67
|
+
}
|
|
81
68
|
|
|
69
|
+
interface Run {
|
|
70
|
+
readonly allowed: ReadonlySet<string>;
|
|
71
|
+
readonly blockedForPicks: ReadonlySet<string>;
|
|
72
|
+
readonly blockedForBundles: ReadonlySet<string>;
|
|
73
|
+
readonly products: ReadonlyMap<string, Product>;
|
|
74
|
+
readonly digest: SignalDigest;
|
|
75
|
+
readonly engaged: ReadonlySet<string>;
|
|
76
|
+
readonly bundles: readonly Bundle[];
|
|
77
|
+
readonly ourReasons: ReadonlyMap<string, string>;
|
|
78
|
+
readonly facts: HostFacts;
|
|
79
|
+
placed: Set<string>;
|
|
80
|
+
remaining: number;
|
|
81
|
+
violations: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function startRun(
|
|
85
|
+
input: TrackingInput,
|
|
86
|
+
digest: SignalDigest,
|
|
87
|
+
ourReasons: ReadonlyMap<string, string>,
|
|
88
|
+
): Run {
|
|
82
89
|
return {
|
|
83
|
-
allowed,
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
allowed: inStockSkus(input),
|
|
91
|
+
blockedForPicks: blockedForPicks(input),
|
|
92
|
+
blockedForBundles: blockedForBundles(input),
|
|
93
|
+
products: new Map(input.candidates.map((product) => [product.sku, product])),
|
|
94
|
+
digest,
|
|
95
|
+
engaged: categoriesTouchedBySignals(input),
|
|
96
|
+
bundles: input.bundles,
|
|
97
|
+
ourReasons,
|
|
98
|
+
facts: hostFacts(input),
|
|
99
|
+
placed: new Set(),
|
|
100
|
+
remaining: digest.maxItems,
|
|
101
|
+
violations: [],
|
|
86
102
|
};
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
function verifyBasis(
|
|
90
|
-
|
|
91
|
-
product: Product,
|
|
92
|
-
digest: SignalDigest,
|
|
93
|
-
engaged: ReadonlySet<string>,
|
|
94
|
-
): boolean {
|
|
105
|
+
function verifyBasis(basis: RecommendationBasis, product: Product, run: Run): boolean {
|
|
106
|
+
const { digest } = run;
|
|
95
107
|
switch (basis) {
|
|
96
108
|
case 'most_viewed':
|
|
97
109
|
return digest.topViewed.some((viewed) => viewed.sku === product.sku);
|
|
@@ -101,7 +113,7 @@ function verifyBasis(
|
|
|
101
113
|
return digest.purchasedSkus.length > 0;
|
|
102
114
|
case 'liked_category':
|
|
103
115
|
return (
|
|
104
|
-
engaged.has(product.category) &&
|
|
116
|
+
run.engaged.has(product.category) &&
|
|
105
117
|
digest.categoryAffinity.some((affinity) => affinity.category === product.category)
|
|
106
118
|
);
|
|
107
119
|
case 'similar_to_current':
|
|
@@ -111,135 +123,82 @@ function verifyBasis(
|
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
119
|
-
const placedSkus = new Set<string>();
|
|
120
|
-
const violations: string[] = [];
|
|
121
|
-
let remaining = maxItems;
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
violations,
|
|
125
|
-
facts: facts.pooled,
|
|
126
|
-
factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
|
|
127
|
-
get remaining() {
|
|
128
|
-
return remaining;
|
|
129
|
-
},
|
|
130
|
-
hasPlaced: (sku: string) => placedSkus.has(sku),
|
|
131
|
-
record(violation: string) {
|
|
132
|
-
violations.push(violation);
|
|
133
|
-
},
|
|
134
|
-
place(sku: string) {
|
|
135
|
-
placedSkus.add(sku);
|
|
136
|
-
remaining -= 1;
|
|
137
|
-
},
|
|
138
|
-
};
|
|
126
|
+
function place(run: Run, sku: string): void {
|
|
127
|
+
run.placed.add(sku);
|
|
128
|
+
run.remaining -= 1;
|
|
139
129
|
}
|
|
140
130
|
|
|
141
|
-
type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
142
|
-
|
|
143
131
|
function screenClaim(
|
|
144
132
|
value: string | null,
|
|
145
133
|
limit: number,
|
|
146
134
|
field: string,
|
|
147
|
-
|
|
148
|
-
facts: readonly string[] =
|
|
135
|
+
run: Run,
|
|
136
|
+
facts: readonly string[] = run.facts.pooled,
|
|
149
137
|
): string | null {
|
|
150
138
|
if (value === null) return null;
|
|
151
139
|
|
|
152
140
|
const clamped = clamp(value, limit);
|
|
153
141
|
if (clamped.length === 0) return null;
|
|
154
142
|
|
|
155
|
-
const kind =
|
|
156
|
-
if (kind
|
|
157
|
-
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const weighed = NUMERAL.test(clamped) ? facts : NO_FACTS;
|
|
143
|
+
const kind = unbackedClaim(value, clamped, facts);
|
|
144
|
+
if (kind === null) return clamped;
|
|
162
145
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
if (!result.wording.supported) {
|
|
169
|
-
tracker.record(`unverifiable-claim:wording:${field}`);
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return clamped;
|
|
146
|
+
run.violations.push(`unverifiable-claim:${kind}:${field}`);
|
|
147
|
+
return null;
|
|
174
148
|
}
|
|
175
149
|
|
|
176
|
-
function screenRequired(
|
|
177
|
-
value
|
|
178
|
-
limit: number,
|
|
179
|
-
field: string,
|
|
180
|
-
tracker: PlacementTracker,
|
|
181
|
-
): string {
|
|
182
|
-
return screenClaim(value, limit, field, tracker) ?? '';
|
|
150
|
+
function screenRequired(value: string, limit: number, field: string, run: Run): string {
|
|
151
|
+
return screenClaim(value, limit, field, run) ?? '';
|
|
183
152
|
}
|
|
184
153
|
|
|
185
|
-
function rejectionFor(sku: string,
|
|
154
|
+
function rejectionFor(sku: string, run: Run): string | null {
|
|
186
155
|
const named = clamp(sku, CLAMP.violationSku);
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
191
|
-
if (tracker.remaining <= 0) return `budget:dropped:${named}`;
|
|
156
|
+
if (!run.allowed.has(sku)) return `unknown-sku:${named}`;
|
|
157
|
+
if (run.blockedForPicks.has(sku)) return `blocked-sku:${named}`;
|
|
158
|
+
if (run.placed.has(sku)) return `duplicate-sku:${named}`;
|
|
159
|
+
if (run.remaining <= 0) return `budget:dropped:${named}`;
|
|
192
160
|
return null;
|
|
193
161
|
}
|
|
194
162
|
|
|
195
|
-
function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
163
|
+
function takeSlot(sku: string, run: Run): string | null {
|
|
164
|
+
const rejection = rejectionFor(sku, run);
|
|
165
|
+
if (rejection !== null) {
|
|
166
|
+
run.violations.push(rejection);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
place(run, sku);
|
|
170
|
+
return sku;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function reconcileItems(items: ProductReference[], run: Run): ProductReference[] {
|
|
204
174
|
const kept: ProductReference[] = [];
|
|
205
175
|
|
|
206
176
|
for (const item of items) {
|
|
207
|
-
|
|
208
|
-
if (rejection) {
|
|
209
|
-
tracker.record(rejection);
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const product = candidatesBySku.get(item.sku);
|
|
177
|
+
if (takeSlot(item.sku, run) === null) continue;
|
|
214
178
|
|
|
179
|
+
const product = run.products.get(item.sku);
|
|
215
180
|
if (!product) continue;
|
|
216
181
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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);
|
|
182
|
+
if (!verifyBasis(item.basis, product, run)) {
|
|
183
|
+
run.violations.push(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
184
|
+
kept.push({
|
|
185
|
+
sku: item.sku,
|
|
186
|
+
basis: 'popular',
|
|
187
|
+
reason: null,
|
|
188
|
+
badge: null,
|
|
189
|
+
emphasis: item.emphasis,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
234
192
|
}
|
|
235
193
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
194
|
+
const own = run.facts.bySku.get(item.sku) ?? NO_FACTS;
|
|
195
|
+
const reason =
|
|
196
|
+
item.reason !== null && run.ourReasons.get(item.sku) === item.reason
|
|
197
|
+
? clamp(item.reason, CLAMP.reason) || null
|
|
198
|
+
: screenClaim(item.reason, CLAMP.reason, `reason:${item.sku}`, run, own);
|
|
199
|
+
const badge = screenClaim(item.badge, CLAMP.badge, `badge:${item.sku}`, run, own);
|
|
200
|
+
|
|
201
|
+
kept.push({ sku: item.sku, basis: item.basis, reason, badge, emphasis: item.emphasis });
|
|
243
202
|
}
|
|
244
203
|
|
|
245
204
|
return kept;
|
|
@@ -254,16 +213,14 @@ interface BundleFit {
|
|
|
254
213
|
function fitOf(
|
|
255
214
|
bundle: Bundle,
|
|
256
215
|
digest: SignalDigest,
|
|
257
|
-
|
|
216
|
+
products: ReadonlyMap<string, Product>,
|
|
258
217
|
): BundleFit {
|
|
259
218
|
const fit: BundleFit = { cartHits: 0, viewedHits: 0, categoryHits: 0 };
|
|
260
|
-
|
|
261
219
|
for (const sku of bundle.skus) {
|
|
262
220
|
if (digest.cartSkus.includes(sku)) fit.cartHits += 1;
|
|
263
221
|
else if (digest.topViewed.some((viewed) => viewed.sku === sku)) fit.viewedHits += 1;
|
|
264
|
-
else if (
|
|
222
|
+
else if (products.get(sku)?.category === digest.currentCategory) fit.categoryHits += 1;
|
|
265
223
|
}
|
|
266
|
-
|
|
267
224
|
return fit;
|
|
268
225
|
}
|
|
269
226
|
|
|
@@ -274,28 +231,19 @@ function isBetterFit(fit: BundleFit, best: BundleFit | undefined): boolean {
|
|
|
274
231
|
return fit.categoryHits > best.categoryHits;
|
|
275
232
|
}
|
|
276
233
|
|
|
277
|
-
function chooseBundle(
|
|
278
|
-
bundles: readonly Bundle[],
|
|
279
|
-
allowlist: Allowlist,
|
|
280
|
-
digest: SignalDigest,
|
|
281
|
-
candidatesBySku: Map<string, Product>,
|
|
282
|
-
tracker: PlacementTracker,
|
|
283
|
-
): Bundle | undefined {
|
|
234
|
+
function chooseBundle(run: Run): Bundle | undefined {
|
|
284
235
|
let best: Bundle | undefined;
|
|
285
236
|
let bestFit: BundleFit | undefined;
|
|
286
237
|
|
|
287
|
-
for (const bundle of bundles) {
|
|
288
|
-
if (bundle.skus.length >
|
|
238
|
+
for (const bundle of run.bundles) {
|
|
239
|
+
if (bundle.skus.length > run.remaining) continue;
|
|
289
240
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (allowlist.blockedInBundle.has(sku)) isPlaceable = false;
|
|
294
|
-
if (tracker.hasPlaced(sku)) isPlaceable = false;
|
|
295
|
-
}
|
|
241
|
+
const isPlaceable = bundle.skus.every(
|
|
242
|
+
(sku) => run.allowed.has(sku) && !run.blockedForBundles.has(sku) && !run.placed.has(sku),
|
|
243
|
+
);
|
|
296
244
|
if (!isPlaceable) continue;
|
|
297
245
|
|
|
298
|
-
const fit = fitOf(bundle, digest,
|
|
246
|
+
const fit = fitOf(bundle, run.digest, run.products);
|
|
299
247
|
if (isBetterFit(fit, bestFit)) {
|
|
300
248
|
best = bundle;
|
|
301
249
|
bestFit = fit;
|
|
@@ -310,154 +258,109 @@ export function bundleForShopper(
|
|
|
310
258
|
digest: SignalDigest,
|
|
311
259
|
spokenFor: readonly string[],
|
|
312
260
|
): Bundle | undefined {
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
316
|
-
|
|
317
|
-
for (const sku of spokenFor) tracker.place(sku);
|
|
261
|
+
const run = startRun(input, digest, new Map());
|
|
262
|
+
for (const sku of spokenFor) place(run, sku);
|
|
318
263
|
|
|
319
|
-
return chooseBundle(
|
|
264
|
+
return chooseBundle(run);
|
|
320
265
|
}
|
|
321
266
|
|
|
322
267
|
export function placeableHeroSkus(blocks: readonly Block[], input: TrackingInput): string[] {
|
|
323
|
-
const
|
|
324
|
-
|
|
268
|
+
const allowed = inStockSkus(input);
|
|
269
|
+
const blocked = blockedForPicks(input);
|
|
325
270
|
const skus: string[] = [];
|
|
326
271
|
for (const block of blocks) {
|
|
327
|
-
if (block.kind !== 'hero') continue;
|
|
328
|
-
if (block.sku
|
|
329
|
-
if (!
|
|
330
|
-
if (skus.includes(block.sku)) continue;
|
|
331
|
-
skus.push(block.sku);
|
|
272
|
+
if (block.kind !== 'hero' || block.sku === null) continue;
|
|
273
|
+
if (!allowed.has(block.sku) || blocked.has(block.sku)) continue;
|
|
274
|
+
if (!skus.includes(block.sku)) skus.push(block.sku);
|
|
332
275
|
}
|
|
333
|
-
|
|
334
276
|
return skus;
|
|
335
277
|
}
|
|
336
278
|
|
|
337
|
-
function reconcileBlock(
|
|
338
|
-
block: Block,
|
|
339
|
-
allowlist: Allowlist,
|
|
340
|
-
candidatesBySku: Map<string, Product>,
|
|
341
|
-
digest: SignalDigest,
|
|
342
|
-
engaged: ReadonlySet<string>,
|
|
343
|
-
bundles: readonly Bundle[],
|
|
344
|
-
tracker: PlacementTracker,
|
|
345
|
-
ourReasons: ReadonlyMap<string, string>,
|
|
346
|
-
): Block | null {
|
|
279
|
+
function reconcileBlock(block: Block, run: Run): Block | null {
|
|
347
280
|
switch (block.kind) {
|
|
348
281
|
case 'hero': {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const rejection = rejectionFor(sku, allowlist, tracker);
|
|
352
|
-
if (rejection) {
|
|
353
|
-
tracker.record(rejection);
|
|
354
|
-
|
|
355
|
-
sku = null;
|
|
356
|
-
} else {
|
|
357
|
-
tracker.place(sku);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', tracker);
|
|
282
|
+
const sku = block.sku === null ? null : takeSlot(block.sku, run);
|
|
283
|
+
const headline = screenRequired(block.headline, CLAMP.headline, 'hero-headline', run);
|
|
362
284
|
if (headline.length === 0 && sku === null) {
|
|
363
|
-
|
|
285
|
+
run.violations.push('empty-block:hero');
|
|
364
286
|
return null;
|
|
365
287
|
}
|
|
366
288
|
return {
|
|
367
289
|
kind: 'hero',
|
|
368
290
|
headline,
|
|
369
|
-
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body',
|
|
291
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'hero-body', run),
|
|
370
292
|
sku,
|
|
371
|
-
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta',
|
|
293
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'hero-cta', run),
|
|
372
294
|
};
|
|
373
295
|
}
|
|
374
296
|
|
|
375
297
|
case 'grid': {
|
|
376
|
-
const items = reconcileItems(
|
|
377
|
-
block.items,
|
|
378
|
-
allowlist,
|
|
379
|
-
candidatesBySku,
|
|
380
|
-
digest,
|
|
381
|
-
engaged,
|
|
382
|
-
tracker,
|
|
383
|
-
ourReasons,
|
|
384
|
-
);
|
|
298
|
+
const items = reconcileItems(block.items, run);
|
|
385
299
|
if (items.length === 0) {
|
|
386
|
-
|
|
300
|
+
run.violations.push('empty-block:grid');
|
|
387
301
|
return null;
|
|
388
302
|
}
|
|
389
303
|
return {
|
|
390
304
|
kind: 'grid',
|
|
391
|
-
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title',
|
|
392
|
-
|
|
393
|
-
columns: Math.min(block.columns, Math.max(2, items.length)) as 2 | 3 | 4,
|
|
305
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'grid-title', run),
|
|
306
|
+
columns: columnsFor(items.length, block.columns),
|
|
394
307
|
items,
|
|
395
308
|
};
|
|
396
309
|
}
|
|
397
310
|
|
|
398
311
|
case 'carousel': {
|
|
399
|
-
const items = reconcileItems(
|
|
400
|
-
block.items,
|
|
401
|
-
allowlist,
|
|
402
|
-
candidatesBySku,
|
|
403
|
-
digest,
|
|
404
|
-
engaged,
|
|
405
|
-
tracker,
|
|
406
|
-
ourReasons,
|
|
407
|
-
);
|
|
312
|
+
const items = reconcileItems(block.items, run);
|
|
408
313
|
if (items.length === 0) {
|
|
409
|
-
|
|
314
|
+
run.violations.push('empty-block:carousel');
|
|
410
315
|
return null;
|
|
411
316
|
}
|
|
412
317
|
return {
|
|
413
318
|
kind: 'carousel',
|
|
414
|
-
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title',
|
|
319
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'carousel-title', run),
|
|
415
320
|
items,
|
|
416
321
|
};
|
|
417
322
|
}
|
|
418
323
|
|
|
419
324
|
case 'banner': {
|
|
420
|
-
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text',
|
|
325
|
+
const text = screenRequired(block.text, CLAMP.bannerText, 'banner-text', run);
|
|
421
326
|
if (text.length === 0) {
|
|
422
|
-
|
|
327
|
+
run.violations.push('empty-block:banner');
|
|
423
328
|
return null;
|
|
424
329
|
}
|
|
425
330
|
return {
|
|
426
331
|
kind: 'banner',
|
|
427
332
|
tone: block.tone,
|
|
428
333
|
text,
|
|
429
|
-
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta',
|
|
334
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'banner-cta', run),
|
|
430
335
|
};
|
|
431
336
|
}
|
|
432
337
|
|
|
433
338
|
case 'copy': {
|
|
434
|
-
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body',
|
|
339
|
+
const body = screenRequired(block.body, CLAMP.copyBody, 'copy-body', run);
|
|
435
340
|
if (body.length === 0) {
|
|
436
|
-
|
|
341
|
+
run.violations.push('empty-block:copy');
|
|
437
342
|
return null;
|
|
438
343
|
}
|
|
439
344
|
return {
|
|
440
345
|
kind: 'copy',
|
|
441
|
-
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title',
|
|
346
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'copy-title', run),
|
|
442
347
|
body,
|
|
443
348
|
};
|
|
444
349
|
}
|
|
445
350
|
|
|
446
351
|
case 'bundle': {
|
|
447
|
-
const chosen = chooseBundle(
|
|
352
|
+
const chosen = chooseBundle(run);
|
|
448
353
|
if (!chosen) {
|
|
449
|
-
|
|
354
|
+
run.violations.push('no-bundle');
|
|
450
355
|
return null;
|
|
451
356
|
}
|
|
452
357
|
|
|
453
|
-
for (const sku of chosen.skus)
|
|
454
|
-
|
|
358
|
+
for (const sku of chosen.skus) place(run, sku);
|
|
455
359
|
return {
|
|
456
360
|
kind: 'bundle',
|
|
457
|
-
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title',
|
|
458
|
-
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body',
|
|
459
|
-
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta',
|
|
460
|
-
|
|
361
|
+
title: screenClaim(block.title, CLAMP.blockTitle, 'bundle-title', run),
|
|
362
|
+
body: screenClaim(block.body, CLAMP.subheadline, 'bundle-body', run),
|
|
363
|
+
ctaLabel: screenClaim(block.ctaLabel, CLAMP.ctaLabel, 'bundle-cta', run),
|
|
461
364
|
bundleId: chosen.id,
|
|
462
365
|
};
|
|
463
366
|
}
|
|
@@ -478,44 +381,32 @@ export function reconcileSpec(
|
|
|
478
381
|
generated: GeneratedSpec,
|
|
479
382
|
input: TrackingInput,
|
|
480
383
|
digest: SignalDigest,
|
|
481
|
-
|
|
482
384
|
ourReasons: ReadonlyMap<string, string> = new Map(),
|
|
483
385
|
): ReconcileResult {
|
|
484
|
-
const
|
|
485
|
-
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
486
|
-
const engaged = engagedCategories(input);
|
|
487
|
-
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
386
|
+
const run = startRun(input, digest, ourReasons);
|
|
488
387
|
|
|
489
388
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
490
|
-
|
|
389
|
+
run.violations.push(`too-many-blocks:${generated.blocks.length}`);
|
|
491
390
|
}
|
|
492
391
|
|
|
493
392
|
const blocks: Block[] = [];
|
|
494
393
|
for (const block of capBlocks(generated.blocks)) {
|
|
495
|
-
const reconciled = reconcileBlock(
|
|
496
|
-
block,
|
|
497
|
-
allowlist,
|
|
498
|
-
candidatesBySku,
|
|
499
|
-
digest,
|
|
500
|
-
engaged,
|
|
501
|
-
input.bundles,
|
|
502
|
-
tracker,
|
|
503
|
-
ourReasons,
|
|
504
|
-
);
|
|
394
|
+
const reconciled = reconcileBlock(block, run);
|
|
505
395
|
if (reconciled !== null) blocks.push(reconciled);
|
|
506
396
|
}
|
|
507
397
|
|
|
508
398
|
const spec: GeneratedSpec = {
|
|
509
399
|
tone: generated.tone,
|
|
510
|
-
headline: screenRequired(generated.headline, CLAMP.headline, 'headline',
|
|
511
|
-
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline',
|
|
400
|
+
headline: screenRequired(generated.headline, CLAMP.headline, 'headline', run),
|
|
401
|
+
subheadline: screenClaim(generated.subheadline, CLAMP.subheadline, 'subheadline', run),
|
|
512
402
|
blocks,
|
|
513
|
-
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale',
|
|
403
|
+
rationale: screenRequired(generated.rationale, CLAMP.rationale, 'rationale', run),
|
|
514
404
|
};
|
|
515
405
|
|
|
516
|
-
|
|
517
|
-
if (
|
|
518
|
-
|
|
406
|
+
const hasProducts = showsAnyProduct(blocks);
|
|
407
|
+
if (!hasProducts) run.violations.push('unusable:no-products');
|
|
408
|
+
if (spec.headline.length === 0) run.violations.push('unusable:no-headline');
|
|
409
|
+
const isUsable = hasProducts && spec.headline.length > 0;
|
|
519
410
|
|
|
520
|
-
return { spec, isUsable, violations:
|
|
411
|
+
return { spec, isUsable, violations: run.violations };
|
|
521
412
|
}
|