@rudra-js/core 0.7.4 → 0.7.5
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/dist/claim-screening.d.ts +3 -3
- package/dist/claim-screening.d.ts.map +1 -1
- package/dist/claim-screening.js +30 -93
- package/dist/claim-screening.js.map +1 -1
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +161 -176
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +1 -0
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -0
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +1 -1
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +9 -17
- package/dist/fallback-component.js.map +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +19 -19
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +56 -39
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +15 -31
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +3 -3
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +133 -171
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +1 -1
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +51 -52
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +0 -4
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +15 -35
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +16 -19
- package/dist/tracking-input.js.map +1 -1
- package/package.json +3 -3
- package/src/claim-screening.ts +34 -114
- package/src/component-generator.ts +223 -220
- package/src/component-spec.ts +4 -15
- package/src/fallback-component.ts +11 -20
- package/src/fit-to-shopper.ts +19 -21
- package/src/model-prompt.ts +55 -54
- package/src/product-selection.ts +21 -50
- package/src/provider.ts +0 -4
- package/src/reconciliation.ts +159 -268
- package/src/signal-digest.ts +50 -79
- package/src/spec-cache.ts +15 -48
- package/src/tracking-input.ts +21 -36
|
@@ -2,7 +2,6 @@ import { z } from 'zod';
|
|
|
2
2
|
import {
|
|
3
3
|
SPEC_VERSION,
|
|
4
4
|
generatedSpecSchema,
|
|
5
|
-
type Block,
|
|
6
5
|
type ComponentSpec,
|
|
7
6
|
type DegradedReason,
|
|
8
7
|
type GeneratedSpec,
|
|
@@ -12,12 +11,11 @@ import { buildFallbackSpec } from './fallback-component.js';
|
|
|
12
11
|
import { buildPrompt } from './model-prompt.js';
|
|
13
12
|
import type { ComponentProvider, TokenUsage } from './provider.js';
|
|
14
13
|
import { bundleForShopper, capBlocks, placeableHeroSkus, reconcileSpec } from './reconciliation.js';
|
|
15
|
-
import { selectProducts, type RankOrder
|
|
14
|
+
import { selectProducts, type RankOrder } from './product-selection.js';
|
|
16
15
|
import { fitToShopper, type FittedSpec } from './fit-to-shopper.js';
|
|
17
16
|
import { buildDigest, toCohortDigest, type SignalDigest } from './signal-digest.js';
|
|
18
17
|
import {
|
|
19
18
|
createMemorySpecCache,
|
|
20
|
-
cohortCacheKey,
|
|
21
19
|
specCacheKey,
|
|
22
20
|
type CachedSpec,
|
|
23
21
|
type SpecCache,
|
|
@@ -31,11 +29,8 @@ import {
|
|
|
31
29
|
export interface GenerationEvent {
|
|
32
30
|
key: string | null;
|
|
33
31
|
source: SpecSource;
|
|
34
|
-
|
|
35
32
|
elapsedMs: number;
|
|
36
|
-
|
|
37
33
|
calledModel: boolean;
|
|
38
|
-
|
|
39
34
|
violations?: string[];
|
|
40
35
|
usage?: TokenUsage;
|
|
41
36
|
degradedReason?: DegradedReason;
|
|
@@ -45,23 +40,16 @@ export interface GenerationEvent {
|
|
|
45
40
|
|
|
46
41
|
export interface ComponentGeneratorOptions {
|
|
47
42
|
provider?: ComponentProvider | null;
|
|
48
|
-
|
|
49
43
|
cache?: SpecCache;
|
|
50
|
-
|
|
51
44
|
modelTimeoutMs?: number;
|
|
52
|
-
|
|
53
45
|
cacheTimeoutMs?: number;
|
|
54
|
-
|
|
55
46
|
generation?: 'cohort' | 'per-shopper';
|
|
56
|
-
|
|
57
47
|
rank?: RankOrder;
|
|
58
|
-
|
|
59
48
|
onEvent?: (event: GenerationEvent) => void;
|
|
60
49
|
}
|
|
61
50
|
|
|
62
51
|
export interface ComponentGenerator {
|
|
63
52
|
generate(input: TrackingInputDraft): Promise<ComponentSpec>;
|
|
64
|
-
|
|
65
53
|
generateDeterministic(input: TrackingInputDraft): ComponentSpec;
|
|
66
54
|
}
|
|
67
55
|
|
|
@@ -98,23 +86,6 @@ async function withinBudget<T>(
|
|
|
98
86
|
}
|
|
99
87
|
}
|
|
100
88
|
|
|
101
|
-
function createSingleFlight() {
|
|
102
|
-
const inFlight = new Map<string, Promise<ModelCall>>();
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
isRunning: (key: string) => inFlight.has(key),
|
|
106
|
-
|
|
107
|
-
run(key: string, task: () => Promise<ModelCall>): Promise<ModelCall> {
|
|
108
|
-
const existing = inFlight.get(key);
|
|
109
|
-
if (existing) return existing;
|
|
110
|
-
|
|
111
|
-
const started = task().finally(() => inFlight.delete(key));
|
|
112
|
-
inFlight.set(key, started);
|
|
113
|
-
return started;
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
89
|
const cachedSpecSchema = z.object({
|
|
119
90
|
spec: generatedSpecSchema,
|
|
120
91
|
generatedAt: z.number(),
|
|
@@ -130,6 +101,40 @@ interface ModelCall {
|
|
|
130
101
|
usage?: TokenUsage;
|
|
131
102
|
}
|
|
132
103
|
|
|
104
|
+
interface Settings {
|
|
105
|
+
options: ComponentGeneratorOptions;
|
|
106
|
+
provider: ComponentProvider | null;
|
|
107
|
+
cache: SpecCache;
|
|
108
|
+
generation: 'cohort' | 'per-shopper';
|
|
109
|
+
rank: RankOrder;
|
|
110
|
+
modelTimeoutMs: number;
|
|
111
|
+
cacheTimeoutMs: number;
|
|
112
|
+
inFlight: Map<string, Promise<ModelCall>>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Run {
|
|
116
|
+
input: TrackingInput;
|
|
117
|
+
digest: SignalDigest;
|
|
118
|
+
promptDigest: SignalDigest;
|
|
119
|
+
startedAt: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Trace {
|
|
123
|
+
calledModel: boolean;
|
|
124
|
+
cache: CacheRead['outcome'];
|
|
125
|
+
usage: TokenUsage | undefined;
|
|
126
|
+
error?: unknown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Found =
|
|
130
|
+
| { spec: GeneratedSpec; generatedAt: number; source: 'cache' | 'llm'; trace: Trace }
|
|
131
|
+
| { failed: DegradedReason; trace: Trace };
|
|
132
|
+
|
|
133
|
+
type EventDetails = Pick<
|
|
134
|
+
GenerationEvent,
|
|
135
|
+
'calledModel' | 'usage' | 'violations' | 'cache' | 'error'
|
|
136
|
+
>;
|
|
137
|
+
|
|
133
138
|
function fitCohortSpec(
|
|
134
139
|
spec: GeneratedSpec,
|
|
135
140
|
input: TrackingInput,
|
|
@@ -139,222 +144,220 @@ function fitCohortSpec(
|
|
|
139
144
|
const picks = selectProducts(input, digest, { rank });
|
|
140
145
|
const blocks = capBlocks(spec.blocks);
|
|
141
146
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const block of blocks) {
|
|
145
|
-
if (block.kind === 'bundle') {
|
|
146
|
-
hasBundleBlock = true;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
aboveBundle.push(block);
|
|
150
|
-
}
|
|
151
|
-
if (!hasBundleBlock) return fitToShopper(spec, picks, digest.maxItems);
|
|
147
|
+
const bundleAt = blocks.findIndex((block) => block.kind === 'bundle');
|
|
148
|
+
if (bundleAt === -1) return fitToShopper(spec, picks, digest.maxItems);
|
|
152
149
|
|
|
153
|
-
const
|
|
154
|
-
|
|
150
|
+
const heroesAbove = placeableHeroSkus(blocks.slice(0, bundleAt), input);
|
|
151
|
+
const bundle = bundleForShopper(input, digest, heroesAbove);
|
|
152
|
+
if (!bundle) return fitToShopper(spec, picks, digest.maxItems);
|
|
155
153
|
|
|
156
|
-
const spokenFor = new Set<string>(
|
|
154
|
+
const spokenFor = new Set<string>(bundle.skus);
|
|
157
155
|
for (const sku of placeableHeroSkus(blocks, input)) spokenFor.add(sku);
|
|
158
156
|
|
|
159
157
|
const roomLeft = digest.maxItems - spokenFor.size;
|
|
160
|
-
|
|
161
158
|
if (roomLeft <= 0) return fitToShopper(spec, picks, digest.maxItems);
|
|
162
159
|
|
|
163
|
-
const forGrid
|
|
164
|
-
for (const pick of picks) {
|
|
165
|
-
if (!spokenFor.has(pick.product.sku)) forGrid.push(pick);
|
|
166
|
-
}
|
|
167
|
-
|
|
160
|
+
const forGrid = picks.filter((pick) => !spokenFor.has(pick.product.sku));
|
|
168
161
|
return fitToShopper(spec, forGrid, roomLeft);
|
|
169
162
|
}
|
|
170
163
|
|
|
171
164
|
export function createComponentGenerator(
|
|
172
165
|
options: ComponentGeneratorOptions = {},
|
|
173
166
|
): ComponentGenerator {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!options.onEvent) return;
|
|
184
|
-
try {
|
|
185
|
-
options.onEvent(event);
|
|
186
|
-
} catch {}
|
|
167
|
+
const settings: Settings = {
|
|
168
|
+
options,
|
|
169
|
+
provider: options.provider ?? null,
|
|
170
|
+
cache: options.cache ?? createMemorySpecCache(),
|
|
171
|
+
generation: options.generation ?? 'cohort',
|
|
172
|
+
rank: options.rank ?? 'signals',
|
|
173
|
+
modelTimeoutMs: options.modelTimeoutMs ?? 1_500,
|
|
174
|
+
cacheTimeoutMs: options.cacheTimeoutMs ?? 50,
|
|
175
|
+
inFlight: new Map(),
|
|
187
176
|
};
|
|
188
177
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
startedAt: number,
|
|
193
|
-
key: string | null,
|
|
194
|
-
degradedReason: DegradedReason,
|
|
195
|
-
modelCall: Pick<GenerationEvent, 'calledModel' | 'usage' | 'violations' | 'cache' | 'error'> = {
|
|
196
|
-
calledModel: false,
|
|
197
|
-
},
|
|
198
|
-
): ComponentSpec => {
|
|
199
|
-
const finishedAt = Date.now();
|
|
200
|
-
report({
|
|
201
|
-
key,
|
|
202
|
-
source: 'fallback',
|
|
203
|
-
elapsedMs: finishedAt - startedAt,
|
|
204
|
-
...modelCall,
|
|
205
|
-
degradedReason,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
...buildFallbackSpec(input, digest, { rank }),
|
|
210
|
-
specVersion: SPEC_VERSION,
|
|
211
|
-
slot: digest.slot,
|
|
212
|
-
source: 'fallback',
|
|
213
|
-
generatedAt: finishedAt,
|
|
214
|
-
latencyMs: finishedAt - startedAt,
|
|
215
|
-
provider: null,
|
|
216
|
-
model: null,
|
|
217
|
-
degradedReason,
|
|
218
|
-
};
|
|
178
|
+
return {
|
|
179
|
+
generate: (draft) => generate(settings, draft),
|
|
180
|
+
generateDeterministic: (draft) => generateDeterministic(settings, draft),
|
|
219
181
|
};
|
|
182
|
+
}
|
|
220
183
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
184
|
+
async function generate(settings: Settings, draft: TrackingInputDraft): Promise<ComponentSpec> {
|
|
185
|
+
const run = startRun(settings, draft);
|
|
186
|
+
const provider = settings.provider;
|
|
187
|
+
if (!provider) return fallback(settings, run, null, 'no-provider');
|
|
188
|
+
|
|
189
|
+
const key = cacheKeyFor(provider, run);
|
|
190
|
+
const found = await findSpec(settings, provider, run, key);
|
|
191
|
+
if ('failed' in found)
|
|
192
|
+
return fallback(settings, run, key, found.failed, eventDetails(found.trace));
|
|
193
|
+
|
|
194
|
+
const reconciled = shapeForShopper(settings, run, found.spec);
|
|
195
|
+
const details = eventDetails(found.trace, reconciled.violations);
|
|
196
|
+
if (!reconciled.isUsable) return fallback(settings, run, key, 'unusable-on-serve', details);
|
|
197
|
+
|
|
198
|
+
const finishedAt = Date.now();
|
|
199
|
+
report(settings, {
|
|
200
|
+
key,
|
|
201
|
+
source: found.source,
|
|
202
|
+
elapsedMs: finishedAt - run.startedAt,
|
|
203
|
+
...details,
|
|
204
|
+
});
|
|
230
205
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
206
|
+
return {
|
|
207
|
+
...reconciled.spec,
|
|
208
|
+
specVersion: SPEC_VERSION,
|
|
209
|
+
slot: run.digest.slot,
|
|
210
|
+
source: found.source,
|
|
211
|
+
generatedAt: found.generatedAt,
|
|
212
|
+
latencyMs: finishedAt - run.startedAt,
|
|
213
|
+
provider: provider.name,
|
|
214
|
+
model: provider.model,
|
|
235
215
|
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function generateDeterministic(settings: Settings, draft: TrackingInputDraft): ComponentSpec {
|
|
219
|
+
return fallback(settings, startRun(settings, draft), null, 'requested');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function startRun(settings: Settings, draft: TrackingInputDraft): Run {
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
const input = parseTrackingInput(draft);
|
|
225
|
+
const digest = buildDigest(input);
|
|
226
|
+
const promptDigest = settings.generation === 'cohort' ? toCohortDigest(digest) : digest;
|
|
227
|
+
return { input, digest, promptDigest, startedAt };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cacheKeyFor(provider: ComponentProvider, run: Run): string {
|
|
231
|
+
const identity = { name: provider.name, model: provider.model };
|
|
232
|
+
const skus = run.input.candidates.map((product) => product.sku);
|
|
233
|
+
return specCacheKey(run.promptDigest, skus, identity);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function findSpec(
|
|
237
|
+
settings: Settings,
|
|
238
|
+
provider: ComponentProvider,
|
|
239
|
+
run: Run,
|
|
240
|
+
key: string,
|
|
241
|
+
): Promise<Found> {
|
|
242
|
+
const read = await readCache(settings, key);
|
|
243
|
+
if (read.entry) {
|
|
244
|
+
const trace = { calledModel: false, cache: read.outcome, usage: undefined };
|
|
245
|
+
return { spec: read.entry.spec, generatedAt: read.entry.generatedAt, source: 'cache', trace };
|
|
246
|
+
}
|
|
236
247
|
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
248
|
+
const calledModel = !settings.inFlight.has(key);
|
|
249
|
+
let call: ModelCall;
|
|
250
|
+
try {
|
|
251
|
+
call = await sharedCall(settings, key, () => askModel(settings, provider, run));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const failed = error instanceof TimeoutError ? 'timeout' : 'provider-error';
|
|
254
|
+
return { failed, trace: { calledModel, cache: read.outcome, usage: undefined, error } };
|
|
255
|
+
}
|
|
243
256
|
|
|
244
|
-
|
|
245
|
-
|
|
257
|
+
const trace = { calledModel, cache: read.outcome, usage: call.usage };
|
|
258
|
+
if (!call.spec) return { failed: 'invalid-generation', trace };
|
|
259
|
+
|
|
260
|
+
const generatedAt = Date.now();
|
|
261
|
+
if (calledModel) storeInBackground(settings, key, { spec: call.spec, generatedAt });
|
|
262
|
+
return { spec: call.spec, generatedAt, source: 'llm', trace };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function readCache(settings: Settings, key: string): Promise<CacheRead> {
|
|
266
|
+
try {
|
|
267
|
+
const stored = await withinBudget('cache read', settings.cacheTimeoutMs, () =>
|
|
268
|
+
settings.cache.get(key),
|
|
246
269
|
);
|
|
270
|
+
const parsed = cachedSpecSchema.safeParse(stored);
|
|
271
|
+
return parsed.success ? { outcome: 'hit', entry: parsed.data } : { outcome: 'miss' };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { outcome: error instanceof TimeoutError ? 'timeout' : 'error' };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function askModel(
|
|
278
|
+
settings: Settings,
|
|
279
|
+
provider: ComponentProvider,
|
|
280
|
+
run: Run,
|
|
281
|
+
): Promise<ModelCall> {
|
|
282
|
+
const { system, user } = buildPrompt(run.input, run.promptDigest);
|
|
283
|
+
const result = await withinBudget('generation', settings.modelTimeoutMs, (signal) =>
|
|
284
|
+
provider.generate({ system, user, schema: generatedSpecSchema, signal }),
|
|
285
|
+
);
|
|
286
|
+
const parsed = generatedSpecSchema.safeParse(result.spec);
|
|
287
|
+
return {
|
|
288
|
+
spec: parsed.success ? parsed.data : null,
|
|
289
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sharedCall(
|
|
294
|
+
settings: Settings,
|
|
295
|
+
key: string,
|
|
296
|
+
ask: () => Promise<ModelCall>,
|
|
297
|
+
): Promise<ModelCall> {
|
|
298
|
+
const running = settings.inFlight.get(key);
|
|
299
|
+
if (running) return running;
|
|
300
|
+
const started = ask().finally(() => settings.inFlight.delete(key));
|
|
301
|
+
settings.inFlight.set(key, started);
|
|
302
|
+
return started;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function storeInBackground(settings: Settings, key: string, cached: CachedSpec): void {
|
|
306
|
+
void Promise.resolve()
|
|
307
|
+
.then(() => settings.cache.set(key, cached))
|
|
308
|
+
.catch(() => {});
|
|
309
|
+
}
|
|
247
310
|
|
|
248
|
-
|
|
311
|
+
function shapeForShopper(settings: Settings, run: Run, spec: GeneratedSpec) {
|
|
312
|
+
if (settings.generation !== 'cohort') {
|
|
313
|
+
return reconcileSpec(spec, run.input, run.digest, new Map<string, string>());
|
|
314
|
+
}
|
|
315
|
+
const fitted = fitCohortSpec(spec, run.input, run.digest, settings.rank);
|
|
316
|
+
return reconcileSpec(fitted.spec, run.input, run.digest, fitted.ourReasons);
|
|
317
|
+
}
|
|
249
318
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
319
|
+
function fallback(
|
|
320
|
+
settings: Settings,
|
|
321
|
+
run: Run,
|
|
322
|
+
key: string | null,
|
|
323
|
+
degradedReason: DegradedReason,
|
|
324
|
+
details: EventDetails = { calledModel: false },
|
|
325
|
+
): ComponentSpec {
|
|
326
|
+
const finishedAt = Date.now();
|
|
327
|
+
report(settings, {
|
|
328
|
+
key,
|
|
329
|
+
source: 'fallback',
|
|
330
|
+
elapsedMs: finishedAt - run.startedAt,
|
|
331
|
+
...details,
|
|
332
|
+
degradedReason,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
...buildFallbackSpec(run.input, run.digest, { rank: settings.rank }),
|
|
337
|
+
specVersion: SPEC_VERSION,
|
|
338
|
+
slot: run.digest.slot,
|
|
339
|
+
source: 'fallback',
|
|
340
|
+
generatedAt: finishedAt,
|
|
341
|
+
latencyMs: finishedAt - run.startedAt,
|
|
342
|
+
provider: null,
|
|
343
|
+
model: null,
|
|
344
|
+
degradedReason,
|
|
254
345
|
};
|
|
346
|
+
}
|
|
255
347
|
|
|
348
|
+
function eventDetails(trace: Trace, violations?: string[]): EventDetails {
|
|
256
349
|
return {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
},
|
|
262
|
-
|
|
263
|
-
async generate(draft) {
|
|
264
|
-
const startedAt = Date.now();
|
|
265
|
-
|
|
266
|
-
const input = parseTrackingInput(draft);
|
|
267
|
-
const digest = buildDigest(input);
|
|
268
|
-
|
|
269
|
-
if (!provider) {
|
|
270
|
-
return buildDeterministic(input, digest, startedAt, null, 'no-provider');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const identity = { name: provider.name, model: provider.model };
|
|
274
|
-
const candidateSkus = input.candidates.map((product) => product.sku);
|
|
275
|
-
const key =
|
|
276
|
-
generation === 'cohort'
|
|
277
|
-
? cohortCacheKey(digest, candidateSkus, identity)
|
|
278
|
-
: specCacheKey(digest, candidateSkus, identity);
|
|
279
|
-
|
|
280
|
-
const read = await readCache(key);
|
|
281
|
-
const cached = read.entry;
|
|
282
|
-
let calledModel = false;
|
|
283
|
-
let answer: { spec: GeneratedSpec; usage?: TokenUsage };
|
|
284
|
-
|
|
285
|
-
let generatedAt: number;
|
|
286
|
-
|
|
287
|
-
if (cached) {
|
|
288
|
-
answer = { spec: cached.spec };
|
|
289
|
-
generatedAt = cached.generatedAt;
|
|
290
|
-
} else {
|
|
291
|
-
calledModel = !singleFlight.isRunning(key);
|
|
292
|
-
|
|
293
|
-
let call: ModelCall;
|
|
294
|
-
try {
|
|
295
|
-
call = await singleFlight.run(key, () =>
|
|
296
|
-
askModel(provider, input, generation === 'cohort' ? toCohortDigest(digest) : digest),
|
|
297
|
-
);
|
|
298
|
-
} catch (error) {
|
|
299
|
-
const reason = error instanceof TimeoutError ? 'timeout' : 'provider-error';
|
|
300
|
-
return buildDeterministic(input, digest, startedAt, key, reason, {
|
|
301
|
-
calledModel,
|
|
302
|
-
cache: read.outcome,
|
|
303
|
-
error,
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
if (!call.spec) {
|
|
308
|
-
return buildDeterministic(input, digest, startedAt, key, 'invalid-generation', {
|
|
309
|
-
calledModel,
|
|
310
|
-
cache: read.outcome,
|
|
311
|
-
...(call.usage ? { usage: call.usage } : {}),
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
answer = { spec: call.spec, ...(call.usage ? { usage: call.usage } : {}) };
|
|
316
|
-
generatedAt = Date.now();
|
|
317
|
-
|
|
318
|
-
if (calledModel) storeInBackground(key, { spec: answer.spec, generatedAt });
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const { spec: served, ourReasons } =
|
|
322
|
-
generation === 'cohort'
|
|
323
|
-
? fitCohortSpec(answer.spec, input, digest, rank)
|
|
324
|
-
: { spec: answer.spec, ourReasons: new Map<string, string>() };
|
|
325
|
-
|
|
326
|
-
const reconciled = reconcileSpec(served, input, digest, ourReasons);
|
|
327
|
-
if (!reconciled.isUsable) {
|
|
328
|
-
return buildDeterministic(input, digest, startedAt, key, 'unusable-on-serve', {
|
|
329
|
-
calledModel,
|
|
330
|
-
cache: read.outcome,
|
|
331
|
-
violations: reconciled.violations,
|
|
332
|
-
...(answer.usage ? { usage: answer.usage } : {}),
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const finishedAt = Date.now();
|
|
337
|
-
const source: SpecSource = cached ? 'cache' : 'llm';
|
|
338
|
-
report({
|
|
339
|
-
key,
|
|
340
|
-
source,
|
|
341
|
-
elapsedMs: finishedAt - startedAt,
|
|
342
|
-
calledModel,
|
|
343
|
-
cache: read.outcome,
|
|
344
|
-
violations: reconciled.violations,
|
|
345
|
-
...(answer.usage ? { usage: answer.usage } : {}),
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
...reconciled.spec,
|
|
350
|
-
specVersion: SPEC_VERSION,
|
|
351
|
-
slot: digest.slot,
|
|
352
|
-
source,
|
|
353
|
-
generatedAt,
|
|
354
|
-
latencyMs: finishedAt - startedAt,
|
|
355
|
-
provider: provider.name,
|
|
356
|
-
model: provider.model,
|
|
357
|
-
};
|
|
358
|
-
},
|
|
350
|
+
calledModel: trace.calledModel,
|
|
351
|
+
cache: trace.cache,
|
|
352
|
+
...(violations ? { violations } : {}),
|
|
353
|
+
...(trace.usage ? { usage: trace.usage } : {}),
|
|
354
|
+
...('error' in trace ? { error: trace.error } : {}),
|
|
359
355
|
};
|
|
360
356
|
}
|
|
357
|
+
|
|
358
|
+
function report(settings: Settings, event: GenerationEvent): void {
|
|
359
|
+
if (!settings.options.onEvent) return;
|
|
360
|
+
try {
|
|
361
|
+
settings.options.onEvent(event);
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
package/src/component-spec.ts
CHANGED
|
@@ -8,25 +8,17 @@ export const EMPHASIS = ['normal', 'featured'] as const;
|
|
|
8
8
|
|
|
9
9
|
export const RECOMMENDATION_BASES = [
|
|
10
10
|
'similar_to_current',
|
|
11
|
-
|
|
12
11
|
'most_viewed',
|
|
13
|
-
|
|
14
12
|
'complements_cart',
|
|
15
|
-
|
|
16
13
|
'complements_purchase',
|
|
17
|
-
|
|
18
14
|
'liked_category',
|
|
19
|
-
|
|
20
15
|
'popular',
|
|
21
16
|
] as const;
|
|
22
17
|
|
|
23
18
|
export const productReferenceSchema = z.object({
|
|
24
19
|
sku: z.string(),
|
|
25
|
-
|
|
26
20
|
basis: z.enum(RECOMMENDATION_BASES),
|
|
27
|
-
|
|
28
21
|
reason: z.string().nullable(),
|
|
29
|
-
|
|
30
22
|
badge: z.string().nullable(),
|
|
31
23
|
emphasis: z.enum(EMPHASIS),
|
|
32
24
|
});
|
|
@@ -48,6 +40,10 @@ const gridBlockSchema = z.object({
|
|
|
48
40
|
items: z.array(productReferenceSchema),
|
|
49
41
|
});
|
|
50
42
|
|
|
43
|
+
export function columnsFor(count: number, widest: 2 | 3 | 4): 2 | 3 | 4 {
|
|
44
|
+
return Math.min(widest, Math.max(2, count)) as 2 | 3 | 4;
|
|
45
|
+
}
|
|
46
|
+
|
|
51
47
|
const carouselBlockSchema = z.object({
|
|
52
48
|
kind: z.literal('carousel'),
|
|
53
49
|
title: z.string().nullable(),
|
|
@@ -72,7 +68,6 @@ const bundleBlockSchema = z.object({
|
|
|
72
68
|
title: z.string().nullable(),
|
|
73
69
|
body: z.string().nullable(),
|
|
74
70
|
ctaLabel: z.string().nullable(),
|
|
75
|
-
|
|
76
71
|
bundleId: z.string().nullable(),
|
|
77
72
|
});
|
|
78
73
|
|
|
@@ -99,7 +94,6 @@ export const generatedSpecSchema = z.object({
|
|
|
99
94
|
headline: z.string(),
|
|
100
95
|
subheadline: z.string().nullable(),
|
|
101
96
|
blocks: z.array(blockSchema),
|
|
102
|
-
|
|
103
97
|
rationale: z.string(),
|
|
104
98
|
});
|
|
105
99
|
export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
|
|
@@ -121,15 +115,10 @@ export interface ComponentSpec extends GeneratedSpec {
|
|
|
121
115
|
specVersion: typeof SPEC_VERSION;
|
|
122
116
|
slot: string;
|
|
123
117
|
source: SpecSource;
|
|
124
|
-
|
|
125
118
|
generatedAt: number;
|
|
126
|
-
|
|
127
119
|
latencyMs: number;
|
|
128
|
-
|
|
129
120
|
provider: string | null;
|
|
130
|
-
|
|
131
121
|
model: string | null;
|
|
132
|
-
|
|
133
122
|
degradedReason?: DegradedReason;
|
|
134
123
|
}
|
|
135
124
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { columnsFor, type GeneratedSpec, type ProductReference } from './component-spec.js';
|
|
2
2
|
import type { SignalDigest } from './signal-digest.js';
|
|
3
|
-
import { selectProducts, type SelectOptions
|
|
3
|
+
import { selectProducts, type SelectOptions } from './product-selection.js';
|
|
4
4
|
import type { TrackingInput } from './tracking-input.js';
|
|
5
5
|
|
|
6
6
|
const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
|
|
@@ -19,22 +19,6 @@ function headlineFor(digest: SignalDigest): { headline: string; subheadline: str
|
|
|
19
19
|
return { headline: 'You might also like', subheadline: null };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function columnsFor(itemCount: number): 2 | 3 | 4 {
|
|
23
|
-
if (itemCount >= 4) return 4;
|
|
24
|
-
if (itemCount === 3) return 3;
|
|
25
|
-
return 2;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function toProductReference(pick: ProductPick, index: number, total: number): ProductReference {
|
|
29
|
-
return {
|
|
30
|
-
sku: pick.product.sku,
|
|
31
|
-
basis: pick.basis,
|
|
32
|
-
reason: pick.reason,
|
|
33
|
-
badge: null,
|
|
34
|
-
emphasis: index === 0 && total >= MIN_PICKS_FOR_A_FEATURED_LEAD ? 'featured' : 'normal',
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
22
|
export function buildFallbackSpec(
|
|
39
23
|
input: TrackingInput,
|
|
40
24
|
digest: SignalDigest,
|
|
@@ -42,7 +26,14 @@ export function buildFallbackSpec(
|
|
|
42
26
|
): GeneratedSpec {
|
|
43
27
|
const picks = selectProducts(input, digest, options).slice(0, digest.maxItems);
|
|
44
28
|
const { headline, subheadline } = headlineFor(digest);
|
|
45
|
-
const
|
|
29
|
+
const isLeadFeatured = picks.length >= MIN_PICKS_FOR_A_FEATURED_LEAD;
|
|
30
|
+
const items: ProductReference[] = picks.map((pick, index) => ({
|
|
31
|
+
sku: pick.product.sku,
|
|
32
|
+
basis: pick.basis,
|
|
33
|
+
reason: pick.reason,
|
|
34
|
+
badge: null,
|
|
35
|
+
emphasis: index === 0 && isLeadFeatured ? 'featured' : 'normal',
|
|
36
|
+
}));
|
|
46
37
|
|
|
47
38
|
return {
|
|
48
39
|
tone: 'neutral',
|
|
@@ -51,7 +42,7 @@ export function buildFallbackSpec(
|
|
|
51
42
|
blocks:
|
|
52
43
|
items.length === 0
|
|
53
44
|
? []
|
|
54
|
-
: [{ kind: 'grid', title: null, columns: columnsFor(items.length), items }],
|
|
45
|
+
: [{ kind: 'grid', title: null, columns: columnsFor(items.length, 4), items }],
|
|
55
46
|
rationale: digest.isColdStart
|
|
56
47
|
? 'Deterministic: no behavioural signals, ranked by rating and stock.'
|
|
57
48
|
: 'Deterministic: ranked by category affinity, revisit, rating and tag overlap.',
|