@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/component-spec.ts
CHANGED
|
@@ -1,94 +1,38 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The component specification — the only thing a language model is ever allowed
|
|
5
|
-
* to return.
|
|
6
|
-
*
|
|
7
|
-
* The model chooses layout, ordering, emphasis and copy. Which products to show
|
|
8
|
-
* and why is its call only in per-shopper mode, apart from the product a hero
|
|
9
|
-
* names. It never returns markup, code, URLs, prices, product titles, or
|
|
10
|
-
* images. Every field is an enum, a bounded number, a SKU reference resolved
|
|
11
|
-
* against the candidate set, or free text that is clamped and escaped before it
|
|
12
|
-
* renders. That is what makes generated output safe to put in a server-rendered
|
|
13
|
-
* response.
|
|
14
|
-
*
|
|
15
|
-
* Two constraints shape this file, and both are easy to undo by accident:
|
|
16
|
-
*
|
|
17
|
-
* 1. It doubles as a provider structured-output schema, so it avoids
|
|
18
|
-
* recursion, string length bounds, and numeric ranges. Providers reject
|
|
19
|
-
* schemas that use them in strict mode. Bounds are enforced afterwards, in
|
|
20
|
-
* reconciliation, where a violation can be repaired rather than fatal.
|
|
21
|
-
* 2. Optionality is `.nullable()` rather than `.optional()`, because strict
|
|
22
|
-
* structured outputs require every declared property to be present. "No
|
|
23
|
-
* badge" has to be expressible as `null`, not as a missing key.
|
|
24
|
-
*
|
|
25
|
-
* A test asserts both by converting this schema to JSON Schema, so neither is
|
|
26
|
-
* left to a comment nobody reads.
|
|
27
|
-
*
|
|
28
|
-
* Note the asymmetry with `tracking-input`, which rejects unknown fields: these
|
|
29
|
-
* schemas strip them. The inputs differ in kind. An unrecognised key in a host
|
|
30
|
-
* payload is a typo, and silently dropping it costs the shopper their history,
|
|
31
|
-
* so it must be loud. An unrecognised key from a model is drift, and discarding
|
|
32
|
-
* it is exactly right — rejecting the whole spec over one stray key would cost
|
|
33
|
-
* the shopper the component. Either way the renderer only ever sees the fields
|
|
34
|
-
* declared here, which is the property that matters.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/** Overall voice of the component. */
|
|
38
3
|
export const TONES = ['neutral', 'enthusiastic', 'urgent', 'editorial'] as const;
|
|
39
4
|
|
|
40
|
-
/** What a banner is claiming. */
|
|
41
5
|
export const BANNER_TONES = ['info', 'promo', 'urgency', 'restock'] as const;
|
|
42
6
|
|
|
43
|
-
/** How prominently a single product is placed. */
|
|
44
7
|
export const EMPHASIS = ['normal', 'featured'] as const;
|
|
45
8
|
|
|
46
|
-
/**
|
|
47
|
-
* Why a product was chosen — the recommendation strategy behind the pick.
|
|
48
|
-
*
|
|
49
|
-
* This is a closed set rather than free text because every value here is a
|
|
50
|
-
* factual claim about the shopper, and a claim the server can check. "Because
|
|
51
|
-
* you viewed this" written as prose is unverifiable: nothing downstream can
|
|
52
|
-
* tell whether the shopper viewed anything. Declared as `most_viewed`,
|
|
53
|
-
* reconciliation can look it up in the digest and drop the claim if it is
|
|
54
|
-
* false.
|
|
55
|
-
*
|
|
56
|
-
* Every value is deliberately checkable against data already in hand — the
|
|
57
|
-
* signal digest or the host's own catalog. A basis that needs data the
|
|
58
|
-
* framework never receives (`new_arrival`, `back_in_stock`, `trending_today`)
|
|
59
|
-
* is not in this list, because it could only ever be taken on trust.
|
|
60
|
-
*/
|
|
61
9
|
export const RECOMMENDATION_BASES = [
|
|
62
|
-
/** Same category as the product being viewed. */
|
|
63
10
|
'similar_to_current',
|
|
64
|
-
|
|
11
|
+
|
|
65
12
|
'most_viewed',
|
|
66
|
-
|
|
13
|
+
|
|
67
14
|
'complements_cart',
|
|
68
|
-
|
|
15
|
+
|
|
69
16
|
'complements_purchase',
|
|
70
|
-
|
|
17
|
+
|
|
71
18
|
'liked_category',
|
|
72
|
-
|
|
19
|
+
|
|
73
20
|
'popular',
|
|
74
21
|
] as const;
|
|
75
22
|
|
|
76
|
-
/** A reference to one product from the candidate set, and why it was chosen. */
|
|
77
23
|
export const productReferenceSchema = z.object({
|
|
78
|
-
/** Must be a SKU the host supplied in `TrackingInput.candidates`. */
|
|
79
24
|
sku: z.string(),
|
|
80
|
-
|
|
25
|
+
|
|
81
26
|
basis: z.enum(RECOMMENDATION_BASES),
|
|
82
|
-
|
|
27
|
+
|
|
83
28
|
reason: z.string().nullable(),
|
|
84
|
-
|
|
29
|
+
|
|
85
30
|
badge: z.string().nullable(),
|
|
86
31
|
emphasis: z.enum(EMPHASIS),
|
|
87
32
|
});
|
|
88
33
|
export type ProductReference = z.infer<typeof productReferenceSchema>;
|
|
89
34
|
export type RecommendationBasis = (typeof RECOMMENDATION_BASES)[number];
|
|
90
35
|
|
|
91
|
-
/** One large featured statement, optionally anchored to a single product. */
|
|
92
36
|
const heroBlockSchema = z.object({
|
|
93
37
|
kind: z.literal('hero'),
|
|
94
38
|
headline: z.string(),
|
|
@@ -97,7 +41,6 @@ const heroBlockSchema = z.object({
|
|
|
97
41
|
ctaLabel: z.string().nullable(),
|
|
98
42
|
});
|
|
99
43
|
|
|
100
|
-
/** The general-purpose choice when several products are comparably relevant. */
|
|
101
44
|
const gridBlockSchema = z.object({
|
|
102
45
|
kind: z.literal('grid'),
|
|
103
46
|
title: z.string().nullable(),
|
|
@@ -105,14 +48,12 @@ const gridBlockSchema = z.object({
|
|
|
105
48
|
items: z.array(productReferenceSchema),
|
|
106
49
|
});
|
|
107
50
|
|
|
108
|
-
/** A horizontally scanned row, for when order implies a ranking. */
|
|
109
51
|
const carouselBlockSchema = z.object({
|
|
110
52
|
kind: z.literal('carousel'),
|
|
111
53
|
title: z.string().nullable(),
|
|
112
54
|
items: z.array(productReferenceSchema),
|
|
113
55
|
});
|
|
114
56
|
|
|
115
|
-
/** A single line of merchandising copy. */
|
|
116
57
|
const bannerBlockSchema = z.object({
|
|
117
58
|
kind: z.literal('banner'),
|
|
118
59
|
tone: z.enum(BANNER_TONES),
|
|
@@ -120,24 +61,21 @@ const bannerBlockSchema = z.object({
|
|
|
120
61
|
ctaLabel: z.string().nullable(),
|
|
121
62
|
});
|
|
122
63
|
|
|
123
|
-
/** Short editorial prose, for explaining the theme of a selection. */
|
|
124
64
|
const copyBlockSchema = z.object({
|
|
125
65
|
kind: z.literal('copy'),
|
|
126
66
|
title: z.string().nullable(),
|
|
127
67
|
body: z.string(),
|
|
128
68
|
});
|
|
129
69
|
|
|
130
|
-
/** A set the shop sells together. The shop picks which one; this only asks for it. */
|
|
131
70
|
const bundleBlockSchema = z.object({
|
|
132
71
|
kind: z.literal('bundle'),
|
|
133
72
|
title: z.string().nullable(),
|
|
134
73
|
body: z.string().nullable(),
|
|
135
74
|
ctaLabel: z.string().nullable(),
|
|
136
|
-
|
|
75
|
+
|
|
137
76
|
bundleId: z.string().nullable(),
|
|
138
77
|
});
|
|
139
78
|
|
|
140
|
-
/** Blocks do not nest. The vocabulary is closed on purpose. */
|
|
141
79
|
export const blockSchema = z.discriminatedUnion('kind', [
|
|
142
80
|
heroBlockSchema,
|
|
143
81
|
gridBlockSchema,
|
|
@@ -156,75 +94,49 @@ export type BannerBlock = z.infer<typeof bannerBlockSchema>;
|
|
|
156
94
|
export type CopyBlock = z.infer<typeof copyBlockSchema>;
|
|
157
95
|
export type BundleBlock = z.infer<typeof bundleBlockSchema>;
|
|
158
96
|
|
|
159
|
-
/**
|
|
160
|
-
* Exactly what the model must return. Provenance — version, slot, latency, which
|
|
161
|
-
* provider answered — is added by the server afterwards, and is deliberately not
|
|
162
|
-
* part of the model's burden.
|
|
163
|
-
*/
|
|
164
97
|
export const generatedSpecSchema = z.object({
|
|
165
98
|
tone: z.enum(TONES),
|
|
166
99
|
headline: z.string(),
|
|
167
100
|
subheadline: z.string().nullable(),
|
|
168
101
|
blocks: z.array(blockSchema),
|
|
169
|
-
|
|
102
|
+
|
|
170
103
|
rationale: z.string(),
|
|
171
104
|
});
|
|
172
105
|
export type GeneratedSpec = z.infer<typeof generatedSpecSchema>;
|
|
173
106
|
export type GeneratedSpecResult = z.ZodSafeParseResult<GeneratedSpec>;
|
|
174
107
|
|
|
175
|
-
/** How a spec came to exist. Surfaced for benchmarking and observability. */
|
|
176
108
|
export type SpecSource = 'llm' | 'cache' | 'fallback';
|
|
177
109
|
|
|
178
|
-
/**
|
|
179
|
-
* Why a component is not what the model would have produced.
|
|
180
|
-
*
|
|
181
|
-
* A closed set, like `SpecSource`: it is rendered into pages as
|
|
182
|
-
* `data-rudra-degraded` and counted in dashboards, so a host needs to know what
|
|
183
|
-
* it can receive without reading the generator.
|
|
184
|
-
*/
|
|
185
110
|
export type DegradedReason =
|
|
186
|
-
/** No provider was configured, so nothing was ever asked. */
|
|
187
111
|
| 'no-provider'
|
|
188
|
-
/** The provider errored, or threw before it reached the vendor. */
|
|
189
112
|
| 'provider-error'
|
|
190
|
-
/** The deadline fired before an answer arrived. */
|
|
191
113
|
| 'timeout'
|
|
192
|
-
/** An answer came back that did not satisfy the schema. */
|
|
193
114
|
| 'invalid-generation'
|
|
194
|
-
/** A usable answer reconciled down to nothing for this shopper. */
|
|
195
115
|
| 'unusable-on-serve'
|
|
196
|
-
/** The caller asked for the deterministic component on purpose. */
|
|
197
116
|
| 'requested';
|
|
198
117
|
|
|
199
118
|
export const SPEC_VERSION = '1' as const;
|
|
200
119
|
|
|
201
|
-
/** A generated spec plus the provenance the server owns. This is what renders. */
|
|
202
120
|
export interface ComponentSpec extends GeneratedSpec {
|
|
203
121
|
specVersion: typeof SPEC_VERSION;
|
|
204
122
|
slot: string;
|
|
205
123
|
source: SpecSource;
|
|
206
|
-
|
|
124
|
+
|
|
207
125
|
generatedAt: number;
|
|
208
|
-
|
|
126
|
+
|
|
209
127
|
latencyMs: number;
|
|
210
|
-
|
|
128
|
+
|
|
211
129
|
provider: string | null;
|
|
212
|
-
|
|
130
|
+
|
|
213
131
|
model: string | null;
|
|
214
|
-
|
|
215
|
-
* Why the deterministic component is showing instead of a generated one.
|
|
216
|
-
* Present on every fallback, including the ones where no model was involved
|
|
217
|
-
* at all — no provider configured, or the caller asked for it directly.
|
|
218
|
-
*/
|
|
132
|
+
|
|
219
133
|
degradedReason?: DegradedReason;
|
|
220
134
|
}
|
|
221
135
|
|
|
222
|
-
/** Throws a `ZodError` if the value is not a well-formed generated spec. */
|
|
223
136
|
export function parseGeneratedSpec(value: unknown): GeneratedSpec {
|
|
224
137
|
return generatedSpecSchema.parse(value);
|
|
225
138
|
}
|
|
226
139
|
|
|
227
|
-
/** Non-throwing variant, for validating untrusted model output. */
|
|
228
140
|
export function safeParseGeneratedSpec(value: unknown): GeneratedSpecResult {
|
|
229
141
|
return generatedSpecSchema.safeParse(value);
|
|
230
142
|
}
|
|
@@ -3,7 +3,6 @@ import type { SignalDigest } from './signal-digest.js';
|
|
|
3
3
|
import { selectProducts, type SelectOptions, type ProductPick } from './product-selection.js';
|
|
4
4
|
import type { TrackingInput } from './tracking-input.js';
|
|
5
5
|
|
|
6
|
-
/** A featured lead only reads as deliberate when something follows it. */
|
|
7
6
|
const MIN_PICKS_FOR_A_FEATURED_LEAD = 3;
|
|
8
7
|
|
|
9
8
|
function headlineFor(digest: SignalDigest): { headline: string; subheadline: string | null } {
|
|
@@ -20,7 +19,6 @@ function headlineFor(digest: SignalDigest): { headline: string; subheadline: str
|
|
|
20
19
|
return { headline: 'You might also like', subheadline: null };
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
/** Wide enough to fill, never wider. */
|
|
24
22
|
function columnsFor(itemCount: number): 2 | 3 | 4 {
|
|
25
23
|
if (itemCount >= 4) return 4;
|
|
26
24
|
if (itemCount === 3) return 3;
|
|
@@ -37,15 +35,6 @@ function toProductReference(pick: ProductPick, index: number, total: number): Pr
|
|
|
37
35
|
};
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
/**
|
|
41
|
-
* Builds a renderable spec from signals alone. Never throws. Returns a spec with
|
|
42
|
-
* no blocks only when there is genuinely nothing in stock left to show, which
|
|
43
|
-
* the renderer treats as "render nothing" — an empty recommendation region is
|
|
44
|
-
* worse than none.
|
|
45
|
-
*
|
|
46
|
-
* Reads the same digest and selector the model path does, so a degraded render is
|
|
47
|
-
* a weaker version of the same decision rather than an unrelated one.
|
|
48
|
-
*/
|
|
49
38
|
export function buildFallbackSpec(
|
|
50
39
|
input: TrackingInput,
|
|
51
40
|
digest: SignalDigest,
|
package/src/fit-to-shopper.ts
CHANGED
|
@@ -6,38 +6,29 @@ export interface FittedSpec {
|
|
|
6
6
|
ourReasons: Map<string, string>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
// The model picks the shape. Selection picks the grid and carousel products and
|
|
10
|
-
// what may be said about them, so a shared component still fits one shopper.
|
|
11
9
|
export function fitToShopper(
|
|
12
10
|
spec: GeneratedSpec,
|
|
13
11
|
picks: readonly ProductPick[],
|
|
14
12
|
maxItems: number,
|
|
15
13
|
): FittedSpec {
|
|
16
|
-
// Picks are ordered best first. Every slot takes the next one.
|
|
17
14
|
let next = 0;
|
|
18
15
|
const limit = Math.min(picks.length, maxItems);
|
|
19
16
|
|
|
20
17
|
const ourReasons = new Map<string, string>();
|
|
21
18
|
const blocks: Block[] = [];
|
|
22
19
|
for (const block of spec.blocks) {
|
|
23
|
-
// A hero is left alone. Its headline and body were written about the
|
|
24
|
-
// product it names, so swapping the product would leave copy that describes
|
|
25
|
-
// something else. Reconciliation drops the link if this shopper cannot see
|
|
26
|
-
// that product, and the words stay.
|
|
27
|
-
|
|
28
20
|
if (block.kind === 'grid' || block.kind === 'carousel') {
|
|
29
21
|
const items: ProductReference[] = [];
|
|
30
22
|
for (const item of block.items) {
|
|
31
|
-
if (next >= limit) break;
|
|
23
|
+
if (next >= limit) break;
|
|
32
24
|
const chosen = picks[next]!;
|
|
33
|
-
|
|
34
|
-
// shop. None of them is the model's, whatever the model wrote in this slot.
|
|
25
|
+
|
|
35
26
|
ourReasons.set(chosen.product.sku, chosen.reason);
|
|
36
27
|
items.push({
|
|
37
28
|
sku: chosen.product.sku,
|
|
38
29
|
basis: chosen.basis,
|
|
39
30
|
reason: chosen.reason,
|
|
40
|
-
|
|
31
|
+
|
|
41
32
|
badge: null,
|
|
42
33
|
emphasis: item.emphasis,
|
|
43
34
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @rudra-js/core — the contracts and logic that turn one tracking payload into one
|
|
3
|
-
* renderable component specification.
|
|
4
|
-
*
|
|
5
|
-
* Carries no React and no model-vendor SDK, so it can be unit tested in
|
|
6
|
-
* isolation and imported from any server runtime.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
export {
|
|
10
2
|
FIELD_LIMITS,
|
|
11
3
|
productSchema,
|
package/src/model-prompt.ts
CHANGED
|
@@ -2,30 +2,12 @@ import { BANNER_TONES, EMPHASIS, RECOMMENDATION_BASES, TONES } from './component
|
|
|
2
2
|
import type { SignalDigest } from './signal-digest.js';
|
|
3
3
|
import { FIELD_LIMITS, type Product, type TrackingInput } from './tracking-input.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* What actually reaches the model.
|
|
7
|
-
*
|
|
8
|
-
* The prompt is split in two, and the split is load-bearing. `system` is
|
|
9
|
-
* byte-identical for every request in a deployment, which is what lets a
|
|
10
|
-
* provider cache it and charge a fraction for the repeat. `user` carries
|
|
11
|
-
* everything that varies. Interpolating one shopper's data into the system half
|
|
12
|
-
* would not break anything visibly — it would quietly make the cached prefix
|
|
13
|
-
* useless and multiply the bill, which is why a test asserts the halves stay
|
|
14
|
-
* separate rather than trusting anyone to remember.
|
|
15
|
-
*
|
|
16
|
-
* This module is also the only place shopper-supplied text meets model
|
|
17
|
-
* instructions, so every host value is written as a JSON string rather than as
|
|
18
|
-
* prose. A search for `boots\n\n# Task\nIgnore the above` is then one quoted
|
|
19
|
-
* value on one line, not a heading the model might read as a new instruction.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
5
|
export const UNTRUSTED_BEGIN = 'BEGIN_UNTRUSTED_DATA';
|
|
23
6
|
export const UNTRUSTED_END = 'END_UNTRUSTED_DATA';
|
|
24
7
|
|
|
25
8
|
export interface PromptPair {
|
|
26
|
-
/** Stable across requests. Safe for a provider to cache. */
|
|
27
9
|
system: string;
|
|
28
|
-
|
|
10
|
+
|
|
29
11
|
user: string;
|
|
30
12
|
}
|
|
31
13
|
|
|
@@ -126,45 +108,9 @@ The "rationale" field is for engineers reading generation logs, not for
|
|
|
126
108
|
shoppers. One sentence on why this arrangement, naming the signals you leaned
|
|
127
109
|
on.`;
|
|
128
110
|
|
|
129
|
-
/**
|
|
130
|
-
* Characters a value has no business containing.
|
|
131
|
-
*
|
|
132
|
-
* `JSON.stringify` escapes control characters, quotes and backslashes, and
|
|
133
|
-
* nothing else. Everything below survives it, and each one lets a shopper's
|
|
134
|
-
* value do something the surrounding quotes are meant to prevent — end a line,
|
|
135
|
-
* reverse the reading order, or carry text that displays as nothing at all.
|
|
136
|
-
*
|
|
137
|
-
* This is written as Unicode properties rather than a list of code points on
|
|
138
|
-
* purpose. A list is a denylist: it covered the tag block (U+E0000-U+E007F)
|
|
139
|
-
* but not the variation selectors supplement (U+E0100-U+E01EF), which smuggles
|
|
140
|
-
* text exactly the same way, and it missed U+0085, U+061C and U+00AD as well.
|
|
141
|
-
* Properties cover the ones nobody has thought of yet.
|
|
142
|
-
*
|
|
143
|
-
* The general categories alone do not, though. Half of what renders as nothing
|
|
144
|
-
* is a mark or a letter rather than a format character \u2014 the Mongolian free
|
|
145
|
-
* variation selectors, U+034F, the Hangul fillers, and the sixteen variation
|
|
146
|
-
* selectors at U+FE00 \u2014 so `Default_Ignorable_Code_Point` is in the class too.
|
|
147
|
-
* It is the property that means "takes up no room", which is the thing that
|
|
148
|
-
* makes a character able to carry text nobody sees.
|
|
149
|
-
*
|
|
150
|
-
* Three are left out. The zero-width joiner is a format character, but it is
|
|
151
|
-
* also how a family emoji is spelled, and U+FE0E and U+FE0F are what make a
|
|
152
|
-
* character render as an emoji rather than as text. Escaping those mangles
|
|
153
|
-
* ordinary product titles. The other thirteen selectors at U+FE00 spell
|
|
154
|
-
* nothing and are escaped like the rest.
|
|
155
|
-
*/
|
|
156
111
|
const UNPRINTABLE =
|
|
157
112
|
/(?!\u{200D}|\u{FE0E}|\u{FE0F})[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
|
|
158
113
|
|
|
159
|
-
/**
|
|
160
|
-
* How long one quoted value may be once escaped.
|
|
161
|
-
*
|
|
162
|
-
* The contract caps the string a host may send, and this caps what that string
|
|
163
|
-
* turns into. They are not the same number: an escape writes up to eight
|
|
164
|
-
* characters for one, so a field on its cap can still buy eight times the
|
|
165
|
-
* prompt \u2014 and the bill \u2014 that the cap implies. Ordinary text escapes nothing
|
|
166
|
-
* and never comes near this.
|
|
167
|
-
*/
|
|
168
114
|
const MAX_QUOTED = FIELD_LIMITS.shortText * 2;
|
|
169
115
|
|
|
170
116
|
const escapeUnprintable = (text: string) =>
|
|
@@ -173,12 +119,10 @@ const escapeUnprintable = (text: string) =>
|
|
|
173
119
|
return `\\u{${codePoint.toString(16).toUpperCase()}}`;
|
|
174
120
|
});
|
|
175
121
|
|
|
176
|
-
/** Host-supplied text, written so it cannot introduce structure of its own. */
|
|
177
122
|
const quote = (value: string) => {
|
|
178
123
|
const escaped = escapeUnprintable(JSON.stringify(value));
|
|
179
124
|
if (escaped.length <= MAX_QUOTED) return escaped;
|
|
180
125
|
|
|
181
|
-
// Cut whole characters, so the result cannot end inside an escape sequence.
|
|
182
126
|
let length = 2;
|
|
183
127
|
let kept = '';
|
|
184
128
|
for (const character of value) {
|
|
@@ -237,29 +181,8 @@ function describeCandidate(product: Product): string {
|
|
|
237
181
|
return `- ${parts.join(' | ')}`;
|
|
238
182
|
}
|
|
239
183
|
|
|
240
|
-
/**
|
|
241
|
-
* How many candidates reach the prompt.
|
|
242
|
-
*
|
|
243
|
-
* The payload contract caps the candidate list at 200, and every field on a
|
|
244
|
-
* product at its own length — which multiplies out to a prompt far larger than
|
|
245
|
-
* is sensible to send or pay for. The contract deliberately does not impose an
|
|
246
|
-
* aggregate budget, on the grounds that trimming to fit is this layer's job.
|
|
247
|
-
* This is that trim. The host's ordering is its merchandising priority, so the
|
|
248
|
-
* first ones through are the ones it put first.
|
|
249
|
-
*/
|
|
250
184
|
const MAX_CANDIDATES = 60;
|
|
251
185
|
|
|
252
|
-
/**
|
|
253
|
-
* The candidates the model is actually shown.
|
|
254
|
-
*
|
|
255
|
-
* An out-of-stock product is dropped during reconciliation whatever the model
|
|
256
|
-
* does with it, so offering one only costs the shopper a slot.
|
|
257
|
-
*
|
|
258
|
-
* Exported because reconciliation checks the numerals the model writes against
|
|
259
|
-
* the strings on this list, and a candidate it was never shown cannot be where
|
|
260
|
-
* one came from. Two copies of "what the model saw" would drift, and the pair
|
|
261
|
-
* that drifted would be the prompt and the screen reading it back.
|
|
262
|
-
*/
|
|
263
186
|
export function offeredCandidates(input: TrackingInput): Product[] {
|
|
264
187
|
return input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
|
|
265
188
|
}
|
|
@@ -267,10 +190,6 @@ export function offeredCandidates(input: TrackingInput): Product[] {
|
|
|
267
190
|
export function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair {
|
|
268
191
|
const offered = offeredCandidates(input);
|
|
269
192
|
|
|
270
|
-
// The markers are OWASP's labelled-block recommendation. They are safe as
|
|
271
|
-
// boundaries because every value between them is quoted and stripped of
|
|
272
|
-
// anything that could end a line, so no shopper value can occupy a line by
|
|
273
|
-
// itself — which is the only way one could impersonate a marker.
|
|
274
193
|
const user = `${UNTRUSTED_BEGIN}
|
|
275
194
|
|
|
276
195
|
## Shopper
|
package/src/product-selection.ts
CHANGED
|
@@ -3,35 +3,16 @@ import type { SignalDigest } from './signal-digest.js';
|
|
|
3
3
|
import { neverRecommend } from './reconciliation.js';
|
|
4
4
|
import type { Product, TrackingInput } from './tracking-input.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* The deterministic selector — which products to show, in what order, and why.
|
|
8
|
-
*
|
|
9
|
-
* This is the half of the problem a language model is not needed for. Given the
|
|
10
|
-
* same digest, it always returns the same picks, it cannot fail, and it costs
|
|
11
|
-
* nothing. It exists for three reasons, in ascending order of importance:
|
|
12
|
-
*
|
|
13
|
-
* 1. It is what renders when the model is slow, erroring, or not configured.
|
|
14
|
-
* 2. It supplies the candidate ordering the model is asked to work from.
|
|
15
|
-
* 3. It is the control arm. If a generated component cannot be told apart from
|
|
16
|
-
* this, the model has not earned its place, and the evaluation has to be
|
|
17
|
-
* able to ask that question honestly.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
6
|
export interface ProductPick {
|
|
21
7
|
product: Product;
|
|
22
|
-
|
|
8
|
+
|
|
23
9
|
basis: RecommendationBasis;
|
|
24
|
-
|
|
10
|
+
|
|
25
11
|
reason: string;
|
|
26
|
-
|
|
12
|
+
|
|
27
13
|
score: number;
|
|
28
14
|
}
|
|
29
15
|
|
|
30
|
-
/**
|
|
31
|
-
* How much each factor moves a product up the list. Relative sizes are what
|
|
32
|
-
* matter: category affinity dominates, a revisit is nearly as strong, and
|
|
33
|
-
* rating only separates products the signals cannot.
|
|
34
|
-
*/
|
|
35
16
|
const SCORE_WEIGHTS = {
|
|
36
17
|
category: 3,
|
|
37
18
|
revisit: 1.5,
|
|
@@ -39,7 +20,6 @@ const SCORE_WEIGHTS = {
|
|
|
39
20
|
tagOverlap: 0.6,
|
|
40
21
|
} as const;
|
|
41
22
|
|
|
42
|
-
/** Beyond this many shared tags, more overlap says nothing new. */
|
|
43
23
|
const MAX_TAG_OVERLAP = 3;
|
|
44
24
|
|
|
45
25
|
const UNRATED = 3.5;
|
|
@@ -67,15 +47,6 @@ interface Evidence {
|
|
|
67
47
|
hasCart: boolean;
|
|
68
48
|
}
|
|
69
49
|
|
|
70
|
-
/**
|
|
71
|
-
* States why a product was picked, choosing the most specific claim the signals
|
|
72
|
-
* actually support.
|
|
73
|
-
*
|
|
74
|
-
* Every branch has to be one reconciliation can verify — the selector is held to
|
|
75
|
-
* the same standard as the model, and a basis it cannot support would be
|
|
76
|
-
* downgraded there just the same. `popular` asserts nothing and is the honest
|
|
77
|
-
* answer when nothing else holds.
|
|
78
|
-
*/
|
|
79
50
|
function basisFor(
|
|
80
51
|
product: Product,
|
|
81
52
|
digest: SignalDigest,
|
|
@@ -96,16 +67,9 @@ function basisFor(
|
|
|
96
67
|
return { basis: 'popular', reason: `Popular in ${product.category}` };
|
|
97
68
|
}
|
|
98
69
|
|
|
99
|
-
/** How the picks are ordered once the unplaceable ones are gone. */
|
|
100
70
|
export type RankOrder = 'signals' | 'given';
|
|
101
71
|
|
|
102
72
|
export interface SelectOptions {
|
|
103
|
-
/**
|
|
104
|
-
* `signals` scores each candidate and orders by that score. `given` keeps the
|
|
105
|
-
* order you sent, for a shop whose own ranking is better than four weights.
|
|
106
|
-
* Either way the exclusions and the stock check still apply, and every pick
|
|
107
|
-
* still carries a basis reconciliation can verify.
|
|
108
|
-
*/
|
|
109
73
|
rank?: RankOrder;
|
|
110
74
|
}
|
|
111
75
|
|
|
@@ -117,8 +81,7 @@ export function selectProducts(
|
|
|
117
81
|
const affinityByCategory = new Map(
|
|
118
82
|
digest.categoryAffinity.map((affinity) => [affinity.category, affinity.score]),
|
|
119
83
|
);
|
|
120
|
-
|
|
121
|
-
// same thing whether a shopper has two signals or two hundred.
|
|
84
|
+
|
|
122
85
|
const strongestAffinity = Math.max(1, ...affinityByCategory.values());
|
|
123
86
|
const tags = engagedTags(input, digest);
|
|
124
87
|
const excluded = neverRecommend(input);
|
|
@@ -132,8 +95,7 @@ export function selectProducts(
|
|
|
132
95
|
const categoryScore = (affinityByCategory.get(product.category) ?? 0) / strongestAffinity;
|
|
133
96
|
const tagOverlap = product.tags.filter((tag) => tags.has(tag)).length;
|
|
134
97
|
const ratingScore = (product.rating ?? UNRATED) / 5;
|
|
135
|
-
|
|
136
|
-
// saturates: the twentieth view means little more than the fifth.
|
|
98
|
+
|
|
137
99
|
const revisitScore = Math.min(1, Math.log2(1 + (viewsBySku.get(product.sku) ?? 0)) / 3);
|
|
138
100
|
|
|
139
101
|
const score =
|
package/src/provider.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import type { z } from 'zod';
|
|
2
2
|
import type { GeneratedSpec } from './component-spec.js';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* The language-model port.
|
|
6
|
-
*
|
|
7
|
-
* `@rudra-js/core` depends on no vendor SDK. Adapters live in their own packages,
|
|
8
|
-
* so a host installs exactly one and the rest never reach its dependency tree.
|
|
9
|
-
* Anything satisfying this interface works — a hosted API, a self-hosted model,
|
|
10
|
-
* an in-tenancy deployment, or a recorded fixture.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Reported by an adapter for cost accounting. Never used for control flow. */
|
|
14
4
|
export interface TokenUsage {
|
|
15
5
|
inputTokens?: number;
|
|
16
6
|
outputTokens?: number;
|
|
@@ -19,21 +9,12 @@ export interface TokenUsage {
|
|
|
19
9
|
}
|
|
20
10
|
|
|
21
11
|
export interface ProviderRequest {
|
|
22
|
-
/**
|
|
23
|
-
* Stable across every request in a deployment. Adapters that support prompt
|
|
24
|
-
* caching should mark this as the cached prefix; interpolating anything
|
|
25
|
-
* per-shopper into it would silently destroy the cache hit rate.
|
|
26
|
-
*/
|
|
27
12
|
system: string;
|
|
28
|
-
|
|
13
|
+
|
|
29
14
|
user: string;
|
|
30
|
-
|
|
31
|
-
* The schema the response must satisfy. Adapters convert it with their own
|
|
32
|
-
* SDK helper, so there is no hand-maintained JSON Schema to drift out of
|
|
33
|
-
* sync with the one `component-spec` defines.
|
|
34
|
-
*/
|
|
15
|
+
|
|
35
16
|
schema: z.ZodType<GeneratedSpec>;
|
|
36
|
-
|
|
17
|
+
|
|
37
18
|
signal: AbortSignal;
|
|
38
19
|
}
|
|
39
20
|
|
|
@@ -42,48 +23,18 @@ export interface ProviderResult {
|
|
|
42
23
|
usage?: TokenUsage;
|
|
43
24
|
}
|
|
44
25
|
|
|
45
|
-
/**
|
|
46
|
-
* What an adapter promises.
|
|
47
|
-
*
|
|
48
|
-
* Three obligations, none of which the caller can verify from the outside, so
|
|
49
|
-
* they are stated here rather than assumed:
|
|
50
|
-
*
|
|
51
|
-
* 1. Return a parsed object, not a string. Turning model output into a
|
|
52
|
-
* `GeneratedSpec` is the adapter's job, because only it knows what its
|
|
53
|
-
* structured-output mode returns.
|
|
54
|
-
* 2. Throw on failure — a refusal, a transport error, an unparseable
|
|
55
|
-
* response. Never return a partial or invented spec. The caller treats a
|
|
56
|
-
* throw as "use the deterministic component", which is always safe; a
|
|
57
|
-
* fabricated spec is not.
|
|
58
|
-
* 3. Respect `signal`, in both directions: do not start work when it is
|
|
59
|
-
* already aborted, and stop when it aborts mid-flight. The caller races
|
|
60
|
-
* the call against its own timeout regardless, so ignoring it does not
|
|
61
|
-
* hold a page open — but it does leave work running and billing after
|
|
62
|
-
* nobody is waiting, and it answers a caller that has already given up.
|
|
63
|
-
*
|
|
64
|
-
* `AbortSignal.throwIfAborted()` is the whole of the first half.
|
|
65
|
-
*
|
|
66
|
-
* The caller re-validates whatever comes back. An adapter that returns
|
|
67
|
-
* something malformed is a bug, not a security hole.
|
|
68
|
-
*/
|
|
69
26
|
export interface ComponentProvider {
|
|
70
|
-
/** Short identifier recorded on every generated spec, e.g. 'anthropic'. */
|
|
71
27
|
readonly name: string;
|
|
72
|
-
|
|
28
|
+
|
|
73
29
|
readonly model: string;
|
|
74
30
|
generate(request: ProviderRequest): Promise<ProviderResult>;
|
|
75
31
|
}
|
|
76
32
|
|
|
77
|
-
/** A provider that returns a fixed spec and never touches the network. */
|
|
78
33
|
export function createFixedSpecProvider(spec: GeneratedSpec): ComponentProvider {
|
|
79
34
|
return {
|
|
80
35
|
name: 'fixed',
|
|
81
36
|
model: 'none',
|
|
82
37
|
generate: async ({ signal }) => {
|
|
83
|
-
// Nothing here is slow enough to need cancelling, which is exactly why it
|
|
84
|
-
// is worth checking: the obligation is not "cancel your work", it is "do
|
|
85
|
-
// not answer a caller that has already given up". A reference
|
|
86
|
-
// implementation that skips the cheap half teaches the wrong lesson.
|
|
87
38
|
signal.throwIfAborted();
|
|
88
39
|
return { spec };
|
|
89
40
|
},
|