@rudra-js/core 0.1.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/component-generator.d.ts +84 -0
- package/dist/component-generator.d.ts.map +1 -0
- package/dist/component-generator.js +331 -0
- package/dist/component-generator.js.map +1 -0
- package/dist/component-spec.d.ts +426 -0
- package/dist/component-spec.d.ts.map +1 -0
- package/dist/component-spec.js +170 -0
- package/dist/component-spec.js.map +1 -0
- package/dist/fallback-component.d.ts +11 -0
- package/dist/fallback-component.d.ts.map +1 -0
- package/dist/fallback-component.js +69 -0
- package/dist/fallback-component.js.map +1 -0
- package/dist/fit-to-shopper.d.ts +4 -0
- package/dist/fit-to-shopper.d.ts.map +1 -0
- package/dist/fit-to-shopper.js +36 -0
- package/dist/fit-to-shopper.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/model-prompt.d.ts +29 -0
- package/dist/model-prompt.d.ts.map +1 -0
- package/dist/model-prompt.js +227 -0
- package/dist/model-prompt.js.map +1 -0
- package/dist/product-selection.d.ts +33 -0
- package/dist/product-selection.d.ts.map +1 -0
- package/dist/product-selection.js +102 -0
- package/dist/product-selection.js.map +1 -0
- package/dist/provider.d.ts +80 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +23 -0
- package/dist/provider.js.map +1 -0
- package/dist/reconciliation.d.ts +49 -0
- package/dist/reconciliation.d.ts.map +1 -0
- package/dist/reconciliation.js +564 -0
- package/dist/reconciliation.js.map +1 -0
- package/dist/signal-digest.d.ts +66 -0
- package/dist/signal-digest.d.ts.map +1 -0
- package/dist/signal-digest.js +224 -0
- package/dist/signal-digest.js.map +1 -0
- package/dist/spec-cache.d.ts +88 -0
- package/dist/spec-cache.d.ts.map +1 -0
- package/dist/spec-cache.js +152 -0
- package/dist/spec-cache.js.map +1 -0
- package/dist/tracking-input.d.ts +258 -0
- package/dist/tracking-input.d.ts.map +1 -0
- package/dist/tracking-input.js +241 -0
- package/dist/tracking-input.js.map +1 -0
- package/package.json +60 -0
- package/src/component-generator.ts +521 -0
- package/src/component-spec.ts +243 -0
- package/src/fallback-component.ts +77 -0
- package/src/fit-to-shopper.ts +45 -0
- package/src/index.ts +102 -0
- package/src/model-prompt.ts +258 -0
- package/src/product-selection.ts +153 -0
- package/src/provider.ts +98 -0
- package/src/reconciliation.ts +675 -0
- package/src/signal-digest.ts +335 -0
- package/src/spec-cache.ts +223 -0
- package/src/tracking-input.ts +300 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
SPEC_VERSION,
|
|
4
|
+
generatedSpecSchema,
|
|
5
|
+
type Block,
|
|
6
|
+
type ComponentSpec,
|
|
7
|
+
type DegradedReason,
|
|
8
|
+
type GeneratedSpec,
|
|
9
|
+
type SpecSource,
|
|
10
|
+
} from './component-spec.js';
|
|
11
|
+
import { buildFallbackSpec } from './fallback-component.js';
|
|
12
|
+
import { buildPrompt } from './model-prompt.js';
|
|
13
|
+
import type { ComponentProvider, TokenUsage } from './provider.js';
|
|
14
|
+
import {
|
|
15
|
+
MAX_BLOCKS,
|
|
16
|
+
bundleForShopper,
|
|
17
|
+
placeableHeroSkus,
|
|
18
|
+
reconcileSpec,
|
|
19
|
+
} from './reconciliation.js';
|
|
20
|
+
import { selectProducts, type ProductPick } from './product-selection.js';
|
|
21
|
+
import { fitToShopper } from './fit-to-shopper.js';
|
|
22
|
+
import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
|
|
23
|
+
import {
|
|
24
|
+
createMemorySpecCache,
|
|
25
|
+
cohortCacheKey,
|
|
26
|
+
specCacheKey,
|
|
27
|
+
type CachedSpec,
|
|
28
|
+
type SpecCache,
|
|
29
|
+
} from './spec-cache.js';
|
|
30
|
+
import {
|
|
31
|
+
parseTrackingInput,
|
|
32
|
+
type TrackingInput,
|
|
33
|
+
type TrackingInputDraft,
|
|
34
|
+
} from './tracking-input.js';
|
|
35
|
+
|
|
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
|
+
export interface GenerationEvent {
|
|
63
|
+
/** Null when no key was computed, which means no provider was configured. */
|
|
64
|
+
key: string | null;
|
|
65
|
+
source: SpecSource;
|
|
66
|
+
/** Wall-clock milliseconds for the whole call. */
|
|
67
|
+
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
|
+
*/
|
|
80
|
+
calledModel: boolean;
|
|
81
|
+
/** What reconciliation removed. Absent when no spec was reconciled. */
|
|
82
|
+
violations?: string[];
|
|
83
|
+
usage?: TokenUsage;
|
|
84
|
+
degradedReason?: DegradedReason;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ComponentGeneratorOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Omit to run without a model. That is a supported configuration rather than
|
|
90
|
+
* a stub: it is the control arm of the benchmark, and the right setting for
|
|
91
|
+
* anyone who has not yet decided on a provider.
|
|
92
|
+
*/
|
|
93
|
+
provider?: ComponentProvider | null;
|
|
94
|
+
/** Defaults to an in-process cache. Pass `createNullSpecCache()` to disable. */
|
|
95
|
+
cache?: SpecCache;
|
|
96
|
+
/**
|
|
97
|
+
* How long the model gets. Past this the deterministic component renders and
|
|
98
|
+
* the request is aborted. Defaults to 1500ms.
|
|
99
|
+
*/
|
|
100
|
+
modelTimeoutMs?: number;
|
|
101
|
+
/**
|
|
102
|
+
* How long the cache gets. The shipped caches cannot exceed it, but the store
|
|
103
|
+
* is a port a host implements — a hung Redis read on the render path would
|
|
104
|
+
* hold the page open, which is exactly what this module exists to prevent.
|
|
105
|
+
*/
|
|
106
|
+
cacheTimeoutMs?: number;
|
|
107
|
+
// 'cohort' shares one generated component between shoppers who look alike and
|
|
108
|
+
// fills in each shopper's own products. 'per-shopper' generates for the
|
|
109
|
+
// individual, which is what the benchmark compares against.
|
|
110
|
+
generation?: 'cohort' | 'per-shopper';
|
|
111
|
+
/** Observability. Never allowed to break a render. */
|
|
112
|
+
onEvent?: (event: GenerationEvent) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ComponentGenerator {
|
|
116
|
+
generate(input: TrackingInputDraft): Promise<ComponentSpec>;
|
|
117
|
+
/** The deterministic component, without consulting a model or a cache. */
|
|
118
|
+
generateDeterministic(input: TrackingInputDraft): ComponentSpec;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class TimeoutError extends Error {
|
|
122
|
+
constructor(label: string, milliseconds: number) {
|
|
123
|
+
super(`${label} exceeded ${milliseconds}ms`);
|
|
124
|
+
this.name = 'TimeoutError';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Races a promise against a deadline.
|
|
130
|
+
*
|
|
131
|
+
* The deadline is enforced here rather than trusted to the thing being waited
|
|
132
|
+
* on. A provider that ignores its abort signal, or a store that never settles,
|
|
133
|
+
* must still not hold a page open.
|
|
134
|
+
*
|
|
135
|
+
* Once the deadline has fired the caller is told so, whatever the race
|
|
136
|
+
* actually settled with. Aborting is what makes that necessary: a provider
|
|
137
|
+
* honouring its half of the contract rejects from inside the `abort()` below,
|
|
138
|
+
* so its rejection reaches the race first and the deadline's own never wins.
|
|
139
|
+
* Reporting the error that happened to arrive would blame the vendor for the
|
|
140
|
+
* caller's deadline — and blame it most often on the best-behaved adapters.
|
|
141
|
+
*/
|
|
142
|
+
async function withinBudget<T>(
|
|
143
|
+
label: string,
|
|
144
|
+
milliseconds: number,
|
|
145
|
+
start: (signal: AbortSignal) => Promise<T>,
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
149
|
+
let expired: TimeoutError | undefined;
|
|
150
|
+
|
|
151
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
152
|
+
timer = setTimeout(() => {
|
|
153
|
+
expired = new TimeoutError(label, milliseconds);
|
|
154
|
+
controller.abort();
|
|
155
|
+
reject(expired);
|
|
156
|
+
}, milliseconds);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
return await Promise.race([start(controller.signal), deadline]);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw expired ?? error;
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Collapses concurrent work for the same key into one execution.
|
|
170
|
+
*
|
|
171
|
+
* Without this, a key that is not yet cached fans out into one model call per
|
|
172
|
+
* concurrent request — the same answer, bought many times over. The entry is
|
|
173
|
+
* removed as soon as it settles, so one failure does not poison the next
|
|
174
|
+
* attempt.
|
|
175
|
+
*/
|
|
176
|
+
function createSingleFlight<T>() {
|
|
177
|
+
const inFlight = new Map<string, Promise<T>>();
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
isRunning: (key: string) => inFlight.has(key),
|
|
181
|
+
|
|
182
|
+
run(key: string, task: () => Promise<T>): Promise<T> {
|
|
183
|
+
const existing = inFlight.get(key);
|
|
184
|
+
if (existing) return existing;
|
|
185
|
+
|
|
186
|
+
const started = task().finally(() => inFlight.delete(key));
|
|
187
|
+
inFlight.set(key, started);
|
|
188
|
+
return started;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A cache entry is no more trustworthy than model output, so it is parsed too. */
|
|
194
|
+
const cachedSpecSchema = z.object({
|
|
195
|
+
spec: generatedSpecSchema,
|
|
196
|
+
generatedAt: z.number(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** What the model said, plus what it cost. */
|
|
200
|
+
/**
|
|
201
|
+
* What one call to the model produced.
|
|
202
|
+
*
|
|
203
|
+
* `spec` is null when the answer did not satisfy the schema. `usage` is carried
|
|
204
|
+
* either way: the request went out and was paid for whether or not anything
|
|
205
|
+
* usable came back, and those are the calls most worth seeing.
|
|
206
|
+
*/
|
|
207
|
+
interface ModelCall {
|
|
208
|
+
spec: GeneratedSpec | null;
|
|
209
|
+
usage?: TokenUsage;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A model call whose answer can be reconciled and served. */
|
|
213
|
+
interface ModelAnswer extends ModelCall {
|
|
214
|
+
spec: GeneratedSpec;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Fills a cohort spec with this shopper's products, keeping room for a set.
|
|
219
|
+
*
|
|
220
|
+
* The grid used to take the whole item budget, so a bundle block later in the
|
|
221
|
+
* spec found nothing left and was dropped. The set is chosen first, its
|
|
222
|
+
* products are held back from the grid, and the grid's limit drops by what the
|
|
223
|
+
* set and the heroes have already spoken for.
|
|
224
|
+
*
|
|
225
|
+
* The arithmetic has to hold whatever order the model put the blocks in, so it
|
|
226
|
+
* is written as one sum over the whole spec rather than as a running budget:
|
|
227
|
+
* the grid gets `maxItems` minus every distinct product the set and the heroes
|
|
228
|
+
* will place. Nothing is then dropped for want of budget, and reconciliation
|
|
229
|
+
* reaches the same set this did — it can only ever have more placed than the
|
|
230
|
+
* pre-choice assumed, and never one of the set's own products.
|
|
231
|
+
*/
|
|
232
|
+
function fitCohortSpec(
|
|
233
|
+
spec: GeneratedSpec,
|
|
234
|
+
input: TrackingInput,
|
|
235
|
+
digest: SignalDigest,
|
|
236
|
+
): GeneratedSpec {
|
|
237
|
+
const picks = selectProducts(input, digest);
|
|
238
|
+
// Blocks past the cap never render, so a set is not worth reserving for one.
|
|
239
|
+
const blocks = spec.blocks.slice(0, MAX_BLOCKS);
|
|
240
|
+
|
|
241
|
+
let hasBundleBlock = false;
|
|
242
|
+
const aboveBundle: Block[] = [];
|
|
243
|
+
for (const block of blocks) {
|
|
244
|
+
if (block.kind === 'bundle') {
|
|
245
|
+
hasBundleBlock = true;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
aboveBundle.push(block);
|
|
249
|
+
}
|
|
250
|
+
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
251
|
+
|
|
252
|
+
// Only the heroes above the bundle block are placed when it is reached, so
|
|
253
|
+
// they are all the choice may account for.
|
|
254
|
+
const chosen = bundleForShopper(input, digest, placeableHeroSkus(aboveBundle, input, digest));
|
|
255
|
+
if (!chosen) return fitToShopper(spec, picks, digest.maxItems);
|
|
256
|
+
|
|
257
|
+
const spokenFor = new Set<string>(chosen.skus);
|
|
258
|
+
for (const sku of placeableHeroSkus(blocks, input, digest)) spokenFor.add(sku);
|
|
259
|
+
|
|
260
|
+
const roomLeft = digest.maxItems - spokenFor.size;
|
|
261
|
+
// A set is worth showing, but not at the cost of an empty grid.
|
|
262
|
+
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
263
|
+
|
|
264
|
+
const forGrid: ProductPick[] = [];
|
|
265
|
+
for (const pick of picks) {
|
|
266
|
+
if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return fitToShopper(spec, forGrid, roomLeft);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Attaches the provenance the server owns. The model never supplies any of it. */
|
|
273
|
+
function withProvenance(
|
|
274
|
+
spec: GeneratedSpec,
|
|
275
|
+
provenance: Omit<ComponentSpec, keyof GeneratedSpec | 'specVersion'>,
|
|
276
|
+
): ComponentSpec {
|
|
277
|
+
return { ...spec, specVersion: SPEC_VERSION, ...provenance };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function createComponentGenerator(
|
|
281
|
+
options: ComponentGeneratorOptions = {},
|
|
282
|
+
): ComponentGenerator {
|
|
283
|
+
const provider = options.provider ?? null;
|
|
284
|
+
const cache = options.cache ?? createMemorySpecCache();
|
|
285
|
+
const generation = options.generation ?? 'cohort';
|
|
286
|
+
const modelTimeoutMs = options.modelTimeoutMs ?? 1_500;
|
|
287
|
+
const cacheTimeoutMs = options.cacheTimeoutMs ?? 50;
|
|
288
|
+
const singleFlight = createSingleFlight<ModelCall>();
|
|
289
|
+
|
|
290
|
+
const report = (event: GenerationEvent): void => {
|
|
291
|
+
if (!options.onEvent) return;
|
|
292
|
+
try {
|
|
293
|
+
options.onEvent(event);
|
|
294
|
+
} catch {
|
|
295
|
+
// A broken metrics hook must not take down a page.
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const buildDeterministic = (
|
|
300
|
+
input: TrackingInput,
|
|
301
|
+
digest: SignalDigest,
|
|
302
|
+
startedAt: number,
|
|
303
|
+
key: string | null,
|
|
304
|
+
degradedReason: DegradedReason,
|
|
305
|
+
/**
|
|
306
|
+
* What the model call cost, when there was one. A generation that is
|
|
307
|
+
* unusable for this shopper was still asked for and still billed, so
|
|
308
|
+
* omitting it here would hide the calls that produce nothing — exactly the
|
|
309
|
+
* ones worth knowing about.
|
|
310
|
+
*/
|
|
311
|
+
modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations'> = {
|
|
312
|
+
calledModel: false,
|
|
313
|
+
},
|
|
314
|
+
): ComponentSpec => {
|
|
315
|
+
const finishedAt = Date.now();
|
|
316
|
+
report({
|
|
317
|
+
key,
|
|
318
|
+
source: 'fallback',
|
|
319
|
+
elapsedMs: finishedAt - startedAt,
|
|
320
|
+
...modelCall,
|
|
321
|
+
degradedReason,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return withProvenance(buildFallbackSpec(input, digest), {
|
|
325
|
+
slot: digest.slot,
|
|
326
|
+
source: 'fallback',
|
|
327
|
+
generatedAt: finishedAt,
|
|
328
|
+
latencyMs: finishedAt - startedAt,
|
|
329
|
+
provider: null,
|
|
330
|
+
model: null,
|
|
331
|
+
degradedReason,
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Reads the cache, treating anything unexpected as a miss.
|
|
337
|
+
*
|
|
338
|
+
* The value is re-validated because a store is a port a host implements, and
|
|
339
|
+
* what comes back is no more trustworthy than what a model returns — a shared
|
|
340
|
+
* store outlives a deploy, so it can hold entries written by an older shape of
|
|
341
|
+
* the spec. Generating again is always safe; handing an unvalidated object to
|
|
342
|
+
* reconciliation is not.
|
|
343
|
+
*/
|
|
344
|
+
const readCache = async (key: string): Promise<CachedSpec | undefined> => {
|
|
345
|
+
try {
|
|
346
|
+
const stored = await withinBudget('cache read', cacheTimeoutMs, () => cache.get(key));
|
|
347
|
+
const parsed = cachedSpecSchema.safeParse(stored);
|
|
348
|
+
return parsed.success ? parsed.data : undefined;
|
|
349
|
+
} catch {
|
|
350
|
+
// A store that is down or slow degrades to generating, not to an error
|
|
351
|
+
// page. Nothing here is worth failing a render over.
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Writes to the cache without the render waiting for it.
|
|
358
|
+
*
|
|
359
|
+
* The spec is already in hand; nothing downstream needs the write to finish.
|
|
360
|
+
* Awaiting it put a second unbounded call to a host-implemented store on the
|
|
361
|
+
* render path, which is the failure this module exists to prevent arriving
|
|
362
|
+
* through the other door. The `Promise.resolve` wrapper is what catches a
|
|
363
|
+
* store that throws synchronously rather than rejecting.
|
|
364
|
+
*/
|
|
365
|
+
const storeInBackground = (key: string, cached: CachedSpec): void => {
|
|
366
|
+
void Promise.resolve()
|
|
367
|
+
.then(() => cache.set(key, cached))
|
|
368
|
+
.catch(() => {
|
|
369
|
+
// A store that cannot be written is not a reason to fail a render.
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Asks the model.
|
|
375
|
+
*
|
|
376
|
+
* Deliberately does not decide whether the answer is usable. That depends on
|
|
377
|
+
* the asking shopper's live facts — stock, dislikes, what is in their basket
|
|
378
|
+
* — and none of those are in the cache key, so a verdict reached here would
|
|
379
|
+
* be handed to every request that joined this one. A null `spec` in the
|
|
380
|
+
* result means the answer did not satisfy the schema, which is a fault of the
|
|
381
|
+
* adapter rather than a judgement about any shopper — and is still a call
|
|
382
|
+
* that happened, so its usage comes back with it.
|
|
383
|
+
*/
|
|
384
|
+
const askModel = async (
|
|
385
|
+
active: ComponentProvider,
|
|
386
|
+
input: TrackingInput,
|
|
387
|
+
promptDigest: SignalDigest,
|
|
388
|
+
): Promise<ModelCall> => {
|
|
389
|
+
const { system, user } = buildPrompt(input, promptDigest);
|
|
390
|
+
|
|
391
|
+
const result = await withinBudget('generation', modelTimeoutMs, (signal) =>
|
|
392
|
+
active.generate({ system, user, schema: generatedSpecSchema, signal }),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Providers return parsed objects, but the shape is still model output.
|
|
396
|
+
const parsed = generatedSpecSchema.safeParse(result.spec);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
spec: parsed.success ? parsed.data : null,
|
|
400
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
401
|
+
};
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
generateDeterministic(draft) {
|
|
406
|
+
const startedAt = Date.now();
|
|
407
|
+
const input = parseTrackingInput(draft);
|
|
408
|
+
return buildDeterministic(input, buildDigest(input), startedAt, null, 'requested');
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async generate(draft) {
|
|
412
|
+
const startedAt = Date.now();
|
|
413
|
+
// Deliberately unguarded: an invalid payload is a caller bug, not a
|
|
414
|
+
// degraded render.
|
|
415
|
+
const input = parseTrackingInput(draft);
|
|
416
|
+
const digest = buildDigest(input);
|
|
417
|
+
|
|
418
|
+
if (!provider) {
|
|
419
|
+
return buildDeterministic(input, digest, startedAt, null, 'no-provider');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const providerId = `${provider.name}:${provider.model}`;
|
|
423
|
+
const key =
|
|
424
|
+
generation === 'cohort'
|
|
425
|
+
? cohortCacheKey(
|
|
426
|
+
digest,
|
|
427
|
+
input.candidates.map((product) => product.sku),
|
|
428
|
+
providerId,
|
|
429
|
+
)
|
|
430
|
+
: specCacheKey(
|
|
431
|
+
digest,
|
|
432
|
+
input.candidates.map((product) => product.sku),
|
|
433
|
+
providerId,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const cached = await readCache(key);
|
|
437
|
+
let calledModel = false;
|
|
438
|
+
let answer: ModelAnswer;
|
|
439
|
+
// When the model produced this, not when it was served. A cached
|
|
440
|
+
// component is not newly generated, and pretending otherwise makes any
|
|
441
|
+
// measure of how stale a page is showing read as zero.
|
|
442
|
+
let generatedAt: number;
|
|
443
|
+
|
|
444
|
+
if (cached) {
|
|
445
|
+
answer = { spec: cached.spec };
|
|
446
|
+
generatedAt = cached.generatedAt;
|
|
447
|
+
} else {
|
|
448
|
+
// Asked before joining, because by the time the shared promise settles
|
|
449
|
+
// the entry is gone and there is no way to tell a leader from a
|
|
450
|
+
// follower — and they must not both be counted as a model call.
|
|
451
|
+
calledModel = !singleFlight.isRunning(key);
|
|
452
|
+
|
|
453
|
+
let call: ModelCall;
|
|
454
|
+
try {
|
|
455
|
+
call = await singleFlight.run(key, () =>
|
|
456
|
+
askModel(provider, input, generation === 'cohort' ? toCohortDigest(digest) : digest),
|
|
457
|
+
);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
|
|
460
|
+
// The request went out. Leaving `calledModel` to default here reported
|
|
461
|
+
// every failed call as no call at all, so the calls that cost money
|
|
462
|
+
// and produced nothing were the only ones missing from the count.
|
|
463
|
+
return buildDeterministic(input, digest, startedAt, key, reason, { calledModel });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!call.spec) {
|
|
467
|
+
return buildDeterministic(input, digest, startedAt, key, 'invalid-generation', {
|
|
468
|
+
calledModel,
|
|
469
|
+
...(call.usage ? { usage: call.usage } : {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
|
|
474
|
+
generatedAt = Date.now();
|
|
475
|
+
|
|
476
|
+
// Stored unreconciled on purpose, and stored even when it is unusable
|
|
477
|
+
// for this shopper. Reconciliation narrows a spec to one shopper's live
|
|
478
|
+
// facts, and those move independently of the key — a product can sell
|
|
479
|
+
// out and come back without the candidate list changing. Keeping what
|
|
480
|
+
// the model said means the restock is picked up from cache rather than
|
|
481
|
+
// paid for again.
|
|
482
|
+
if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// One place where anything is served, whichever side of the cache it came
|
|
486
|
+
// from, and always against the facts of the shopper asking now.
|
|
487
|
+
// A cohort spec names products chosen for whoever asked first.
|
|
488
|
+
const served =
|
|
489
|
+
generation === 'cohort' ? fitCohortSpec(answer.spec, input, digest) : answer.spec;
|
|
490
|
+
|
|
491
|
+
const reconciled = reconcileSpec(served, input, digest);
|
|
492
|
+
if (!reconciled.isUsable) {
|
|
493
|
+
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|
|
494
|
+
calledModel,
|
|
495
|
+
violations: reconciled.violations,
|
|
496
|
+
...(answer.usage ? { usage: answer.usage } : {}),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const finishedAt = Date.now();
|
|
501
|
+
const source: SpecSource = cached ? 'cache' : 'llm';
|
|
502
|
+
report({
|
|
503
|
+
key,
|
|
504
|
+
source,
|
|
505
|
+
elapsedMs: finishedAt - startedAt,
|
|
506
|
+
calledModel,
|
|
507
|
+
violations: reconciled.violations,
|
|
508
|
+
...(answer.usage ? { usage: answer.usage } : {}),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return withProvenance(reconciled.spec, {
|
|
512
|
+
slot: digest.slot,
|
|
513
|
+
source,
|
|
514
|
+
generatedAt,
|
|
515
|
+
latencyMs: finishedAt - startedAt,
|
|
516
|
+
provider: provider.name,
|
|
517
|
+
model: provider.model,
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|