@rudra-js/core 0.5.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 +64 -16
- package/dist/claim-screening.d.ts +10 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +157 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts +0 -70
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +27 -135
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +4 -159
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +0 -92
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +0 -6
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +0 -21
- 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 +5 -14
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -8
- 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 +1 -30
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +19 -78
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +0 -29
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -22
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +0 -55
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +0 -12
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +6 -61
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +76 -466
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +1 -18
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +27 -66
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +8 -70
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +5 -68
- 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 +5 -105
- package/dist/tracking-input.js.map +1 -1
- package/package.json +7 -6
- package/src/claim-screening.ts +190 -0
- package/src/component-generator.ts +45 -240
- package/src/component-spec.ts +17 -118
- package/src/fallback-component.ts +0 -22
- package/src/fit-to-shopper.ts +12 -16
- package/src/index.ts +1 -8
- package/src/model-prompt.ts +25 -84
- package/src/product-selection.ts +6 -52
- package/src/provider.ts +4 -60
- package/src/reconciliation.ts +104 -499
- package/src/signal-digest.ts +34 -83
- package/src/spec-cache.ts +10 -97
- package/src/tracking-input.ts +17 -111
|
@@ -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,
|
|
@@ -33,52 +28,14 @@ import {
|
|
|
33
28
|
type TrackingInputDraft,
|
|
34
29
|
} from './tracking-input.js';
|
|
35
30
|
|
|
36
|
-
/**
|
|
37
|
-
* Turns one tracking payload into one renderable component.
|
|
38
|
-
*
|
|
39
|
-
* Everything else in this package is a piece of that sentence; this module is
|
|
40
|
-
* the order they go in. It is deliberately the only place that knows the whole
|
|
41
|
-
* sequence, and it is written as a straight line so the sequence is readable:
|
|
42
|
-
*
|
|
43
|
-
* validate → digest → cache → generate → reconcile → render
|
|
44
|
-
*
|
|
45
|
-
* The single promise it makes to a caller is that `generate` always returns
|
|
46
|
-
* something renderable. A model that is slow, refusing, erroring, rate-limited
|
|
47
|
-
* or simply not configured produces the deterministic component instead. The
|
|
48
|
-
* only way it rejects is a malformed payload, which is a caller bug and should
|
|
49
|
-
* be loud.
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Reported exactly once per call to `generate`, whatever happened.
|
|
54
|
-
*
|
|
55
|
-
* One flat shape rather than a variant per outcome, because the numbers the
|
|
56
|
-
* evaluation needs are ratios over all calls — hit rate, fallback share, model
|
|
57
|
-
* calls and cost per thousand views. A variant that some callers do not emit
|
|
58
|
-
* makes every one of those ratios wrong by however many it skipped, which is
|
|
59
|
-
* what happened when requests that joined an in-flight generation reported
|
|
60
|
-
* nothing at all.
|
|
61
|
-
*/
|
|
62
31
|
export interface GenerationEvent {
|
|
63
|
-
/** Null when no key was computed, which means no provider was configured. */
|
|
64
32
|
key: string | null;
|
|
65
33
|
source: SpecSource;
|
|
66
|
-
|
|
34
|
+
|
|
67
35
|
elapsedMs: number;
|
|
68
|
-
|
|
69
|
-
* True for the caller that sent the request, on every outcome — including a
|
|
70
|
-
* call that timed out, errored or came back unparseable. Requests that joined
|
|
71
|
-
* an in-flight generation share its answer and its usage figures, so cost
|
|
72
|
-
* must be summed over this flag rather than over every event.
|
|
73
|
-
*
|
|
74
|
-
* It counts requests sent, which is an upper bound on requests billed: an
|
|
75
|
-
* adapter that throws before it reaches the vendor looks the same from here
|
|
76
|
-
* as one that throws after. An upper bound is the useful direction — the
|
|
77
|
-
* calls that produce nothing are the ones worth seeing, and reporting them as
|
|
78
|
-
* no call at all hides them completely.
|
|
79
|
-
*/
|
|
36
|
+
|
|
80
37
|
calledModel: boolean;
|
|
81
|
-
|
|
38
|
+
|
|
82
39
|
violations?: string[];
|
|
83
40
|
usage?: TokenUsage;
|
|
84
41
|
degradedReason?: DegradedReason;
|
|
@@ -87,47 +44,24 @@ export interface GenerationEvent {
|
|
|
87
44
|
}
|
|
88
45
|
|
|
89
46
|
export interface ComponentGeneratorOptions {
|
|
90
|
-
/**
|
|
91
|
-
* Omit to run without a model. That is a supported configuration rather than
|
|
92
|
-
* a stub: it is the control arm of the benchmark, and the right setting for
|
|
93
|
-
* anyone who has not yet decided on a provider.
|
|
94
|
-
*/
|
|
95
47
|
provider?: ComponentProvider | null;
|
|
96
|
-
|
|
48
|
+
|
|
97
49
|
cache?: SpecCache;
|
|
98
|
-
|
|
99
|
-
* How long the model gets. Past this the deterministic component renders and
|
|
100
|
-
* the request is aborted. Defaults to 1500ms.
|
|
101
|
-
*/
|
|
50
|
+
|
|
102
51
|
modelTimeoutMs?: number;
|
|
103
|
-
|
|
104
|
-
* How long the cache gets. The shipped caches cannot exceed it, but the store
|
|
105
|
-
* is a port a host implements — a hung Redis read on the render path would
|
|
106
|
-
* hold the page open, which is exactly what this module exists to prevent.
|
|
107
|
-
*/
|
|
52
|
+
|
|
108
53
|
cacheTimeoutMs?: number;
|
|
109
|
-
|
|
110
|
-
* 'cohort' shares one generated component between shoppers who look alike and
|
|
111
|
-
* fills in each shopper's own products. 'per-shopper' generates for the
|
|
112
|
-
* individual, which is what the benchmark compares against. Defaults to
|
|
113
|
-
* 'cohort'.
|
|
114
|
-
*/
|
|
54
|
+
|
|
115
55
|
generation?: 'cohort' | 'per-shopper';
|
|
116
|
-
|
|
117
|
-
* How the products are ordered. 'signals' scores each candidate from this
|
|
118
|
-
* shopper's signals. 'given' keeps the order you sent, for a shop whose own
|
|
119
|
-
* ranking is better than four weights. Either way the exclusions and the
|
|
120
|
-
* stock check still apply, and each product still carries a basis
|
|
121
|
-
* reconciliation can verify. Defaults to 'signals'.
|
|
122
|
-
*/
|
|
56
|
+
|
|
123
57
|
rank?: RankOrder;
|
|
124
|
-
|
|
58
|
+
|
|
125
59
|
onEvent?: (event: GenerationEvent) => void;
|
|
126
60
|
}
|
|
127
61
|
|
|
128
62
|
export interface ComponentGenerator {
|
|
129
63
|
generate(input: TrackingInputDraft): Promise<ComponentSpec>;
|
|
130
|
-
|
|
64
|
+
|
|
131
65
|
generateDeterministic(input: TrackingInputDraft): ComponentSpec;
|
|
132
66
|
}
|
|
133
67
|
|
|
@@ -138,20 +72,6 @@ export class TimeoutError extends Error {
|
|
|
138
72
|
}
|
|
139
73
|
}
|
|
140
74
|
|
|
141
|
-
/**
|
|
142
|
-
* Races a promise against a deadline.
|
|
143
|
-
*
|
|
144
|
-
* The deadline is enforced here rather than trusted to the thing being waited
|
|
145
|
-
* on. A provider that ignores its abort signal, or a store that never settles,
|
|
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.
|
|
154
|
-
*/
|
|
155
75
|
async function withinBudget<T>(
|
|
156
76
|
label: string,
|
|
157
77
|
milliseconds: number,
|
|
@@ -178,21 +98,13 @@ async function withinBudget<T>(
|
|
|
178
98
|
}
|
|
179
99
|
}
|
|
180
100
|
|
|
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>>();
|
|
101
|
+
function createSingleFlight() {
|
|
102
|
+
const inFlight = new Map<string, Promise<ModelCall>>();
|
|
191
103
|
|
|
192
104
|
return {
|
|
193
105
|
isRunning: (key: string) => inFlight.has(key),
|
|
194
106
|
|
|
195
|
-
run(key: string, task: () => Promise<
|
|
107
|
+
run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
|
|
196
108
|
const existing = inFlight.get(key);
|
|
197
109
|
if (existing) return existing;
|
|
198
110
|
|
|
@@ -203,7 +115,6 @@ function createSingleFlight<T>() {
|
|
|
203
115
|
};
|
|
204
116
|
}
|
|
205
117
|
|
|
206
|
-
/** A cache entry is no more trustworthy than model output, so it is parsed too. */
|
|
207
118
|
const cachedSpecSchema = z.object({
|
|
208
119
|
spec: generatedSpecSchema,
|
|
209
120
|
generatedAt: z.number(),
|
|
@@ -214,48 +125,19 @@ interface CacheRead {
|
|
|
214
125
|
entry?: CachedSpec;
|
|
215
126
|
}
|
|
216
127
|
|
|
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
|
-
*/
|
|
224
128
|
interface ModelCall {
|
|
225
129
|
spec: GeneratedSpec | null;
|
|
226
130
|
usage?: TokenUsage;
|
|
227
131
|
}
|
|
228
132
|
|
|
229
|
-
/** A model call whose answer can be reconciled and served. */
|
|
230
|
-
interface ModelAnswer extends ModelCall {
|
|
231
|
-
spec: GeneratedSpec;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
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
|
-
* The arithmetic has to hold whatever order the model put the blocks in, so it
|
|
243
|
-
* is written as one sum over the whole spec rather than as a running budget:
|
|
244
|
-
* the grid gets `maxItems` minus every distinct product the set and the heroes
|
|
245
|
-
* will place. Nothing is then dropped for want of budget, and reconciliation
|
|
246
|
-
* reaches the same set this did — it can only ever have more placed than the
|
|
247
|
-
* pre-choice assumed, and never one of the set's own products.
|
|
248
|
-
*/
|
|
249
133
|
function fitCohortSpec(
|
|
250
134
|
spec: GeneratedSpec,
|
|
251
135
|
input: TrackingInput,
|
|
252
136
|
digest: SignalDigest,
|
|
253
137
|
rank: RankOrder,
|
|
254
|
-
|
|
255
|
-
): GeneratedSpec {
|
|
138
|
+
): FittedSpec {
|
|
256
139
|
const picks = selectProducts(input, digest, { rank });
|
|
257
|
-
|
|
258
|
-
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
140
|
+
const blocks = capBlocks(spec.blocks);
|
|
259
141
|
|
|
260
142
|
let hasBundleBlock = false;
|
|
261
143
|
const aboveBundle: Block[] = [];
|
|
@@ -266,34 +148,24 @@ function fitCohortSpec(
|
|
|
266
148
|
}
|
|
267
149
|
aboveBundle.push(block);
|
|
268
150
|
}
|
|
269
|
-
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems
|
|
151
|
+
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
270
152
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
|
|
274
|
-
if (!chosen) return fitToShopper(spec, picks, digest.maxItems, ourReasons);
|
|
153
|
+
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input));
|
|
154
|
+
if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
|
|
275
155
|
|
|
276
156
|
const spokenFor = new Set<string>(chosen.skus);
|
|
277
|
-
for (const sku of placeableHeroSkus(blocks, input
|
|
157
|
+
for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
|
|
278
158
|
|
|
279
159
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
280
|
-
|
|
281
|
-
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems
|
|
160
|
+
|
|
161
|
+
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
282
162
|
|
|
283
163
|
const forGrid: ProductPick[] = [];
|
|
284
164
|
for (const pick of picks) {
|
|
285
165
|
if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
|
|
286
166
|
}
|
|
287
167
|
|
|
288
|
-
return fitToShopper(spec, forGrid, roomLeft
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
292
|
-
function withProvenance(
|
|
293
|
-
spec: GeneratedSpec,
|
|
294
|
-
provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
|
|
295
|
-
): ComponentSpec {
|
|
296
|
-
return { ...spec, specVersion: SPEC_VERSION, ...provenance };
|
|
168
|
+
return fitToShopper(spec, forGrid, roomLeft);
|
|
297
169
|
}
|
|
298
170
|
|
|
299
171
|
export function createComponentGenerator(
|
|
@@ -305,15 +177,13 @@ export function createComponentGenerator(
|
|
|
305
177
|
const rank = options.rank ?? 'signals';
|
|
306
178
|
const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
|
|
307
179
|
const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
|
|
308
|
-
const singleFlight = createSingleFlight
|
|
180
|
+
const singleFlight = createSingleFlight();
|
|
309
181
|
|
|
310
182
|
const report = (event: GenerationEvent): void => {
|
|
311
183
|
if (!options.onEvent) return;
|
|
312
184
|
try {
|
|
313
185
|
options.onEvent(event);
|
|
314
|
-
} catch {
|
|
315
|
-
// A broken metrics hook must not take down a page.
|
|
316
|
-
}
|
|
186
|
+
} catch {}
|
|
317
187
|
};
|
|
318
188
|
|
|
319
189
|
const buildDeterministic = (
|
|
@@ -322,12 +192,6 @@ export function createComponentGenerator(
|
|
|
322
192
|
startedAt: number,
|
|
323
193
|
key: string | null,
|
|
324
194
|
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
195
|
modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
|
|
332
196
|
calledModel: false,
|
|
333
197
|
},
|
|
@@ -341,7 +205,9 @@ export function createComponentGenerator(
|
|
|
341
205
|
degradedReason,
|
|
342
206
|
});
|
|
343
207
|
|
|
344
|
-
return
|
|
208
|
+
return {
|
|
209
|
+
...buildFallbackSpec(input, digest, { rank }),
|
|
210
|
+
specVersion: SPEC_VERSION,
|
|
345
211
|
slot: digest.slot,
|
|
346
212
|
source: 'fallback',
|
|
347
213
|
generatedAt: finishedAt,
|
|
@@ -349,58 +215,25 @@ export function createComponentGenerator(
|
|
|
349
215
|
provider: null,
|
|
350
216
|
model: null,
|
|
351
217
|
degradedReason,
|
|
352
|
-
}
|
|
218
|
+
};
|
|
353
219
|
};
|
|
354
220
|
|
|
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
221
|
const readCache = async (key: string): Promise<CacheRead> => {
|
|
365
222
|
try {
|
|
366
223
|
const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
|
|
367
224
|
const parsed = cachedSpecSchema.safeParse(stored);
|
|
368
225
|
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
369
226
|
} 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.
|
|
372
227
|
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
373
228
|
}
|
|
374
229
|
};
|
|
375
230
|
|
|
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
|
-
*/
|
|
385
231
|
const storeInBackground = (key: string, cached: CachedSpec): void => {
|
|
386
232
|
void Promise.resolve()
|
|
387
233
|
.then(() => cache.set(key, cached))
|
|
388
|
-
.catch(() => {
|
|
389
|
-
// A store that cannot be written is not a reason to fail a render.
|
|
390
|
-
});
|
|
234
|
+
.catch(() => {});
|
|
391
235
|
};
|
|
392
236
|
|
|
393
|
-
/**
|
|
394
|
-
* Asks the model.
|
|
395
|
-
*
|
|
396
|
-
* Deliberately does not decide whether the answer is usable. That depends on
|
|
397
|
-
* the asking shopper's live facts — stock, dislikes, what is in their basket
|
|
398
|
-
* — 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. A null `spec` in the
|
|
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.
|
|
403
|
-
*/
|
|
404
237
|
const askModel = async (
|
|
405
238
|
active: ComponentProvider,
|
|
406
239
|
input: TrackingInput,
|
|
@@ -412,7 +245,6 @@ export function createComponentGenerator(
|
|
|
412
245
|
active.generate({ system, user, schema: generatedSpecSchema, signal }),
|
|
413
246
|
);
|
|
414
247
|
|
|
415
|
-
// Providers return parsed objects, but the shape is still model output.
|
|
416
248
|
const parsed = generatedSpecSchema.safeParse(result.spec);
|
|
417
249
|
|
|
418
250
|
return {
|
|
@@ -430,8 +262,7 @@ export function createComponentGenerator(
|
|
|
430
262
|
|
|
431
263
|
async generate(draft) {
|
|
432
264
|
const startedAt = Date.now();
|
|
433
|
-
|
|
434
|
-
// degraded render.
|
|
265
|
+
|
|
435
266
|
const input = parseTrackingInput(draft);
|
|
436
267
|
const digest = buildDigest(input);
|
|
437
268
|
|
|
@@ -439,36 +270,24 @@ export function createComponentGenerator(
|
|
|
439
270
|
return buildDeterministic(input, digest, startedAt, null, 'no-provider');
|
|
440
271
|
}
|
|
441
272
|
|
|
442
|
-
const
|
|
273
|
+
const identity = { name: provider.name, model: provider.model };
|
|
274
|
+
const candidateSkus = input.candidates.map((product) => product.sku);
|
|
443
275
|
const key =
|
|
444
276
|
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
|
-
);
|
|
277
|
+
? cohortCacheKey(digest, candidateSkus, identity)
|
|
278
|
+
: specCacheKey(digest, candidateSkus, identity);
|
|
455
279
|
|
|
456
280
|
const read = await readCache(key);
|
|
457
281
|
const cached = read.entry;
|
|
458
282
|
let calledModel = false;
|
|
459
|
-
let answer:
|
|
460
|
-
|
|
461
|
-
// component is not newly generated, and pretending otherwise makes any
|
|
462
|
-
// measure of how stale a page is showing read as zero.
|
|
283
|
+
let answer: { spec: GeneratedSpec; usage?: TokenUsage };
|
|
284
|
+
|
|
463
285
|
let generatedAt: number;
|
|
464
286
|
|
|
465
287
|
if (cached) {
|
|
466
288
|
answer = { spec: cached.spec };
|
|
467
289
|
generatedAt = cached.generatedAt;
|
|
468
290
|
} else {
|
|
469
|
-
// 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.
|
|
472
291
|
calledModel = !singleFlight.isRunning(key);
|
|
473
292
|
|
|
474
293
|
let call: ModelCall;
|
|
@@ -478,9 +297,6 @@ export function createComponentGenerator(
|
|
|
478
297
|
);
|
|
479
298
|
} catch (error) {
|
|
480
299
|
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
300
|
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
485
301
|
calledModel,
|
|
486
302
|
cache: read.outcome,
|
|
@@ -499,26 +315,13 @@ export function createComponentGenerator(
|
|
|
499
315
|
answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
|
|
500
316
|
generatedAt = Date.now();
|
|
501
317
|
|
|
502
|
-
// Stored unreconciled on purpose, and stored even when it is unusable
|
|
503
|
-
// for this shopper. Reconciliation narrows a spec to one shopper's live
|
|
504
|
-
// facts, and those move independently of the key — a product can sell
|
|
505
|
-
// out and come back without the candidate list changing. Keeping what
|
|
506
|
-
// the model said means the restock is picked up from cache rather than
|
|
507
|
-
// paid for again.
|
|
508
318
|
if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
509
319
|
}
|
|
510
320
|
|
|
511
|
-
|
|
512
|
-
// from, and always against the facts of the shopper asking now.
|
|
513
|
-
// A cohort spec names products chosen for whoever asked first, and every
|
|
514
|
-
// reason under one of them is written here rather than by the model. Only
|
|
515
|
-
// the cohort path does that, so in per-shopper mode this stays empty and
|
|
516
|
-
// every reason is screened.
|
|
517
|
-
const ourReasons = new Map<string, string>();
|
|
518
|
-
const served =
|
|
321
|
+
const { spec: served, ourReasons } =
|
|
519
322
|
generation === 'cohort'
|
|
520
|
-
? fitCohortSpec(answer.spec, input, digest, rank
|
|
521
|
-
: answer.spec;
|
|
323
|
+
? fitCohortSpec(answer.spec, input, digest, rank)
|
|
324
|
+
: { spec: answer.spec, ourReasons: new Map<string, string>() };
|
|
522
325
|
|
|
523
326
|
const reconciled = reconcileSpec(served, input, digest, ourReasons);
|
|
524
327
|
if (!reconciled.isUsable) {
|
|
@@ -542,14 +345,16 @@ export function createComponentGenerator(
|
|
|
542
345
|
...(answer.usage ? { usage: answer.usage } : {}),
|
|
543
346
|
});
|
|
544
347
|
|
|
545
|
-
return
|
|
348
|
+
return {
|
|
349
|
+
...reconciled.spec,
|
|
350
|
+
specVersion: SPEC_VERSION,
|
|
546
351
|
slot: digest.slot,
|
|
547
352
|
source,
|
|
548
353
|
generatedAt,
|
|
549
354
|
latencyMs: finishedAt - startedAt,
|
|
550
355
|
provider: provider.name,
|
|
551
356
|
model: provider.model,
|
|
552
|
-
}
|
|
357
|
+
};
|
|
553
358
|
},
|
|
554
359
|
};
|
|
555
360
|
}
|