@rudra-js/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/dist/claim-screening.d.ts +33 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +226 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +32 -98
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -49
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -17
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +3 -0
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +3 -13
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +5 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +4 -3
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +1 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +38 -18
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -6
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -3
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +1 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +1 -8
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +10 -22
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +116 -399
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +10 -0
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +38 -28
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +14 -2
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +14 -24
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +15 -13
- package/dist/tracking-input.js.map +1 -1
- package/package.json +4 -4
- package/src/claim-screening.ts +242 -0
- package/src/component-generator.ts +37 -123
- package/src/component-spec.ts +5 -18
- package/src/fallback-component.ts +3 -14
- package/src/fit-to-shopper.ts +9 -4
- package/src/index.ts +1 -0
- package/src/model-prompt.ts +42 -20
- package/src/product-selection.ts +1 -9
- package/src/provider.ts +1 -8
- package/src/reconciliation.ts +135 -430
- package/src/signal-digest.ts +41 -31
- package/src/spec-cache.ts +27 -24
- package/src/tracking-input.ts +16 -13
package/README.md
CHANGED
|
@@ -120,10 +120,23 @@ before anything renders. A product you left out doesn't reach the page.
|
|
|
120
120
|
`bundles` is optional. These are the sets you sell together, each with your own
|
|
121
121
|
price for the set, the currency that price is in, and, if you want one, your own
|
|
122
122
|
name for it. Every product in a set has to be a candidate as well. That's what
|
|
123
|
-
lets the
|
|
123
|
+
lets the checks that pass a single product run over a whole set, and what lets
|
|
124
124
|
the renderer look the members up in the catalog it already has. Ids must be
|
|
125
125
|
unique, and one set must not name the same product twice.
|
|
126
126
|
|
|
127
|
+
A set is placed whole, so one check runs differently: a set may hold something
|
|
128
|
+
the shopper already bought or has in the basket, where a grid or a carousel
|
|
129
|
+
would drop it. A thumbs-down on a member still blocks the whole set, and so does
|
|
130
|
+
a member that is out of stock, already on the page, or the product being looked
|
|
131
|
+
at right now.
|
|
132
|
+
|
|
133
|
+
That last one has a cost. A "frequently bought together, including this item"
|
|
134
|
+
set names the product whose page it sits on, so it is refused on exactly the
|
|
135
|
+
page you wrote it for. Leave the item out and sell the companions as the set —
|
|
136
|
+
that rejection goes away, and the rest of the checks still apply. And when a
|
|
137
|
+
bundle block is the only block the model asked for, refusing the set leaves
|
|
138
|
+
nothing to render, so the deterministic component takes over.
|
|
139
|
+
|
|
127
140
|
The model _never_ picks a set and is never told a price. All it does is ask for a
|
|
128
141
|
bundle block and write the words around it. We pick which set when the page is
|
|
129
142
|
served, going on what the shopper has in their basket, has looked at, or is
|
|
@@ -244,6 +257,30 @@ everyone looking at it shares them. If you pick candidates per shopper you'll ge
|
|
|
244
257
|
smaller cohorts. That's the honest outcome, because your prompt really is
|
|
245
258
|
personal.
|
|
246
259
|
|
|
260
|
+
What goes in the key is the _set_ of candidate SKUs. Not the order you sent them
|
|
261
|
+
in, not which ones were in stock, and not where the 60-product cut fell. So two
|
|
262
|
+
requests carrying the same SKUs in a different order share one component, and the
|
|
263
|
+
one generated first is the one both get. If your ranking is personal, order your
|
|
264
|
+
candidates and the products in the copy won't be the ones you put at the top.
|
|
265
|
+
Prices, titles and stock are always read from your catalog at render time, so
|
|
266
|
+
nothing stale is ever shown — a product that sold out is dropped as the page is
|
|
267
|
+
served, and any sentence written about it stays.
|
|
268
|
+
|
|
269
|
+
#### Who can create a cohort
|
|
270
|
+
|
|
271
|
+
Every field above is one you supply, and each distinct value is a cohort of its
|
|
272
|
+
own — a fresh model call, and a slot in the cache. If a visitor can choose one of
|
|
273
|
+
them, a visitor can choose how many calls you pay for. A `locale` copied straight
|
|
274
|
+
out of `Accept-Language`, a `currentCategory` read off a URL slug and a `segment`
|
|
275
|
+
built from a query parameter are the three that go wrong. The shipped cache holds
|
|
276
|
+
10,000 entries and drops the oldest, so enough made-up values also evict the
|
|
277
|
+
cohorts your real shoppers were being served from.
|
|
278
|
+
|
|
279
|
+
Draw those fields from sets you control: your own locale list, your own category
|
|
280
|
+
ids, your own segment labels. Nothing leaks either way — a cohort prompt carries
|
|
281
|
+
no shopper text at all — the cost is the bill and the model round trip on a page
|
|
282
|
+
that was being served from cache.
|
|
283
|
+
|
|
247
284
|
Pass `generation: 'per-shopper'` to generate for the individual instead. The
|
|
248
285
|
model then chooses the products too, and every shopper pays for their own call.
|
|
249
286
|
|
|
@@ -311,9 +348,19 @@ const generator = createComponentGenerator({ provider, rank: 'given' });
|
|
|
311
348
|
|
|
312
349
|
You keep the rest either way. We still drop anything the shopper shouldn't be
|
|
313
350
|
shown, whether it's out of stock, already bought, in the basket, disliked, or
|
|
314
|
-
the product they're looking at right now
|
|
315
|
-
|
|
316
|
-
|
|
351
|
+
the product they're looking at right now — read from the payload you sent, not
|
|
352
|
+
from the shortened history the model was shown, so a shopper with a long order
|
|
353
|
+
book is covered too. Every product still carries a basis we check against their
|
|
354
|
+
real signals, and everything the model writes is still screened.
|
|
355
|
+
|
|
356
|
+
How much each basis proves is worth knowing, because two of the six prove less
|
|
357
|
+
than they read. `most_viewed` is checked against the SKUs they viewed,
|
|
358
|
+
`similar_to_current` against the category being browsed, `liked_category`
|
|
359
|
+
against the categories they bought, liked, carted or viewed in — standing on a
|
|
360
|
+
category page is not one of those — and `popular` claims nothing.
|
|
361
|
+
`complements_cart` and `complements_purchase` check only that the basket, or the
|
|
362
|
+
order history, isn't empty: nothing here can know that one product goes with
|
|
363
|
+
another. A basis that fails takes the sentence and the badge stating it with it.
|
|
317
364
|
|
|
318
365
|
Each candidate can carry its own `reason`, the phrase shown under the product.
|
|
319
366
|
Reach for it when your ranking knows something the signals don't:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Product, TrackingInput } from './tracking-input.js';
|
|
2
|
+
/**
|
|
3
|
+
* Wording attested bans that this project has already ruled is not a claim. An allowance
|
|
4
|
+
* forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
|
|
5
|
+
* clearance for" — forgives whatever follows it. Exported so a test can hold the list
|
|
6
|
+
* against attested's own.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ALLOWED_PHRASES: string[];
|
|
9
|
+
/**
|
|
10
|
+
* The numbers the shop published about one product: its category and tags, only the strings
|
|
11
|
+
* carrying a digit. Exported so a test can pin what never reaches attested.
|
|
12
|
+
*/
|
|
13
|
+
export declare function productFacts(product: Product): string[];
|
|
14
|
+
export interface HostFacts {
|
|
15
|
+
pooled: string[];
|
|
16
|
+
bySku: Map<string, string[]>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The shop's own numbers for this request, for attested to check numerals against.
|
|
20
|
+
*
|
|
21
|
+
* `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
|
|
22
|
+
* means a product the model never saw. A title and a rating it is shown but told never to
|
|
23
|
+
* repeat, so a numeral out of one is one it was told not to write. The browsed category comes
|
|
24
|
+
* from the request rather than the catalogue, so a host passing a URL segment through would
|
|
25
|
+
* hand this list to whoever types the URL.
|
|
26
|
+
*
|
|
27
|
+
* `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
|
|
28
|
+
* written about another. A `reason` and a `badge` read `bySku`, because they sit under a
|
|
29
|
+
* named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hostFacts(input: TrackingInput): HostFacts;
|
|
32
|
+
export declare function claimIn(text: string): string | null;
|
|
33
|
+
//# sourceMappingURL=claim-screening.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim-screening.d.ts","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAmKlE;;;;;GAKG;AACH,eAAO,MAAM,eAAe,UAAkE,CAAC;AAmB/F;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAOvD;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CAC9B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAWzD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQnD"}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { offeredCandidates } from './model-prompt.js';
|
|
2
|
+
const CLAIM_PATTERNS = [
|
|
3
|
+
{
|
|
4
|
+
// Customers scoring the product. "rated for winter use", "rated to -10C"
|
|
5
|
+
// and "an IPX7 water rating" are about what the product can take.
|
|
6
|
+
kind: 'rating',
|
|
7
|
+
patterns: [
|
|
8
|
+
/\breviews?\b|\breviewed\b/,
|
|
9
|
+
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
10
|
+
/\bstars?[\s-]?ratings?\b/,
|
|
11
|
+
// A rating somebody gave it, rather than one it was built to.
|
|
12
|
+
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
13
|
+
// A score out of five is written as a decimal. A spec is "-5C" or "20,000mm".
|
|
14
|
+
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
15
|
+
// The same score with the word "rating" nowhere near it: "4.8 out of 5".
|
|
16
|
+
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
17
|
+
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
18
|
+
// "rated 4.8" is a score. "rated 3 season" is what the tent is built for.
|
|
19
|
+
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
20
|
+
// How many other people bought or liked it is a customer claim too.
|
|
21
|
+
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
22
|
+
// The words a model reaches for when "best" is banned. Each branch carries its own
|
|
23
|
+
// \b — one in front of the group demands a word character before the #, never there.
|
|
24
|
+
/(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
|
|
25
|
+
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
kind: 'price',
|
|
30
|
+
patterns: [
|
|
31
|
+
// No digit beside it: asking for one let "$thirty-nine and it is yours" through.
|
|
32
|
+
/[$£€¥₹]/,
|
|
33
|
+
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
34
|
+
// The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
|
|
35
|
+
/\bdollars?\b|\beuros?\b/,
|
|
36
|
+
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
37
|
+
/\b(?:usd|eur|gbp)\s?\d/,
|
|
38
|
+
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
39
|
+
/\bkr\s?\d|\d\s?kr\b/,
|
|
40
|
+
/\bpric(?:e|es|ed|ing)\b/,
|
|
41
|
+
// A before-and-after is a price claim even with no currency on it.
|
|
42
|
+
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
43
|
+
// "does not feel cheap" is about quality. Only the comparison is money.
|
|
44
|
+
/\bcheap(?:er|est)\b/,
|
|
45
|
+
/\baffordable\b|\bbargain\b/,
|
|
46
|
+
// "at no cost to comfort" is not money. "costs less" is.
|
|
47
|
+
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
48
|
+
/\blow(?:er)?[\s-]cost\b/,
|
|
49
|
+
// "saves weight" and "saves on weight" are both about grams.
|
|
50
|
+
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
// A percentage in outdoor copy is a specification far more often than it is
|
|
55
|
+
// money off, so it only counts beside a money word: "20% off", not
|
|
56
|
+
// "100% recycled".
|
|
57
|
+
kind: 'discount',
|
|
58
|
+
patterns: [
|
|
59
|
+
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
60
|
+
/\b(?:save|saving|savings|off|discount|extra|up to)\s+(?:up to\s+)?\d+(?:\.\d+)?\s?(?:%|percent)/,
|
|
61
|
+
/\bdiscount(?:s|ed)?\b/,
|
|
62
|
+
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
63
|
+
/\bhalf[\s-]?(?:price|off)\b/,
|
|
64
|
+
// Money off with no percent sign anywhere on it: "save 20 off".
|
|
65
|
+
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
66
|
+
// "extra clearance for thick socks" is room inside the shoe.
|
|
67
|
+
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
68
|
+
// "reduced weight" and "reduced to 900g" are specifications. Only a
|
|
69
|
+
// reduced price, or a reduction with a date on it, is money off.
|
|
70
|
+
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
// When it turns up, not what turns up. "ships in a recycled box" and
|
|
75
|
+
// "arrives flat-packed" describe the thing, so the verb alone is not enough
|
|
76
|
+
// — it needs a day or a date beside it.
|
|
77
|
+
kind: 'delivery',
|
|
78
|
+
patterns: [
|
|
79
|
+
/\bdelivery\b|\bshipping\b/,
|
|
80
|
+
/\bnext[\s-]day\b|\bsame[\s-]day\b|\bovernight\b/,
|
|
81
|
+
/\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/,
|
|
82
|
+
/\b(?:ships?|arrives?|arriving)\s+(?:in|within)\s+\d/,
|
|
83
|
+
/\bin time for\b/,
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
kind: 'stock',
|
|
88
|
+
patterns: [
|
|
89
|
+
/\b(?:in|out of|low on) stock\b/,
|
|
90
|
+
/\blimited stock\b/,
|
|
91
|
+
/\brestocked?\b|\bsold out\b/,
|
|
92
|
+
// "the last few miles" is a distance, so a count needs "left" after it.
|
|
93
|
+
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
94
|
+
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
/** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
|
|
99
|
+
const CONFUSABLES = {
|
|
100
|
+
а: 'a',
|
|
101
|
+
в: 'b',
|
|
102
|
+
е: 'e',
|
|
103
|
+
к: 'k',
|
|
104
|
+
м: 'm',
|
|
105
|
+
н: 'h',
|
|
106
|
+
о: 'o',
|
|
107
|
+
р: 'p',
|
|
108
|
+
с: 'c',
|
|
109
|
+
т: 't',
|
|
110
|
+
х: 'x',
|
|
111
|
+
у: 'y',
|
|
112
|
+
ѕ: 's',
|
|
113
|
+
і: 'i',
|
|
114
|
+
ј: 'j',
|
|
115
|
+
ԁ: 'd',
|
|
116
|
+
һ: 'h',
|
|
117
|
+
ӏ: 'l',
|
|
118
|
+
α: 'a',
|
|
119
|
+
ε: 'e',
|
|
120
|
+
ι: 'i',
|
|
121
|
+
κ: 'k',
|
|
122
|
+
ν: 'v',
|
|
123
|
+
ο: 'o',
|
|
124
|
+
ρ: 'p',
|
|
125
|
+
τ: 't',
|
|
126
|
+
υ: 'u',
|
|
127
|
+
χ: 'x',
|
|
128
|
+
ϲ: 'c',
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Characters that render as nothing: format characters, default-ignorable code points and the
|
|
132
|
+
* controls. Cf and Default_Ignorable each hold characters the other does not.
|
|
133
|
+
* A second copy of the class in attested's `hidden.ts`, pinned to it by a test.
|
|
134
|
+
*/
|
|
135
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
136
|
+
/** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
|
|
137
|
+
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
138
|
+
/**
|
|
139
|
+
* Marks that hang on the character before them instead of taking a column of their own.
|
|
140
|
+
* Dropped after composition, so an accented e keeps its accent while an overline,
|
|
141
|
+
* which composes with nothing, cannot hide a word from its own rule.
|
|
142
|
+
*/
|
|
143
|
+
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
144
|
+
/** Only the reading is normalised. What renders is what the model wrote. */
|
|
145
|
+
function normaliseForClaims(text) {
|
|
146
|
+
const lowered = text
|
|
147
|
+
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
148
|
+
.normalize('NFKC')
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
.normalize('NFC')
|
|
151
|
+
.replace(MARKS, '');
|
|
152
|
+
let folded = '';
|
|
153
|
+
for (const char of lowered)
|
|
154
|
+
folded += CONFUSABLES[char] ?? char;
|
|
155
|
+
return folded;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Wording attested bans that this project has already ruled is not a claim. An allowance
|
|
159
|
+
* forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
|
|
160
|
+
* clearance for" — forgives whatever follows it. Exported so a test can hold the list
|
|
161
|
+
* against attested's own.
|
|
162
|
+
*/
|
|
163
|
+
export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
|
|
164
|
+
/** Only a string with one of these in it can stand behind a numeral. */
|
|
165
|
+
const DIGIT = /\p{Nd}/u;
|
|
166
|
+
/** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
|
|
167
|
+
const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
|
|
168
|
+
/** Wide enough for every finite JavaScript number, and for anything a shop sells. */
|
|
169
|
+
const MAX_EXPONENT = 1000;
|
|
170
|
+
// `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
|
|
171
|
+
// attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
|
|
172
|
+
function canLayOut(fact) {
|
|
173
|
+
const match = BARE_EXPONENT.exec(fact.trim());
|
|
174
|
+
if (match === null)
|
|
175
|
+
return true;
|
|
176
|
+
return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The numbers the shop published about one product: its category and tags, only the strings
|
|
180
|
+
* carrying a digit. Exported so a test can pin what never reaches attested.
|
|
181
|
+
*/
|
|
182
|
+
export function productFacts(product) {
|
|
183
|
+
const facts = [];
|
|
184
|
+
if (DIGIT.test(product.category) && canLayOut(product.category))
|
|
185
|
+
facts.push(product.category);
|
|
186
|
+
for (const tag of product.tags) {
|
|
187
|
+
if (DIGIT.test(tag) && canLayOut(tag))
|
|
188
|
+
facts.push(tag);
|
|
189
|
+
}
|
|
190
|
+
return facts;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The shop's own numbers for this request, for attested to check numerals against.
|
|
194
|
+
*
|
|
195
|
+
* `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
|
|
196
|
+
* means a product the model never saw. A title and a rating it is shown but told never to
|
|
197
|
+
* repeat, so a numeral out of one is one it was told not to write. The browsed category comes
|
|
198
|
+
* from the request rather than the catalogue, so a host passing a URL segment through would
|
|
199
|
+
* hand this list to whoever types the URL.
|
|
200
|
+
*
|
|
201
|
+
* `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
|
|
202
|
+
* written about another. A `reason` and a `badge` read `bySku`, because they sit under a
|
|
203
|
+
* named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
|
|
204
|
+
*/
|
|
205
|
+
export function hostFacts(input) {
|
|
206
|
+
const pooled = new Set();
|
|
207
|
+
const bySku = new Map();
|
|
208
|
+
for (const product of offeredCandidates(input)) {
|
|
209
|
+
const own = productFacts(product);
|
|
210
|
+
bySku.set(product.sku, own);
|
|
211
|
+
for (const fact of own)
|
|
212
|
+
pooled.add(fact);
|
|
213
|
+
}
|
|
214
|
+
return { pooled: [...pooled], bySku };
|
|
215
|
+
}
|
|
216
|
+
export function claimIn(text) {
|
|
217
|
+
const normalised = normaliseForClaims(text);
|
|
218
|
+
for (const claim of CLAIM_PATTERNS) {
|
|
219
|
+
for (const pattern of claim.patterns) {
|
|
220
|
+
if (pattern.test(normalised))
|
|
221
|
+
return claim.kind;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=claim-screening.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claim-screening.js","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"component-generator.d.ts","sourceRoot":"","sources":["../src/component-generator.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,UAAU,EAChB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEnE,OAAO,EAAkB,KAAK,SAAS,EAAoB,MAAM,wBAAwB,CAAC;AAG1F,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;GASG;AACH,MAAM,WAAW,eAAe;IAC9B,6EAA6E;IAC7E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE,UAAU,CAAC;IACnB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;OAWG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,yBAAyB;IACxC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACpC,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;IACtC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,sDAAsD;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5D,0EAA0E;IAC1E,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAAC;CACjE;AAED,qBAAa,YAAa,SAAQ,KAAK;IACrC,YAAY,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAG9C;CACF;AA+HD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,kBAAkB,CA8MpB"}
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { SPEC_VERSION, generatedSpecSchema, } from './component-spec.js';
|
|
3
3
|
import { buildFallbackSpec } from './fallback-component.js';
|
|
4
4
|
import { buildPrompt } from './model-prompt.js';
|
|
5
|
-
import {
|
|
5
|
+
import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
|
|
6
6
|
import { selectProducts } from './product-selection.js';
|
|
7
7
|
import { fitToShopper } from './fit-to-shopper.js';
|
|
8
8
|
import { buildDigest, toCohortDigest } from './signal-digest.js';
|
|
@@ -15,18 +15,10 @@ export class TimeoutError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* must still not hold a page open.
|
|
23
|
-
*
|
|
24
|
-
* Once the deadline has fired the caller is told so, whatever the race
|
|
25
|
-
* actually settled with. Aborting is what makes that necessary: a provider
|
|
26
|
-
* honouring its half of the contract rejects from inside the `abort()` below,
|
|
27
|
-
* so its rejection reaches the race first and the deadline's own never wins.
|
|
28
|
-
* Reporting the error that happened to arrive would blame the vendor for the
|
|
29
|
-
* caller's deadline — and blame it most often on the best-behaved adapters.
|
|
18
|
+
* Once the deadline has fired the caller is told so, whatever the race actually
|
|
19
|
+
* settled with: a provider honouring its half of the contract rejects from
|
|
20
|
+
* inside the `abort()` below, so its rejection reaches the race first and the
|
|
21
|
+
* deadline's own never wins.
|
|
30
22
|
*/
|
|
31
23
|
async function withinBudget(label, milliseconds, start) {
|
|
32
24
|
const controller = new AbortController();
|
|
@@ -49,14 +41,6 @@ async function withinBudget(label, milliseconds, start) {
|
|
|
49
41
|
clearTimeout(timer);
|
|
50
42
|
}
|
|
51
43
|
}
|
|
52
|
-
/**
|
|
53
|
-
* Collapses concurrent work for the same key into one execution.
|
|
54
|
-
*
|
|
55
|
-
* Without this, a key that is not yet cached fans out into one model call per
|
|
56
|
-
* concurrent request — the same answer, bought many times over. The entry is
|
|
57
|
-
* removed as soon as it settles, so one failure does not poison the next
|
|
58
|
-
* attempt.
|
|
59
|
-
*/
|
|
60
44
|
function createSingleFlight() {
|
|
61
45
|
const inFlight = new Map();
|
|
62
46
|
return {
|
|
@@ -77,24 +61,16 @@ const cachedSpecSchema = z.object({
|
|
|
77
61
|
generatedAt: z.number(),
|
|
78
62
|
});
|
|
79
63
|
/**
|
|
80
|
-
* Fills a cohort spec with this shopper's products, keeping room for a set.
|
|
81
|
-
*
|
|
82
|
-
* The grid used to take the whole item budget, so a bundle block later in the
|
|
83
|
-
* spec found nothing left and was dropped. The set is chosen first, its
|
|
84
|
-
* products are held back from the grid, and the grid's limit drops by what the
|
|
85
|
-
* set and the heroes have already spoken for.
|
|
86
|
-
*
|
|
87
64
|
* The arithmetic has to hold whatever order the model put the blocks in, so it
|
|
88
65
|
* is written as one sum over the whole spec rather than as a running budget:
|
|
89
66
|
* the grid gets `maxItems` minus every distinct product the set and the heroes
|
|
90
|
-
* will place.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
67
|
+
* will place. Reconciliation then reaches the same set this did — it can only
|
|
68
|
+
* ever have more placed than the pre-choice assumed, and never one of the set's
|
|
69
|
+
* own products.
|
|
93
70
|
*/
|
|
94
|
-
function fitCohortSpec(spec, input, digest, rank
|
|
71
|
+
function fitCohortSpec(spec, input, digest, rank) {
|
|
95
72
|
const picks = selectProducts(input, digest, { rank });
|
|
96
|
-
|
|
97
|
-
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
73
|
+
const blocks = capBlocks(spec.blocks);
|
|
98
74
|
let hasBundleBlock = false;
|
|
99
75
|
const aboveBundle = [];
|
|
100
76
|
for (const block of blocks) {
|
|
@@ -105,27 +81,26 @@ function fitCohortSpec(spec, input, digest, rank, ourReasons) {
|
|
|
105
81
|
aboveBundle.push(block);
|
|
106
82
|
}
|
|
107
83
|
if (!hasBundleBlock)
|
|
108
|
-
return fitToShopper(spec, picks, digest.maxItems
|
|
84
|
+
return fitToShopper(spec, picks, digest.maxItems);
|
|
109
85
|
// Only the heroes above the bundle block are placed when it is reached, so
|
|
110
86
|
// they are all the choice may account for.
|
|
111
|
-
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input
|
|
87
|
+
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
|
|
112
88
|
if (!chosen)
|
|
113
|
-
return fitToShopper(spec, picks, digest.maxItems
|
|
89
|
+
return fitToShopper(spec, picks, digest.maxItems);
|
|
114
90
|
const spokenFor = new Set(chosen.skus);
|
|
115
|
-
for (const sku of placeableHeroSkus(blocks, input
|
|
91
|
+
for (const sku of placeableHeroSkus(blocks, input))
|
|
116
92
|
spokenFor.add(sku);
|
|
117
93
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
118
94
|
// A set is worth showing, but not at the cost of an empty grid.
|
|
119
95
|
if (roomLeft <= 0)
|
|
120
|
-
return fitToShopper(spec, picks, digest.maxItems
|
|
96
|
+
return fitToShopper(spec, picks, digest.maxItems);
|
|
121
97
|
const forGrid = [];
|
|
122
98
|
for (const pick of picks) {
|
|
123
99
|
if (!spokenFor.has(pick.product.sku))
|
|
124
100
|
forGrid.push(pick);
|
|
125
101
|
}
|
|
126
|
-
return fitToShopper(spec, forGrid, roomLeft
|
|
102
|
+
return fitToShopper(spec, forGrid, roomLeft);
|
|
127
103
|
}
|
|
128
|
-
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
129
104
|
function withProvenance(spec, provenance) {
|
|
130
105
|
return { ...spec, specVersion: SPEC_VERSION, ...provenance };
|
|
131
106
|
}
|
|
@@ -147,14 +122,7 @@ export function createComponentGenerator(options = {}) {
|
|
|
147
122
|
// A broken metrics hook must not take down a page.
|
|
148
123
|
}
|
|
149
124
|
};
|
|
150
|
-
const buildDeterministic = (input, digest, startedAt, key, degradedReason,
|
|
151
|
-
/**
|
|
152
|
-
* What the model call cost, when there was one. A generation that is
|
|
153
|
-
* unusable for this shopper was still asked for and still billed, so
|
|
154
|
-
* omitting it here would hide the calls that produce nothing — exactly the
|
|
155
|
-
* ones worth knowing about.
|
|
156
|
-
*/
|
|
157
|
-
modelCall = {
|
|
125
|
+
const buildDeterministic = (input, digest, startedAt, key, degradedReason, modelCall = {
|
|
158
126
|
calledModel: false,
|
|
159
127
|
}) => {
|
|
160
128
|
const finishedAt = Date.now();
|
|
@@ -175,15 +143,6 @@ export function createComponentGenerator(options = {}) {
|
|
|
175
143
|
degradedReason,
|
|
176
144
|
});
|
|
177
145
|
};
|
|
178
|
-
/**
|
|
179
|
-
* Reads the cache, treating anything unexpected as a miss.
|
|
180
|
-
*
|
|
181
|
-
* The value is re-validated because a store is a port a host implements, and
|
|
182
|
-
* what comes back is no more trustworthy than what a model returns — a shared
|
|
183
|
-
* store outlives a deploy, so it can hold entries written by an older shape of
|
|
184
|
-
* the spec. Generating again is always safe; handing an unvalidated object to
|
|
185
|
-
* reconciliation is not.
|
|
186
|
-
*/
|
|
187
146
|
const readCache = async (key) => {
|
|
188
147
|
try {
|
|
189
148
|
const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
|
|
@@ -191,20 +150,11 @@ export function createComponentGenerator(options = {}) {
|
|
|
191
150
|
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
192
151
|
}
|
|
193
152
|
catch (error) {
|
|
194
|
-
// A store that is down or slow degrades to generating, not to an error
|
|
195
|
-
// page. Nothing here is worth failing a render over.
|
|
153
|
+
// A store that is down or slow degrades to generating, not to an error page.
|
|
196
154
|
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
197
155
|
}
|
|
198
156
|
};
|
|
199
|
-
/**
|
|
200
|
-
* Writes to the cache without the render waiting for it.
|
|
201
|
-
*
|
|
202
|
-
* The spec is already in hand; nothing downstream needs the write to finish.
|
|
203
|
-
* Awaiting it put a second unbounded call to a host-implemented store on the
|
|
204
|
-
* render path, which is the failure this module exists to prevent arriving
|
|
205
|
-
* through the other door. The `Promise.resolve` wrapper is what catches a
|
|
206
|
-
* store that throws synchronously rather than rejecting.
|
|
207
|
-
*/
|
|
157
|
+
/** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
|
|
208
158
|
const storeInBackground = (key, cached) => {
|
|
209
159
|
void Promise.resolve()
|
|
210
160
|
.then(() => cache.set(key, cached))
|
|
@@ -213,15 +163,10 @@ export function createComponentGenerator(options = {}) {
|
|
|
213
163
|
});
|
|
214
164
|
};
|
|
215
165
|
/**
|
|
216
|
-
* Asks the model.
|
|
217
|
-
*
|
|
218
166
|
* Deliberately does not decide whether the answer is usable. That depends on
|
|
219
167
|
* the asking shopper's live facts — stock, dislikes, what is in their basket
|
|
220
168
|
* — and none of those are in the cache key, so a verdict reached here would
|
|
221
|
-
* be handed to every request that joined this one.
|
|
222
|
-
* result means the answer did not satisfy the schema, which is a fault of the
|
|
223
|
-
* adapter rather than a judgement about any shopper — and is still a call
|
|
224
|
-
* that happened, so its usage comes back with it.
|
|
169
|
+
* be handed to every request that joined this one.
|
|
225
170
|
*/
|
|
226
171
|
const askModel = async (active, input, promptDigest) => {
|
|
227
172
|
const { system, user } = buildPrompt(input, promptDigest);
|
|
@@ -248,17 +193,16 @@ export function createComponentGenerator(options = {}) {
|
|
|
248
193
|
if (!provider) {
|
|
249
194
|
return buildDeterministic(input, digest, startedAt, null, 'no-provider');
|
|
250
195
|
}
|
|
251
|
-
const
|
|
196
|
+
const identity = { name: provider.name, model: provider.model };
|
|
197
|
+
const candidateSkus = input.candidates.map((product) => product.sku);
|
|
252
198
|
const key = generation === 'cohort'
|
|
253
|
-
? cohortCacheKey(digest,
|
|
254
|
-
: specCacheKey(digest,
|
|
199
|
+
? cohortCacheKey(digest, candidateSkus, identity)
|
|
200
|
+
: specCacheKey(digest, candidateSkus, identity);
|
|
255
201
|
const read = await readCache(key);
|
|
256
202
|
const cached = read.entry;
|
|
257
203
|
let calledModel = false;
|
|
258
204
|
let answer;
|
|
259
|
-
// When the model produced this, not when it was served.
|
|
260
|
-
// component is not newly generated, and pretending otherwise makes any
|
|
261
|
-
// measure of how stale a page is showing read as zero.
|
|
205
|
+
// When the model produced this, not when it was served.
|
|
262
206
|
let generatedAt;
|
|
263
207
|
if (cached) {
|
|
264
208
|
answer = { spec: cached.spec };
|
|
@@ -266,8 +210,7 @@ export function createComponentGenerator(options = {}) {
|
|
|
266
210
|
}
|
|
267
211
|
else {
|
|
268
212
|
// Asked before joining, because by the time the shared promise settles
|
|
269
|
-
// the entry is gone and there is no way to tell a leader from a
|
|
270
|
-
// follower — and they must not both be counted as a model call.
|
|
213
|
+
// the entry is gone and there is no way to tell a leader from a follower.
|
|
271
214
|
calledModel = !singleFlight.isRunning(key);
|
|
272
215
|
let call;
|
|
273
216
|
try {
|
|
@@ -275,9 +218,6 @@ export function createComponentGenerator(options = {}) {
|
|
|
275
218
|
}
|
|
276
219
|
catch (error) {
|
|
277
220
|
const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
|
|
278
|
-
// The request went out. Leaving `calledModel` to default here reported
|
|
279
|
-
// every failed call as no call at all, so the calls that cost money
|
|
280
|
-
// and produced nothing were the only ones missing from the count.
|
|
281
221
|
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
282
222
|
calledModel,
|
|
283
223
|
cache: read.outcome,
|
|
@@ -296,22 +236,16 @@ export function createComponentGenerator(options = {}) {
|
|
|
296
236
|
// Stored unreconciled on purpose, and stored even when it is unusable
|
|
297
237
|
// for this shopper. Reconciliation narrows a spec to one shopper's live
|
|
298
238
|
// facts, and those move independently of the key — a product can sell
|
|
299
|
-
// out and come back without the candidate list changing.
|
|
300
|
-
// the model said means the restock is picked up from cache rather than
|
|
301
|
-
// paid for again.
|
|
239
|
+
// out and come back without the candidate list changing.
|
|
302
240
|
if (calledModel)
|
|
303
241
|
storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
304
242
|
}
|
|
305
|
-
// One place where anything is served, whichever side of the cache it came
|
|
306
|
-
// from, and always against the facts of the shopper asking now.
|
|
307
243
|
// A cohort spec names products chosen for whoever asked first, and every
|
|
308
|
-
// reason under one of them is written here rather than by the model.
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
? fitCohortSpec(answer.spec, input, digest, rank, ourReasons)
|
|
314
|
-
: answer.spec;
|
|
244
|
+
// reason under one of them is written here rather than by the model. In
|
|
245
|
+
// per-shopper mode this stays empty and every reason is screened.
|
|
246
|
+
const { spec: served, ourReasons } = generation === 'cohort'
|
|
247
|
+
? fitCohortSpec(answer.spec, input, digest, rank)
|
|
248
|
+
: { spec: answer.spec, ourReasons: new Map() };
|
|
315
249
|
const reconciled = reconcileSpec(served, input, digest, ourReasons);
|
|
316
250
|
if (!reconciled.isUsable) {
|
|
317
251
|
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|