@rudra-js/core 0.6.0 → 0.7.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 +13 -12
- package/dist/claim-screening.d.ts +0 -23
- package/dist/claim-screening.d.ts.map +1 -1
- package/dist/claim-screening.js +0 -69
- package/dist/claim-screening.js.map +1 -1
- package/dist/component-generator.d.ts +0 -70
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +10 -52
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -110
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +0 -78
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +0 -9
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +0 -11
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +1 -11
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +0 -29
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +0 -79
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -23
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +0 -19
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +0 -48
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +0 -5
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +1 -44
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +2 -109
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +0 -27
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +1 -50
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +6 -80
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +1 -54
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts +0 -49
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +0 -102
- package/dist/tracking-input.js.map +1 -1
- package/package.json +7 -6
- package/src/claim-screening.ts +17 -69
- package/src/component-generator.ts +24 -133
- package/src/component-spec.ts +15 -103
- package/src/fallback-component.ts +0 -11
- package/src/fit-to-shopper.ts +3 -12
- package/src/index.ts +0 -8
- package/src/model-prompt.ts +1 -82
- package/src/product-selection.ts +5 -43
- package/src/provider.ts +4 -53
- package/src/reconciliation.ts +12 -112
- package/src/signal-digest.ts +5 -64
- package/src/spec-cache.ts +8 -98
- package/src/tracking-input.ts +11 -108
package/src/claim-screening.ts
CHANGED
|
@@ -3,26 +3,23 @@ import type { Product, TrackingInput } from './tracking-input.js';
|
|
|
3
3
|
|
|
4
4
|
const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
5
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
6
|
kind: 'rating',
|
|
9
7
|
patterns: [
|
|
10
8
|
/\breviews?\b|\breviewed\b/,
|
|
11
9
|
/\b(?:\d+(?:\.\d+)?|three|four|five)[\s-]?stars?\b/,
|
|
12
10
|
/\bstars?[\s-]?ratings?\b/,
|
|
13
|
-
|
|
11
|
+
|
|
14
12
|
/\b(?:customer|shopper|buyer|user|average|overall)[\s-]ratings?\b/,
|
|
15
|
-
|
|
13
|
+
|
|
16
14
|
/\bratings?\s+of\s+[0-5]\.\d\b/,
|
|
17
|
-
|
|
15
|
+
|
|
18
16
|
/\b[0-5](?:\.\d)?\s+out of\s+(?:5|five)\b/,
|
|
19
17
|
/\b(?:highly|top|best|well|poorly|five|four)[\s-]rated\b/,
|
|
20
|
-
|
|
18
|
+
|
|
21
19
|
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
22
|
-
|
|
20
|
+
|
|
23
21
|
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
24
|
-
|
|
25
|
-
// \b — one in front of the group demands a word character before the #, never there.
|
|
22
|
+
|
|
26
23
|
/(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
|
|
27
24
|
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
28
25
|
],
|
|
@@ -30,32 +27,28 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
30
27
|
{
|
|
31
28
|
kind: 'price',
|
|
32
29
|
patterns: [
|
|
33
|
-
// No digit beside it: asking for one let "$thirty-nine and it is yours" through.
|
|
34
30
|
/[$£€¥₹]/,
|
|
35
31
|
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
36
|
-
|
|
32
|
+
|
|
37
33
|
/\bdollars?\b|\beuros?\b/,
|
|
38
|
-
|
|
34
|
+
|
|
39
35
|
/\b(?:usd|eur|gbp)\s?\d/,
|
|
40
|
-
|
|
36
|
+
|
|
41
37
|
/\bkr\s?\d|\d\s?kr\b/,
|
|
42
38
|
/\bpric(?:e|es|ed|ing)\b/,
|
|
43
|
-
|
|
39
|
+
|
|
44
40
|
/\bwas\s+[$£€¥]?\s?\d[\d,.]*\s*[,;–—-]?\s*now\s+[$£€¥]?\s?\d/,
|
|
45
|
-
|
|
41
|
+
|
|
46
42
|
/\bcheap(?:er|est)\b/,
|
|
47
43
|
/\baffordable\b|\bbargain\b/,
|
|
48
|
-
|
|
44
|
+
|
|
49
45
|
/\bcosts?\s+(?:less|more|only|just|about|around|[$£€¥]?\d)/,
|
|
50
46
|
/\blow(?:er)?[\s-]cost\b/,
|
|
51
|
-
|
|
47
|
+
|
|
52
48
|
/\bsaves?\s+(?:you\s+)?(?:money|cash|[$£€¥]\s?\d)/,
|
|
53
49
|
],
|
|
54
50
|
},
|
|
55
51
|
{
|
|
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
52
|
kind: 'discount',
|
|
60
53
|
patterns: [
|
|
61
54
|
/\d+(?:\.\d+)?\s?(?:%|percent)\s*(?:off\b|discount|reduction|less\b)/,
|
|
@@ -63,19 +56,15 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
63
56
|
/\bdiscount(?:s|ed)?\b/,
|
|
64
57
|
/\bsale\b|\bmarked down\b|\bdeal of\b/,
|
|
65
58
|
/\bhalf[\s-]?(?:price|off)\b/,
|
|
66
|
-
|
|
59
|
+
|
|
67
60
|
/\bsaves?\s+\d[\d.,]*\s+off\b/,
|
|
68
|
-
|
|
61
|
+
|
|
69
62
|
/\bclearance\s+(?:sale|price|event|deal)\b|\bon clearance\b/,
|
|
70
|
-
|
|
71
|
-
// reduced price, or a reduction with a date on it, is money off.
|
|
63
|
+
|
|
72
64
|
/\bprice reduced\b|\breduced price\b|\breduced by \d+\s?(?:%|percent)|\breduced\s+(?:this|next|last)\s+week\b/,
|
|
73
65
|
],
|
|
74
66
|
},
|
|
75
67
|
{
|
|
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
68
|
kind: 'delivery',
|
|
80
69
|
patterns: [
|
|
81
70
|
/\bdelivery\b|\bshipping\b/,
|
|
@@ -91,14 +80,13 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
91
80
|
/\b(?:in|out of|low on) stock\b/,
|
|
92
81
|
/\blimited stock\b/,
|
|
93
82
|
/\brestocked?\b|\bsold out\b/,
|
|
94
|
-
|
|
83
|
+
|
|
95
84
|
/\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
|
|
96
85
|
/\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
|
|
97
86
|
],
|
|
98
87
|
},
|
|
99
88
|
];
|
|
100
89
|
|
|
101
|
-
/** Letters that render as a Latin letter but are not one — a Cyrillic о in "in stоck". */
|
|
102
90
|
const CONFUSABLES: Record<string, string> = {
|
|
103
91
|
а: 'a',
|
|
104
92
|
в: 'b',
|
|
@@ -131,24 +119,12 @@ const CONFUSABLES: Record<string, string> = {
|
|
|
131
119
|
ϲ: 'c',
|
|
132
120
|
};
|
|
133
121
|
|
|
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
122
|
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
140
123
|
|
|
141
|
-
/** The controls that do take room. Deleting these would read `Only 2\n3 left` as 23. */
|
|
142
124
|
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
143
125
|
|
|
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
126
|
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
150
127
|
|
|
151
|
-
/** Only the reading is normalised. What renders is what the model wrote. */
|
|
152
128
|
function normaliseForClaims(text: string): string {
|
|
153
129
|
const lowered = text
|
|
154
130
|
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
@@ -162,35 +138,20 @@ function normaliseForClaims(text: string): string {
|
|
|
162
138
|
return folded;
|
|
163
139
|
}
|
|
164
140
|
|
|
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
141
|
export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
|
|
172
142
|
|
|
173
|
-
/** Only a string with one of these in it can stand behind a numeral. */
|
|
174
143
|
const DIGIT = /\p{Nd}/u;
|
|
175
144
|
|
|
176
|
-
/** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
|
|
177
145
|
const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
|
|
178
146
|
|
|
179
|
-
/** Wide enough for every finite JavaScript number, and for anything a shop sells. */
|
|
180
147
|
const MAX_EXPONENT = 1000;
|
|
181
148
|
|
|
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
149
|
function canLayOut(fact: string): boolean {
|
|
185
150
|
const match = BARE_EXPONENT.exec(fact.trim());
|
|
186
151
|
if (match === null) return true;
|
|
187
152
|
return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
|
|
188
153
|
}
|
|
189
154
|
|
|
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
155
|
export function productFacts(product: Product): string[] {
|
|
195
156
|
const facts: string[] = [];
|
|
196
157
|
if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
|
|
@@ -205,19 +166,6 @@ export interface HostFacts {
|
|
|
205
166
|
bySku: Map<string, string[]>;
|
|
206
167
|
}
|
|
207
168
|
|
|
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
169
|
export function hostFacts(input: TrackingInput): HostFacts {
|
|
222
170
|
const pooled = new Set<string>();
|
|
223
171
|
const bySku = new Map<string, string[]>();
|
|
@@ -28,52 +28,14 @@ import {
|
|
|
28
28
|
type TrackingInputDraft,
|
|
29
29
|
} from './tracking-input.js';
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* Turns one tracking payload into one renderable component.
|
|
33
|
-
*
|
|
34
|
-
* Everything else in this package is a piece of that sentence; this module is
|
|
35
|
-
* the order they go in. It is deliberately the only place that knows the whole
|
|
36
|
-
* sequence, and it is written as a straight line so the sequence is readable:
|
|
37
|
-
*
|
|
38
|
-
* validate → digest → cache → generate → reconcile → render
|
|
39
|
-
*
|
|
40
|
-
* The single promise it makes to a caller is that `generate` always returns
|
|
41
|
-
* something renderable. A model that is slow, refusing, erroring, rate-limited
|
|
42
|
-
* or simply not configured produces the deterministic component instead. The
|
|
43
|
-
* only way it rejects is a malformed payload, which is a caller bug and should
|
|
44
|
-
* be loud.
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Reported exactly once per call to `generate`, whatever happened.
|
|
49
|
-
*
|
|
50
|
-
* One flat shape rather than a variant per outcome, because the numbers the
|
|
51
|
-
* evaluation needs are ratios over all calls — hit rate, fallback share, model
|
|
52
|
-
* calls and cost per thousand views. A variant that some callers do not emit
|
|
53
|
-
* makes every one of those ratios wrong by however many it skipped, which is
|
|
54
|
-
* what happened when requests that joined an in-flight generation reported
|
|
55
|
-
* nothing at all.
|
|
56
|
-
*/
|
|
57
31
|
export interface GenerationEvent {
|
|
58
|
-
/** Null when no key was computed, which means no provider was configured. */
|
|
59
32
|
key: string | null;
|
|
60
33
|
source: SpecSource;
|
|
61
|
-
|
|
34
|
+
|
|
62
35
|
elapsedMs: number;
|
|
63
|
-
|
|
64
|
-
* True for the caller that sent the request, on every outcome — including a
|
|
65
|
-
* call that timed out, errored or came back unparseable. Requests that joined
|
|
66
|
-
* an in-flight generation share its answer and its usage figures, so cost
|
|
67
|
-
* must be summed over this flag rather than over every event.
|
|
68
|
-
*
|
|
69
|
-
* It counts requests sent, which is an upper bound on requests billed: an
|
|
70
|
-
* adapter that throws before it reaches the vendor looks the same from here
|
|
71
|
-
* as one that throws after. An upper bound is the useful direction — the
|
|
72
|
-
* calls that produce nothing are the ones worth seeing, and reporting them as
|
|
73
|
-
* no call at all hides them completely.
|
|
74
|
-
*/
|
|
36
|
+
|
|
75
37
|
calledModel: boolean;
|
|
76
|
-
|
|
38
|
+
|
|
77
39
|
violations?: string[];
|
|
78
40
|
usage?: TokenUsage;
|
|
79
41
|
degradedReason?: DegradedReason;
|
|
@@ -82,47 +44,24 @@ export interface GenerationEvent {
|
|
|
82
44
|
}
|
|
83
45
|
|
|
84
46
|
export interface ComponentGeneratorOptions {
|
|
85
|
-
/**
|
|
86
|
-
* Omit to run without a model. That is a supported configuration rather than
|
|
87
|
-
* a stub: it is the control arm of the benchmark, and the right setting for
|
|
88
|
-
* anyone who has not yet decided on a provider.
|
|
89
|
-
*/
|
|
90
47
|
provider?: ComponentProvider | null;
|
|
91
|
-
|
|
48
|
+
|
|
92
49
|
cache?: SpecCache;
|
|
93
|
-
|
|
94
|
-
* How long the model gets. Past this the deterministic component renders and
|
|
95
|
-
* the request is aborted. Defaults to 1500ms.
|
|
96
|
-
*/
|
|
50
|
+
|
|
97
51
|
modelTimeoutMs?: number;
|
|
98
|
-
|
|
99
|
-
* How long the cache gets. The shipped caches cannot exceed it, but the store
|
|
100
|
-
* is a port a host implements — a hung Redis read on the render path would
|
|
101
|
-
* hold the page open, which is exactly what this module exists to prevent.
|
|
102
|
-
*/
|
|
52
|
+
|
|
103
53
|
cacheTimeoutMs?: number;
|
|
104
|
-
|
|
105
|
-
* 'cohort' shares one generated component between shoppers who look alike and
|
|
106
|
-
* fills in each shopper's own products. 'per-shopper' generates for the
|
|
107
|
-
* individual, which is what the benchmark compares against. Defaults to
|
|
108
|
-
* 'cohort'.
|
|
109
|
-
*/
|
|
54
|
+
|
|
110
55
|
generation?: 'cohort' | 'per-shopper';
|
|
111
|
-
|
|
112
|
-
* How the products are ordered. 'signals' scores each candidate from this
|
|
113
|
-
* shopper's signals. 'given' keeps the order you sent, for a shop whose own
|
|
114
|
-
* ranking is better than four weights. Either way the exclusions and the
|
|
115
|
-
* stock check still apply, and each product still carries a basis
|
|
116
|
-
* reconciliation can verify. Defaults to 'signals'.
|
|
117
|
-
*/
|
|
56
|
+
|
|
118
57
|
rank?: RankOrder;
|
|
119
|
-
|
|
58
|
+
|
|
120
59
|
onEvent?: (event: GenerationEvent) => void;
|
|
121
60
|
}
|
|
122
61
|
|
|
123
62
|
export interface ComponentGenerator {
|
|
124
63
|
generate(input: TrackingInputDraft): Promise<ComponentSpec>;
|
|
125
|
-
|
|
64
|
+
|
|
126
65
|
generateDeterministic(input: TrackingInputDraft): ComponentSpec;
|
|
127
66
|
}
|
|
128
67
|
|
|
@@ -133,12 +72,6 @@ export class TimeoutError extends Error {
|
|
|
133
72
|
}
|
|
134
73
|
}
|
|
135
74
|
|
|
136
|
-
/**
|
|
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.
|
|
141
|
-
*/
|
|
142
75
|
async function withinBudget<T>(
|
|
143
76
|
label: string,
|
|
144
77
|
milliseconds: number,
|
|
@@ -182,7 +115,6 @@ function createSingleFlight() {
|
|
|
182
115
|
};
|
|
183
116
|
}
|
|
184
117
|
|
|
185
|
-
/** A cache entry is no more trustworthy than model output, so it is parsed too. */
|
|
186
118
|
const cachedSpecSchema = z.object({
|
|
187
119
|
spec: generatedSpecSchema,
|
|
188
120
|
generatedAt: z.number(),
|
|
@@ -193,24 +125,11 @@ interface CacheRead {
|
|
|
193
125
|
entry?: CachedSpec;
|
|
194
126
|
}
|
|
195
127
|
|
|
196
|
-
/** `spec` is null when the answer did not satisfy the schema. `usage` is carried either way: the call was still billed. */
|
|
197
128
|
interface ModelCall {
|
|
198
129
|
spec: GeneratedSpec | null;
|
|
199
130
|
usage?: TokenUsage;
|
|
200
131
|
}
|
|
201
132
|
|
|
202
|
-
interface ModelAnswer extends ModelCall {
|
|
203
|
-
spec: GeneratedSpec;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The arithmetic has to hold whatever order the model put the blocks in, so it
|
|
208
|
-
* is written as one sum over the whole spec rather than as a running budget:
|
|
209
|
-
* the grid gets `maxItems` minus every distinct product the set and the heroes
|
|
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.
|
|
213
|
-
*/
|
|
214
133
|
function fitCohortSpec(
|
|
215
134
|
spec: GeneratedSpec,
|
|
216
135
|
input: TrackingInput,
|
|
@@ -231,8 +150,6 @@ function fitCohortSpec(
|
|
|
231
150
|
}
|
|
232
151
|
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
233
152
|
|
|
234
|
-
// Only the heroes above the bundle block are placed when it is reached, so
|
|
235
|
-
// they are all the choice may account for.
|
|
236
153
|
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
|
|
237
154
|
if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
|
|
238
155
|
|
|
@@ -240,7 +157,7 @@ function fitCohortSpec(
|
|
|
240
157
|
for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
|
|
241
158
|
|
|
242
159
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
243
|
-
|
|
160
|
+
|
|
244
161
|
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
245
162
|
|
|
246
163
|
const forGrid: ProductPick[] = [];
|
|
@@ -251,13 +168,6 @@ function fitCohortSpec(
|
|
|
251
168
|
return fitToShopper(spec, forGrid, roomLeft);
|
|
252
169
|
}
|
|
253
170
|
|
|
254
|
-
function withProvenance(
|
|
255
|
-
spec: GeneratedSpec,
|
|
256
|
-
provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
|
|
257
|
-
): ComponentSpec {
|
|
258
|
-
return { ...spec, specVersion: SPEC_VERSION, ...provenance };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
171
|
export function createComponentGenerator(
|
|
262
172
|
options: ComponentGeneratorOptions = {},
|
|
263
173
|
): ComponentGenerator {
|
|
@@ -273,9 +183,7 @@ export function createComponentGenerator(
|
|
|
273
183
|
if (!options.onEvent) return;
|
|
274
184
|
try {
|
|
275
185
|
options.onEvent(event);
|
|
276
|
-
} catch {
|
|
277
|
-
// A broken metrics hook must not take down a page.
|
|
278
|
-
}
|
|
186
|
+
} catch {}
|
|
279
187
|
};
|
|
280
188
|
|
|
281
189
|
const buildDeterministic = (
|
|
@@ -297,7 +205,9 @@ export function createComponentGenerator(
|
|
|
297
205
|
degradedReason,
|
|
298
206
|
});
|
|
299
207
|
|
|
300
|
-
return
|
|
208
|
+
return {
|
|
209
|
+
...buildFallbackSpec(input, digest, { rank }),
|
|
210
|
+
specVersion: SPEC_VERSION,
|
|
301
211
|
slot: digest.slot,
|
|
302
212
|
source: 'fallback',
|
|
303
213
|
generatedAt: finishedAt,
|
|
@@ -305,7 +215,7 @@ export function createComponentGenerator(
|
|
|
305
215
|
provider: null,
|
|
306
216
|
model: null,
|
|
307
217
|
degradedReason,
|
|
308
|
-
}
|
|
218
|
+
};
|
|
309
219
|
};
|
|
310
220
|
|
|
311
221
|
const readCache = async (key: string): Promise<CacheRead> => {
|
|
@@ -314,26 +224,16 @@ export function createComponentGenerator(
|
|
|
314
224
|
const parsed = cachedSpecSchema.safeParse(stored);
|
|
315
225
|
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
316
226
|
} catch (error) {
|
|
317
|
-
// A store that is down or slow degrades to generating, not to an error page.
|
|
318
227
|
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
319
228
|
}
|
|
320
229
|
};
|
|
321
230
|
|
|
322
|
-
/** The `Promise.resolve` wrapper catches a store that throws synchronously rather than rejecting. */
|
|
323
231
|
const storeInBackground = (key: string, cached: CachedSpec): void => {
|
|
324
232
|
void Promise.resolve()
|
|
325
233
|
.then(() => cache.set(key, cached))
|
|
326
|
-
.catch(() => {
|
|
327
|
-
// A store that cannot be written is not a reason to fail a render.
|
|
328
|
-
});
|
|
234
|
+
.catch(() => {});
|
|
329
235
|
};
|
|
330
236
|
|
|
331
|
-
/**
|
|
332
|
-
* Deliberately does not decide whether the answer is usable. That depends on
|
|
333
|
-
* the asking shopper's live facts — stock, dislikes, what is in their basket
|
|
334
|
-
* — and none of those are in the cache key, so a verdict reached here would
|
|
335
|
-
* be handed to every request that joined this one.
|
|
336
|
-
*/
|
|
337
237
|
const askModel = async (
|
|
338
238
|
active: ComponentProvider,
|
|
339
239
|
input: TrackingInput,
|
|
@@ -345,7 +245,6 @@ export function createComponentGenerator(
|
|
|
345
245
|
active.generate({ system, user, schema: generatedSpecSchema, signal }),
|
|
346
246
|
);
|
|
347
247
|
|
|
348
|
-
// Providers return parsed objects, but the shape is still model output.
|
|
349
248
|
const parsed = generatedSpecSchema.safeParse(result.spec);
|
|
350
249
|
|
|
351
250
|
return {
|
|
@@ -363,8 +262,7 @@ export function createComponentGenerator(
|
|
|
363
262
|
|
|
364
263
|
async generate(draft) {
|
|
365
264
|
const startedAt = Date.now();
|
|
366
|
-
|
|
367
|
-
// degraded render.
|
|
265
|
+
|
|
368
266
|
const input = parseTrackingInput(draft);
|
|
369
267
|
const digest = buildDigest(input);
|
|
370
268
|
|
|
@@ -382,16 +280,14 @@ export function createComponentGenerator(
|
|
|
382
280
|
const read = await readCache(key);
|
|
383
281
|
const cached = read.entry;
|
|
384
282
|
let calledModel = false;
|
|
385
|
-
let answer:
|
|
386
|
-
|
|
283
|
+
let answer: { spec: GeneratedSpec; usage?: TokenUsage };
|
|
284
|
+
|
|
387
285
|
let generatedAt: number;
|
|
388
286
|
|
|
389
287
|
if (cached) {
|
|
390
288
|
answer = { spec: cached.spec };
|
|
391
289
|
generatedAt = cached.generatedAt;
|
|
392
290
|
} else {
|
|
393
|
-
// Asked before joining, because by the time the shared promise settles
|
|
394
|
-
// the entry is gone and there is no way to tell a leader from a follower.
|
|
395
291
|
calledModel = !singleFlight.isRunning(key);
|
|
396
292
|
|
|
397
293
|
let call: ModelCall;
|
|
@@ -419,16 +315,9 @@ export function createComponentGenerator(
|
|
|
419
315
|
answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
|
|
420
316
|
generatedAt = Date.now();
|
|
421
317
|
|
|
422
|
-
// Stored unreconciled on purpose, and stored even when it is unusable
|
|
423
|
-
// for this shopper. Reconciliation narrows a spec to one shopper's live
|
|
424
|
-
// facts, and those move independently of the key — a product can sell
|
|
425
|
-
// out and come back without the candidate list changing.
|
|
426
318
|
if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
427
319
|
}
|
|
428
320
|
|
|
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
|
|
431
|
-
// per-shopper mode this stays empty and every reason is screened.
|
|
432
321
|
const { spec: served, ourReasons } =
|
|
433
322
|
generation === 'cohort'
|
|
434
323
|
? fitCohortSpec(answer.spec, input, digest, rank)
|
|
@@ -456,14 +345,16 @@ export function createComponentGenerator(
|
|
|
456
345
|
...(answer.usage ? { usage: answer.usage } : {}),
|
|
457
346
|
});
|
|
458
347
|
|
|
459
|
-
return
|
|
348
|
+
return {
|
|
349
|
+
...reconciled.spec,
|
|
350
|
+
specVersion: SPEC_VERSION,
|
|
460
351
|
slot: digest.slot,
|
|
461
352
|
source,
|
|
462
353
|
generatedAt,
|
|
463
354
|
latencyMs: finishedAt - startedAt,
|
|
464
355
|
provider: provider.name,
|
|
465
356
|
model: provider.model,
|
|
466
|
-
}
|
|
357
|
+
};
|
|
467
358
|
},
|
|
468
359
|
};
|
|
469
360
|
}
|