@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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { offeredCandidates } from './model-prompt.js';
|
|
2
|
+
import type { Product, TrackingInput } from './tracking-input.js';
|
|
3
|
+
|
|
4
|
+
const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
5
|
+
{
|
|
6
|
+
// Customers scoring the product. "rated for winter use", "rated to -10C"
|
|
7
|
+
// and "an IPX7 water rating" are about what the product can take.
|
|
8
|
+
kind: 'rating',
|
|
9
|
+
patterns: [
|
|
10
|
+
/\breviews?\b|\breviewed\b/,
|
|
11
|
+
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
12
|
+
/\bstars?[\s-]?ratings?\b/,
|
|
13
|
+
// A rating somebody gave it, rather than one it was built to.
|
|
14
|
+
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
15
|
+
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
16
|
+
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
17
|
+
// The same score with the word "rating" nowhere near it: "4.8 out of 5".
|
|
18
|
+
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
19
|
+
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
20
|
+
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
21
|
+
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
22
|
+
// How many other people bought or liked it is a customer claim too.
|
|
23
|
+
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
24
|
+
// The words a model reaches for when "best" is banned. Each branch carries its own
|
|
25
|
+
// \b — one in front of the group demands a word character before the #, never there.
|
|
26
|
+
/(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
|
|
27
|
+
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
kind: 'price',
|
|
32
|
+
patterns: [
|
|
33
|
+
// No digit beside it: asking for one let "$thirty-nine and it is yours" through.
|
|
34
|
+
/[$£€¥₹]/,
|
|
35
|
+
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
36
|
+
// The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
|
|
37
|
+
/\bdollars?\b|\beuros?\b/,
|
|
38
|
+
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
39
|
+
/\b(?:usd|eur|gbp)\s?\d/,
|
|
40
|
+
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
41
|
+
/\bkr\s?\d|\d\s?kr\b/,
|
|
42
|
+
/\bpric(?:e|es|ed|ing)\b/,
|
|
43
|
+
// A before-and-after is a price claim even with no currency on it.
|
|
44
|
+
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
45
|
+
// "does not feel cheap" is about quality. Only the comparison is money.
|
|
46
|
+
/\bcheap(?:er|est)\b/,
|
|
47
|
+
/\baffordable\b|\bbargain\b/,
|
|
48
|
+
// "at no cost to comfort" is not money. "costs less" is.
|
|
49
|
+
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
50
|
+
/\blow(?:er)?[\s-]cost\b/,
|
|
51
|
+
// "saves weight" and "saves on weight" are both about grams.
|
|
52
|
+
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
// A percentage in outdoor copy is a specification far more often than it is
|
|
57
|
+
// money off, so it only counts beside a money word: "20% off", not
|
|
58
|
+
// "100% recycled".
|
|
59
|
+
kind: 'discount',
|
|
60
|
+
patterns: [
|
|
61
|
+
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
62
|
+
/\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
|
|
63
|
+
/\bdiscount(?:s|ed)?\b/,
|
|
64
|
+
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
65
|
+
/\bhalf[\s-]?(?:price|off)\b/,
|
|
66
|
+
// Money off with no percent sign anywhere on it: "save 20 off".
|
|
67
|
+
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
68
|
+
// "extra clearance for thick socks" is room inside the shoe.
|
|
69
|
+
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
70
|
+
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
71
|
+
// reduced price, or a reduction with a date on it, is money off.
|
|
72
|
+
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// When it turns up, not what turns up. "ships in a recycled box" and
|
|
77
|
+
// "arrives flat-packed" describe the thing, so the verb alone is not enough
|
|
78
|
+
// — it needs a day or a date beside it.
|
|
79
|
+
kind: 'delivery',
|
|
80
|
+
patterns: [
|
|
81
|
+
/\bdelivery\b|\bshipping\b/,
|
|
82
|
+
/\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
|
|
83
|
+
/\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/,
|
|
84
|
+
/\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
|
|
85
|
+
/\bin time for\b/,
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
kind: 'stock',
|
|
90
|
+
patterns: [
|
|
91
|
+
/\b(?:in|out of|low on) stock\b/,
|
|
92
|
+
/\blimited stock\b/,
|
|
93
|
+
/\brestocked?\b|\bsold out\b/,
|
|
94
|
+
// "the last few miles" is a distance, so a count needs "left" after it.
|
|
95
|
+
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
96
|
+
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
|
|
102
|
+
const CONFUSABLES: Record<string, string> = {
|
|
103
|
+
а: 'a',
|
|
104
|
+
в: 'b',
|
|
105
|
+
е: 'e',
|
|
106
|
+
к: 'k',
|
|
107
|
+
м: 'm',
|
|
108
|
+
н: 'h',
|
|
109
|
+
о: 'o',
|
|
110
|
+
р: 'p',
|
|
111
|
+
с: 'c',
|
|
112
|
+
т: 't',
|
|
113
|
+
х: 'x',
|
|
114
|
+
у: 'y',
|
|
115
|
+
ѕ: 's',
|
|
116
|
+
і: 'i',
|
|
117
|
+
ј: 'j',
|
|
118
|
+
ԁ: 'd',
|
|
119
|
+
һ: 'h',
|
|
120
|
+
ӏ: 'l',
|
|
121
|
+
α: 'a',
|
|
122
|
+
ε: 'e',
|
|
123
|
+
ι: 'i',
|
|
124
|
+
κ: 'k',
|
|
125
|
+
ν: 'v',
|
|
126
|
+
ο: 'o',
|
|
127
|
+
ρ: 'p',
|
|
128
|
+
τ: 't',
|
|
129
|
+
υ: 'u',
|
|
130
|
+
χ: 'x',
|
|
131
|
+
ϲ: 'c',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Characters that render as nothing: format characters, default-ignorable code points and the
|
|
136
|
+
* controls. Cf and Default_Ignorable each hold characters the other does not.
|
|
137
|
+
* A second copy of the class in attested's `hidden.ts`, pinned to it by a test.
|
|
138
|
+
*/
|
|
139
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
140
|
+
|
|
141
|
+
/** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
|
|
142
|
+
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Marks that hang on the character before them instead of taking a column of their own.
|
|
146
|
+
* Dropped after composition, so an accented e keeps its accent while an overline,
|
|
147
|
+
* which composes with nothing, cannot hide a word from its own rule.
|
|
148
|
+
*/
|
|
149
|
+
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
150
|
+
|
|
151
|
+
/** Only the reading is normalised. What renders is what the model wrote. */
|
|
152
|
+
function normaliseForClaims(text: string): string {
|
|
153
|
+
const lowered = text
|
|
154
|
+
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
155
|
+
.normalize('NFKC')
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.normalize('NFC')
|
|
158
|
+
.replace(MARKS, '');
|
|
159
|
+
|
|
160
|
+
let folded = '';
|
|
161
|
+
for (const char of lowered) folded += CONFUSABLES[char] ?? char;
|
|
162
|
+
return folded;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wording attested bans that this project has already ruled is not a claim. An allowance
|
|
167
|
+
* forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
|
|
168
|
+
* clearance for" — forgives whatever follows it. Exported so a test can hold the list
|
|
169
|
+
* against attested's own.
|
|
170
|
+
*/
|
|
171
|
+
export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
|
|
172
|
+
|
|
173
|
+
/** Only a string with one of these in it can stand behind a numeral. */
|
|
174
|
+
const DIGIT = /\p{Nd}/u;
|
|
175
|
+
|
|
176
|
+
/** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
|
|
177
|
+
const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
|
|
178
|
+
|
|
179
|
+
/** Wide enough for every finite JavaScript number, and for anything a shop sells. */
|
|
180
|
+
const MAX_EXPONENT = 1000;
|
|
181
|
+
|
|
182
|
+
// `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
|
|
183
|
+
// attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
|
|
184
|
+
function canLayOut(fact: string): boolean {
|
|
185
|
+
const match = BARE_EXPONENT.exec(fact.trim());
|
|
186
|
+
if (match === null) return true;
|
|
187
|
+
return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The numbers the shop published about one product: its category and tags, only the strings
|
|
192
|
+
* carrying a digit. Exported so a test can pin what never reaches attested.
|
|
193
|
+
*/
|
|
194
|
+
export function productFacts(product: Product): string[] {
|
|
195
|
+
const facts: string[] = [];
|
|
196
|
+
if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
|
|
197
|
+
for (const tag of product.tags) {
|
|
198
|
+
if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
|
|
199
|
+
}
|
|
200
|
+
return facts;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface HostFacts {
|
|
204
|
+
pooled: string[];
|
|
205
|
+
bySku: Map<string, string[]>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The shop's own numbers for this request, for attested to check numerals against.
|
|
210
|
+
*
|
|
211
|
+
* `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
|
|
212
|
+
* means a product the model never saw. A title and a rating it is shown but told never to
|
|
213
|
+
* repeat, so a numeral out of one is one it was told not to write. The browsed category comes
|
|
214
|
+
* from the request rather than the catalogue, so a host passing a URL segment through would
|
|
215
|
+
* hand this list to whoever types the URL.
|
|
216
|
+
*
|
|
217
|
+
* `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
|
|
218
|
+
* written about another. A `reason` and a `badge` read `bySku`, because they sit under a
|
|
219
|
+
* named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
|
|
220
|
+
*/
|
|
221
|
+
export function hostFacts(input: TrackingInput): HostFacts {
|
|
222
|
+
const pooled = new Set<string>();
|
|
223
|
+
const bySku = new Map<string, string[]>();
|
|
224
|
+
|
|
225
|
+
for (const product of offeredCandidates(input)) {
|
|
226
|
+
const own = productFacts(product);
|
|
227
|
+
bySku.set(product.sku, own);
|
|
228
|
+
for (const fact of own) pooled.add(fact);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { pooled: [...pooled], bySku };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function claimIn(text: string): string | null {
|
|
235
|
+
const normalised = normaliseForClaims(text);
|
|
236
|
+
for (const claim of CLAIM_PATTERNS) {
|
|
237
|
+
for (const pattern of claim.patterns) {
|
|
238
|
+
if (pattern.test(normalised)) return claim.kind;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
@@ -11,14 +11,9 @@ import {
|
|
|
11
11
|
import { buildFallbackSpec } from './fallback-component.js';
|
|
12
12
|
import { buildPrompt } from './model-prompt.js';
|
|
13
13
|
import type { ComponentProvider, TokenUsage } from './provider.js';
|
|
14
|
-
import {
|
|
15
|
-
MAX_BLOCKS,
|
|
16
|
-
bundleForShopper,
|
|
17
|
-
placeableHeroSkus,
|
|
18
|
-
reconcileSpec,
|
|
19
|
-
} from './reconciliation.js';
|
|
14
|
+
import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
|
|
20
15
|
import { selectProducts, type RankOrder, type ProductPick } from './product-selection.js';
|
|
21
|
-
import { fitToShopper } from './fit-to-shopper.js';
|
|
16
|
+
import { fitToShopper, type FittedSpec } from './fit-to-shopper.js';
|
|
22
17
|
import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
|
|
23
18
|
import {
|
|
24
19
|
createMemorySpecCache,
|
|
@@ -139,18 +134,10 @@ export class TimeoutError extends Error {
|
|
|
139
134
|
}
|
|
140
135
|
|
|
141
136
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* must still not hold a page open.
|
|
147
|
-
*
|
|
148
|
-
* Once the deadline has fired the caller is told so, whatever the race
|
|
149
|
-
* actually settled with. Aborting is what makes that necessary: a provider
|
|
150
|
-
* honouring its half of the contract rejects from inside the `abort()` below,
|
|
151
|
-
* so its rejection reaches the race first and the deadline's own never wins.
|
|
152
|
-
* Reporting the error that happened to arrive would blame the vendor for the
|
|
153
|
-
* caller's deadline — and blame it most often on the best-behaved adapters.
|
|
137
|
+
* Once the deadline has fired the caller is told so, whatever the race actually
|
|
138
|
+
* settled with: a provider honouring its half of the contract rejects from
|
|
139
|
+
* inside the `abort()` below, so its rejection reaches the race first and the
|
|
140
|
+
* deadline's own never wins.
|
|
154
141
|
*/
|
|
155
142
|
async function withinBudget<T>(
|
|
156
143
|
label: string,
|
|
@@ -178,21 +165,13 @@ async function withinBudget<T>(
|
|
|
178
165
|
}
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
*
|
|
184
|
-
* Without this, a key that is not yet cached fans out into one model call per
|
|
185
|
-
* concurrent request — the same answer, bought many times over. The entry is
|
|
186
|
-
* removed as soon as it settles, so one failure does not poison the next
|
|
187
|
-
* attempt.
|
|
188
|
-
*/
|
|
189
|
-
function createSingleFlight<T>() {
|
|
190
|
-
const inFlight = new Map<string, Promise<T>>();
|
|
168
|
+
function createSingleFlight() {
|
|
169
|
+
const inFlight = new Map<string, Promise<ModelCall>>();
|
|
191
170
|
|
|
192
171
|
return {
|
|
193
172
|
isRunning: (key: string) => inFlight.has(key),
|
|
194
173
|
|
|
195
|
-
run(key: string, task: () => Promise<
|
|
174
|
+
run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
|
|
196
175
|
const existing = inFlight.get(key);
|
|
197
176
|
if (existing) return existing;
|
|
198
177
|
|
|
@@ -214,48 +193,32 @@ interface CacheRead {
|
|
|
214
193
|
entry?: CachedSpec;
|
|
215
194
|
}
|
|
216
195
|
|
|
217
|
-
/**
|
|
218
|
-
* What one call to the model produced.
|
|
219
|
-
*
|
|
220
|
-
* `spec` is null when the answer did not satisfy the schema. `usage` is carried
|
|
221
|
-
* either way: the request went out and was paid for whether or not anything
|
|
222
|
-
* usable came back, and those are the calls most worth seeing.
|
|
223
|
-
*/
|
|
196
|
+
/** `spec` is null when the answer did not satisfy the schema. `usage` is carried either way: the call was still billed. */
|
|
224
197
|
interface ModelCall {
|
|
225
198
|
spec: GeneratedSpec | null;
|
|
226
199
|
usage?: TokenUsage;
|
|
227
200
|
}
|
|
228
201
|
|
|
229
|
-
/** A model call whose answer can be reconciled and served. */
|
|
230
202
|
interface ModelAnswer extends ModelCall {
|
|
231
203
|
spec: GeneratedSpec;
|
|
232
204
|
}
|
|
233
205
|
|
|
234
206
|
/**
|
|
235
|
-
* Fills a cohort spec with this shopper's products, keeping room for a set.
|
|
236
|
-
*
|
|
237
|
-
* The grid used to take the whole item budget, so a bundle block later in the
|
|
238
|
-
* spec found nothing left and was dropped. The set is chosen first, its
|
|
239
|
-
* products are held back from the grid, and the grid's limit drops by what the
|
|
240
|
-
* set and the heroes have already spoken for.
|
|
241
|
-
*
|
|
242
207
|
* The arithmetic has to hold whatever order the model put the blocks in, so it
|
|
243
208
|
* is written as one sum over the whole spec rather than as a running budget:
|
|
244
209
|
* the grid gets `maxItems` minus every distinct product the set and the heroes
|
|
245
|
-
* will place.
|
|
246
|
-
*
|
|
247
|
-
*
|
|
210
|
+
* will place. Reconciliation then reaches the same set this did — it can only
|
|
211
|
+
* ever have more placed than the pre-choice assumed, and never one of the set's
|
|
212
|
+
* own products.
|
|
248
213
|
*/
|
|
249
214
|
function fitCohortSpec(
|
|
250
215
|
spec: GeneratedSpec,
|
|
251
216
|
input: TrackingInput,
|
|
252
217
|
digest: SignalDigest,
|
|
253
218
|
rank: RankOrder,
|
|
254
|
-
|
|
255
|
-
): GeneratedSpec {
|
|
219
|
+
): FittedSpec {
|
|
256
220
|
const picks = selectProducts(input, digest, { rank });
|
|
257
|
-
|
|
258
|
-
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
221
|
+
const blocks = capBlocks(spec.blocks);
|
|
259
222
|
|
|
260
223
|
let hasBundleBlock = false;
|
|
261
224
|
const aboveBundle: Block[] = [];
|
|
@@ -266,29 +229,28 @@ function fitCohortSpec(
|
|
|
266
229
|
}
|
|
267
230
|
aboveBundle.push(block);
|
|
268
231
|
}
|
|
269
|
-
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems
|
|
232
|
+
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
270
233
|
|
|
271
234
|
// Only the heroes above the bundle block are placed when it is reached, so
|
|
272
235
|
// they are all the choice may account for.
|
|
273
|
-
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input
|
|
274
|
-
if (!chosen) return fitToShopper(spec, picks, digest.maxItems
|
|
236
|
+
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
|
|
237
|
+
if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
|
|
275
238
|
|
|
276
239
|
const spokenFor = new Set<string>(chosen.skus);
|
|
277
|
-
for (const sku of placeableHeroSkus(blocks, input
|
|
240
|
+
for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
|
|
278
241
|
|
|
279
242
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
280
243
|
// A set is worth showing, but not at the cost of an empty grid.
|
|
281
|
-
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems
|
|
244
|
+
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
282
245
|
|
|
283
246
|
const forGrid: ProductPick[] = [];
|
|
284
247
|
for (const pick of picks) {
|
|
285
248
|
if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
|
|
286
249
|
}
|
|
287
250
|
|
|
288
|
-
return fitToShopper(spec, forGrid, roomLeft
|
|
251
|
+
return fitToShopper(spec, forGrid, roomLeft);
|
|
289
252
|
}
|
|
290
253
|
|
|
291
|
-
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
292
254
|
function withProvenance(
|
|
293
255
|
spec: GeneratedSpec,
|
|
294
256
|
provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
|
|
@@ -305,7 +267,7 @@ export function createComponentGenerator(
|
|
|
305
267
|
const rank = options.rank ?? 'signals';
|
|
306
268
|
const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
|
|
307
269
|
const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
|
|
308
|
-
const singleFlight = createSingleFlight
|
|
270
|
+
const singleFlight = createSingleFlight();
|
|
309
271
|
|
|
310
272
|
const report = (event: GenerationEvent): void => {
|
|
311
273
|
if (!options.onEvent) return;
|
|
@@ -322,12 +284,6 @@ export function createComponentGenerator(
|
|
|
322
284
|
startedAt: number,
|
|
323
285
|
key: string | null,
|
|
324
286
|
degradedReason: DegradedReason,
|
|
325
|
-
/**
|
|
326
|
-
* What the model call cost, when there was one. A generation that is
|
|
327
|
-
* unusable for this shopper was still asked for and still billed, so
|
|
328
|
-
* omitting it here would hide the calls that produce nothing — exactly the
|
|
329
|
-
* ones worth knowing about.
|
|
330
|
-
*/
|
|
331
287
|
modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
|
|
332
288
|
calledModel: false,
|
|
333
289
|
},
|
|
@@ -352,36 +308,18 @@ export function createComponentGenerator(
|
|
|
352
308
|
});
|
|
353
309
|
};
|
|
354
310
|
|
|
355
|
-
/**
|
|
356
|
-
* Reads the cache, treating anything unexpected as a miss.
|
|
357
|
-
*
|
|
358
|
-
* The value is re-validated because a store is a port a host implements, and
|
|
359
|
-
* what comes back is no more trustworthy than what a model returns — a shared
|
|
360
|
-
* store outlives a deploy, so it can hold entries written by an older shape of
|
|
361
|
-
* the spec. Generating again is always safe; handing an unvalidated object to
|
|
362
|
-
* reconciliation is not.
|
|
363
|
-
*/
|
|
364
311
|
const readCache = async (key: string): Promise<CacheRead> => {
|
|
365
312
|
try {
|
|
366
313
|
const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
|
|
367
314
|
const parsed = cachedSpecSchema.safeParse(stored);
|
|
368
315
|
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
369
316
|
} catch (error) {
|
|
370
|
-
// A store that is down or slow degrades to generating, not to an error
|
|
371
|
-
// page. Nothing here is worth failing a render over.
|
|
317
|
+
// A store that is down or slow degrades to generating, not to an error page.
|
|
372
318
|
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
373
319
|
}
|
|
374
320
|
};
|
|
375
321
|
|
|
376
|
-
/**
|
|
377
|
-
* Writes to the cache without the render waiting for it.
|
|
378
|
-
*
|
|
379
|
-
* The spec is already in hand; nothing downstream needs the write to finish.
|
|
380
|
-
* Awaiting it put a second unbounded call to a host-implemented store on the
|
|
381
|
-
* render path, which is the failure this module exists to prevent arriving
|
|
382
|
-
* through the other door. The `Promise.resolve` wrapper is what catches a
|
|
383
|
-
* store that throws synchronously rather than rejecting.
|
|
384
|
-
*/
|
|
322
|
+
/** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
|
|
385
323
|
const storeInBackground = (key: string, cached: CachedSpec): void => {
|
|
386
324
|
void Promise.resolve()
|
|
387
325
|
.then(() => cache.set(key, cached))
|
|
@@ -391,15 +329,10 @@ export function createComponentGenerator(
|
|
|
391
329
|
};
|
|
392
330
|
|
|
393
331
|
/**
|
|
394
|
-
* Asks the model.
|
|
395
|
-
*
|
|
396
332
|
* Deliberately does not decide whether the answer is usable. That depends on
|
|
397
333
|
* the asking shopper's live facts — stock, dislikes, what is in their basket
|
|
398
334
|
* — and none of those are in the cache key, so a verdict reached here would
|
|
399
|
-
* be handed to every request that joined this one.
|
|
400
|
-
* result means the answer did not satisfy the schema, which is a fault of the
|
|
401
|
-
* adapter rather than a judgement about any shopper — and is still a call
|
|
402
|
-
* that happened, so its usage comes back with it.
|
|
335
|
+
* be handed to every request that joined this one.
|
|
403
336
|
*/
|
|
404
337
|
const askModel = async (
|
|
405
338
|
active: ComponentProvider,
|
|
@@ -439,27 +372,18 @@ export function createComponentGenerator(
|
|
|
439
372
|
return buildDeterministic(input, digest, startedAt, null, 'no-provider');
|
|
440
373
|
}
|
|
441
374
|
|
|
442
|
-
const
|
|
375
|
+
const identity = { name: provider.name, model: provider.model };
|
|
376
|
+
const candidateSkus = input.candidates.map((product) => product.sku);
|
|
443
377
|
const key =
|
|
444
378
|
generation === 'cohort'
|
|
445
|
-
? cohortCacheKey(
|
|
446
|
-
|
|
447
|
-
input.candidates.map((product) => product.sku),
|
|
448
|
-
providerId,
|
|
449
|
-
)
|
|
450
|
-
: specCacheKey(
|
|
451
|
-
digest,
|
|
452
|
-
input.candidates.map((product) => product.sku),
|
|
453
|
-
providerId,
|
|
454
|
-
);
|
|
379
|
+
? cohortCacheKey(digest, candidateSkus, identity)
|
|
380
|
+
: specCacheKey(digest, candidateSkus, identity);
|
|
455
381
|
|
|
456
382
|
const read = await readCache(key);
|
|
457
383
|
const cached = read.entry;
|
|
458
384
|
let calledModel = false;
|
|
459
385
|
let answer: ModelAnswer;
|
|
460
|
-
// When the model produced this, not when it was served.
|
|
461
|
-
// component is not newly generated, and pretending otherwise makes any
|
|
462
|
-
// measure of how stale a page is showing read as zero.
|
|
386
|
+
// When the model produced this, not when it was served.
|
|
463
387
|
let generatedAt: number;
|
|
464
388
|
|
|
465
389
|
if (cached) {
|
|
@@ -467,8 +391,7 @@ export function createComponentGenerator(
|
|
|
467
391
|
generatedAt = cached.generatedAt;
|
|
468
392
|
} else {
|
|
469
393
|
// Asked before joining, because by the time the shared promise settles
|
|
470
|
-
// the entry is gone and there is no way to tell a leader from a
|
|
471
|
-
// follower — and they must not both be counted as a model call.
|
|
394
|
+
// the entry is gone and there is no way to tell a leader from a follower.
|
|
472
395
|
calledModel = !singleFlight.isRunning(key);
|
|
473
396
|
|
|
474
397
|
let call: ModelCall;
|
|
@@ -478,9 +401,6 @@ export function createComponentGenerator(
|
|
|
478
401
|
);
|
|
479
402
|
} catch (error) {
|
|
480
403
|
const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
|
|
481
|
-
// The request went out. Leaving `calledModel` to default here reported
|
|
482
|
-
// every failed call as no call at all, so the calls that cost money
|
|
483
|
-
// and produced nothing were the only ones missing from the count.
|
|
484
404
|
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
485
405
|
calledModel,
|
|
486
406
|
cache: read.outcome,
|
|
@@ -502,24 +422,19 @@ export function createComponentGenerator(
|
|
|
502
422
|
// Stored unreconciled on purpose, and stored even when it is unusable
|
|
503
423
|
// for this shopper. Reconciliation narrows a spec to one shopper's live
|
|
504
424
|
// facts, and those move independently of the key — a product can sell
|
|
505
|
-
// out and come back without the candidate list changing.
|
|
506
|
-
// the model said means the restock is picked up from cache rather than
|
|
507
|
-
// paid for again.
|
|
425
|
+
// out and come back without the candidate list changing.
|
|
508
426
|
if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
509
427
|
}
|
|
510
428
|
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// A cohort spec names products chosen for whoever asked first.
|
|
514
|
-
// Only the cohort path writes a host reason into a spec, so in
|
|
429
|
+
// A cohort spec names products chosen for whoever asked first, and every
|
|
430
|
+
// reason under one of them is written here rather than by the model. In
|
|
515
431
|
// per-shopper mode this stays empty and every reason is screened.
|
|
516
|
-
const
|
|
517
|
-
const served =
|
|
432
|
+
const { spec: served, ourReasons } =
|
|
518
433
|
generation === 'cohort'
|
|
519
|
-
? fitCohortSpec(answer.spec, input, digest, rank
|
|
520
|
-
: answer.spec;
|
|
434
|
+
? fitCohortSpec(answer.spec, input, digest, rank)
|
|
435
|
+
: { spec: answer.spec, ourReasons: new Map<string, string>() };
|
|
521
436
|
|
|
522
|
-
const reconciled = reconcileSpec(served, input, digest,
|
|
437
|
+
const reconciled = reconcileSpec(served, input, digest, ourReasons);
|
|
523
438
|
if (!reconciled.isUsable) {
|
|
524
439
|
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|
|
525
440
|
calledModel,
|
package/src/component-spec.ts
CHANGED
|
@@ -77,20 +77,9 @@ export const RECOMMENDATION_BASES = [
|
|
|
77
77
|
export const productReferenceSchema = z.object({
|
|
78
78
|
/** Must be a SKU the host supplied in `TrackingInput.candidates`. */
|
|
79
79
|
sku: z.string(),
|
|
80
|
-
/**
|
|
81
|
-
* The strategy behind this pick. Reconciliation checks it against the
|
|
82
|
-
* shopper's signals, so the model cannot assert a relationship that is not
|
|
83
|
-
* there.
|
|
84
|
-
*/
|
|
80
|
+
/** Checked against the shopper's signals, so the model cannot assert a pick it did not earn. */
|
|
85
81
|
basis: z.enum(RECOMMENDATION_BASES),
|
|
86
|
-
/**
|
|
87
|
-
* How the basis is phrased for the shopper, e.g. "Pairs with the boots you
|
|
88
|
-
* bought". Free text, but it has to be consistent with `basis`, which is not.
|
|
89
|
-
*
|
|
90
|
-
* Nullable because reconciliation clears it when it cannot verify the basis:
|
|
91
|
-
* a pick may still be worth showing when the stated reason for it is not
|
|
92
|
-
* true, but the prose asserting that reason must not render.
|
|
93
|
-
*/
|
|
82
|
+
/** How the basis is phrased, e.g. "Pairs with the boots you bought". Null once unverifiable. */
|
|
94
83
|
reason: z.string().nullable(),
|
|
95
84
|
/** Short accent label, e.g. "Worth a look". Null when nothing warrants one. */
|
|
96
85
|
badge: z.string().nullable(),
|
|
@@ -177,13 +166,11 @@ export const generatedSpecSchema = z.object({
|
|
|
177
166
|
headline: z.string(),
|
|
178
167
|
subheadline: z.string().nullable(),
|
|
179
168
|
blocks: z.array(blockSchema),
|
|
180
|
-
/**
|
|
181
|
-
* One sentence on why this arrangement was chosen. For engineers reading
|
|
182
|
-
* generation logs, not for shoppers; it is not rendered by default.
|
|
183
|
-
*/
|
|
169
|
+
/** One sentence for generation logs, not for shoppers. Not rendered by default. */
|
|
184
170
|
rationale: z.string(),
|
|
185
171
|
});
|
|
186
172
|
export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
|
|
173
|
+
export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;
|
|
187
174
|
|
|
188
175
|
/** How a spec came to exist. Surfaced for benchmarking and observability. */
|
|
189
176
|
export type SpecSource = 'llm' | 'cache' | 'fallback';
|
|
@@ -238,6 +225,6 @@ export function parseGeneratedSpec(value: unknown): GeneratedSpec {
|
|
|
238
225
|
}
|
|
239
226
|
|
|
240
227
|
/** Non-throwing variant, for validating untrusted model output. */
|
|
241
|
-
export function safeParseGeneratedSpec(value: unknown) {
|
|
228
|
+
export function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult {
|
|
242
229
|
return generatedSpecSchema.safeParse(value);
|
|
243
230
|
}
|
|
@@ -3,20 +3,6 @@ import type { SignalDigest } from './signal-digest.js';
|
|
|
3
3
|
import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
|
|
4
4
|
import type { TrackingInput } from './tracking-input.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* The deterministic component — what renders when no model does.
|
|
8
|
-
*
|
|
9
|
-
* The manuscript names data latency as the central risk of moving
|
|
10
|
-
* personalisation onto the server path: any delay in the recommendation engine
|
|
11
|
-
* blocks the page. This module is the answer. It is pure, synchronous, and
|
|
12
|
-
* cannot fail, so the server always has something correct to render — whether
|
|
13
|
-
* the model is slow, erroring, rate-limited, or simply not configured.
|
|
14
|
-
*
|
|
15
|
-
* It reads the same digest and uses the same selector the model path does, so a
|
|
16
|
-
* degraded render is a weaker version of the same decision rather than an
|
|
17
|
-
* unrelated one. Only the presentation is fixed.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
6
|
/** A featured lead only reads as deliberate when something follows it. */
|
|
21
7
|
const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
|
|
22
8
|
|
|
@@ -56,6 +42,9 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
|
|
|
56
42
|
* no blocks only when there is genuinely nothing in stock left to show, which
|
|
57
43
|
* the renderer treats as "render nothing" — an empty recommendation region is
|
|
58
44
|
* worse than none.
|
|
45
|
+
*
|
|
46
|
+
* Reads the same digest and selector the model path does, so a degraded render is
|
|
47
|
+
* a weaker version of the same decision rather than an unrelated one.
|
|
59
48
|
*/
|
|
60
49
|
export function buildFallbackSpec(
|
|
61
50
|
input: TrackingInput,
|