@rudra-js/core 0.4.0 → 0.6.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 +139 -15
- package/dist/claim-screening.d.ts +33 -0
- package/dist/claim-screening.d.ts.map +1 -0
- package/dist/claim-screening.js +226 -0
- package/dist/claim-screening.js.map +1 -0
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +33 -98
- package/dist/component-generator.js.map +1 -1
- package/dist/component-spec.d.ts +2 -49
- package/dist/component-spec.d.ts.map +1 -1
- package/dist/component-spec.js +3 -17
- package/dist/component-spec.js.map +1 -1
- package/dist/fallback-component.d.ts +3 -0
- package/dist/fallback-component.d.ts.map +1 -1
- package/dist/fallback-component.js +3 -13
- 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 +6 -4
- package/dist/fit-to-shopper.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model-prompt.d.ts +13 -1
- package/dist/model-prompt.d.ts.map +1 -1
- package/dist/model-prompt.js +53 -21
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +1 -9
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +1 -4
- package/dist/product-selection.js.map +1 -1
- package/dist/provider.d.ts +1 -8
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +1 -8
- package/dist/provider.js.map +1 -1
- package/dist/reconciliation.d.ts +15 -12
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +150 -340
- package/dist/reconciliation.js.map +1 -1
- package/dist/signal-digest.d.ts +10 -0
- package/dist/signal-digest.d.ts.map +1 -1
- package/dist/signal-digest.js +38 -28
- package/dist/signal-digest.js.map +1 -1
- package/dist/spec-cache.d.ts +14 -2
- package/dist/spec-cache.d.ts.map +1 -1
- package/dist/spec-cache.js +14 -24
- package/dist/spec-cache.js.map +1 -1
- package/dist/tracking-input.d.ts.map +1 -1
- package/dist/tracking-input.js +15 -13
- package/dist/tracking-input.js.map +1 -1
- package/package.json +4 -2
- package/src/claim-screening.ts +242 -0
- package/src/component-generator.ts +38 -123
- package/src/component-spec.ts +5 -18
- package/src/fallback-component.ts +3 -14
- package/src/fit-to-shopper.ts +11 -4
- package/src/index.ts +1 -0
- package/src/model-prompt.ts +58 -23
- package/src/product-selection.ts +2 -13
- package/src/provider.ts +1 -8
- package/src/reconciliation.ts +178 -359
- package/src/signal-digest.ts +41 -31
- package/src/spec-cache.ts +27 -24
- package/src/tracking-input.ts +16 -13
package/src/fit-to-shopper.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { Block, GeneratedSpec, ProductReference } from './component-spec.js';
|
|
2
2
|
import type { ProductPick } from './product-selection.js';
|
|
3
3
|
|
|
4
|
+
export interface FittedSpec {
|
|
5
|
+
spec: GeneratedSpec;
|
|
6
|
+
ourReasons: Map<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
4
9
|
// The model picks the shape. Selection picks the grid and carousel products and
|
|
5
10
|
// what may be said about them, so a shared component still fits one shopper.
|
|
6
11
|
export function fitToShopper(
|
|
7
12
|
spec: GeneratedSpec,
|
|
8
13
|
picks: readonly ProductPick[],
|
|
9
14
|
maxItems: number,
|
|
10
|
-
|
|
11
|
-
): GeneratedSpec {
|
|
15
|
+
): FittedSpec {
|
|
12
16
|
// Picks are ordered best first. Every slot takes the next one.
|
|
13
17
|
let next = 0;
|
|
14
18
|
const limit = Math.min(picks.length, maxItems);
|
|
15
19
|
|
|
20
|
+
const ourReasons = new Map<string, string>();
|
|
16
21
|
const blocks: Block[] = [];
|
|
17
22
|
for (const block of spec.blocks) {
|
|
18
23
|
// A hero is left alone. Its headline and body were written about the
|
|
@@ -25,7 +30,9 @@ export function fitToShopper(
|
|
|
25
30
|
for (const item of block.items) {
|
|
26
31
|
if (next >= limit) break; // shrink, never pad
|
|
27
32
|
const chosen = picks[next]!;
|
|
28
|
-
|
|
33
|
+
// Every reason here is written by this library or handed to it by the
|
|
34
|
+
// shop. None of them is the model's, whatever the model wrote in this slot.
|
|
35
|
+
ourReasons.set(chosen.product.sku, chosen.reason);
|
|
29
36
|
items.push({
|
|
30
37
|
sku: chosen.product.sku,
|
|
31
38
|
basis: chosen.basis,
|
|
@@ -43,5 +50,5 @@ export function fitToShopper(
|
|
|
43
50
|
blocks.push(block);
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
return { ...spec, blocks };
|
|
53
|
+
return { spec: { ...spec, blocks }, ourReasons };
|
|
47
54
|
}
|
package/src/index.ts
CHANGED
package/src/model-prompt.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BANNER_TONES, EMPHASIS, RECOMMENDATION_BASES, TONES } from './component-spec.js';
|
|
2
2
|
import type { SignalDigest } from './signal-digest.js';
|
|
3
|
-
import type
|
|
3
|
+
import { FIELD_LIMITS, type Product, type TrackingInput } from './tracking-input.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What actually reaches the model.
|
|
@@ -140,34 +140,57 @@ on.`;
|
|
|
140
140
|
* text exactly the same way, and it missed U+0085, U+061C and U+00AD as well.
|
|
141
141
|
* Properties cover the ones nobody has thought of yet.
|
|
142
142
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* caught by Cn, and is invisible.
|
|
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.
|
|
150
149
|
*
|
|
151
|
-
* The zero-width joiner is
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
155
|
*/
|
|
156
|
-
const UNPRINTABLE =
|
|
156
|
+
const UNPRINTABLE =
|
|
157
|
+
/(?!\u{200D}|\u{FE0E}|\u{FE0F})[\p{Cc}\p{Cf}\p{Cn}\p{Co}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/gu;
|
|
157
158
|
|
|
158
159
|
/**
|
|
159
|
-
*
|
|
160
|
+
* How long one quoted value may be once escaped.
|
|
160
161
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* one
|
|
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.
|
|
164
167
|
*/
|
|
165
|
-
const
|
|
166
|
-
|
|
168
|
+
const MAX_QUOTED = FIELD_LIMITS.shortText * 2;
|
|
169
|
+
|
|
170
|
+
const escapeUnprintable = (text: string) =>
|
|
171
|
+
text.replace(UNPRINTABLE, (character) => {
|
|
167
172
|
const codePoint = character.codePointAt(0)!;
|
|
168
173
|
return `\\u{${codePoint.toString(16).toUpperCase()}}`;
|
|
169
174
|
});
|
|
170
175
|
|
|
176
|
+
/** Host-supplied text, written so it cannot introduce structure of its own. */
|
|
177
|
+
const quote = (value: string) => {
|
|
178
|
+
const escaped = escapeUnprintable(JSON.stringify(value));
|
|
179
|
+
if (escaped.length <= MAX_QUOTED) return escaped;
|
|
180
|
+
|
|
181
|
+
// Cut whole characters, so the result cannot end inside an escape sequence.
|
|
182
|
+
let length = 2;
|
|
183
|
+
let kept = '';
|
|
184
|
+
for (const character of value) {
|
|
185
|
+
const cost = escapeUnprintable(JSON.stringify(character)).length - 2;
|
|
186
|
+
if (length + cost > MAX_QUOTED) break;
|
|
187
|
+
length += cost;
|
|
188
|
+
kept += character;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return escapeUnprintable(JSON.stringify(kept));
|
|
192
|
+
};
|
|
193
|
+
|
|
171
194
|
function section(heading: string, body: string | undefined): string | null {
|
|
172
195
|
if (!body || body.length === 0) return null;
|
|
173
196
|
return `${heading}: ${body}`;
|
|
@@ -207,7 +230,6 @@ function describeShopper(digest: SignalDigest): string {
|
|
|
207
230
|
return lines.filter((line): line is string => line !== null).join('\n');
|
|
208
231
|
}
|
|
209
232
|
|
|
210
|
-
/** One candidate per line. Facts the model must not restate are left out. */
|
|
211
233
|
function describeCandidate(product: Product): string {
|
|
212
234
|
const parts = [quote(product.sku), quote(product.title), quote(product.category)];
|
|
213
235
|
if (product.rating !== undefined) parts.push(`rated ${product.rating}`);
|
|
@@ -227,10 +249,23 @@ function describeCandidate(product: Product): string {
|
|
|
227
249
|
*/
|
|
228
250
|
const MAX_CANDIDATES = 60;
|
|
229
251
|
|
|
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
|
+
export function offeredCandidates(input: TrackingInput): Product[] {
|
|
264
|
+
return input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
|
|
265
|
+
}
|
|
266
|
+
|
|
230
267
|
export function buildPrompt(input: TrackingInput, digest: SignalDigest): PromptPair {
|
|
231
|
-
|
|
232
|
-
// does with it, so offering one only costs the shopper a slot.
|
|
233
|
-
const offered = input.candidates.filter((product) => product.isInStock).slice(0, MAX_CANDIDATES);
|
|
268
|
+
const offered = offeredCandidates(input);
|
|
234
269
|
|
|
235
270
|
// The markers are OWASP's labelled-block recommendation. They are safe as
|
|
236
271
|
// boundaries because every value between them is quoted and stripped of
|
package/src/product-selection.ts
CHANGED
|
@@ -21,10 +21,8 @@ export interface ProductPick {
|
|
|
21
21
|
product: Product;
|
|
22
22
|
/** Why this product, stated so reconciliation can check it. */
|
|
23
23
|
basis: RecommendationBasis;
|
|
24
|
-
/** How the basis reads to a shopper. */
|
|
24
|
+
/** How the basis reads to a shopper. The host's own sentence when it wrote one. */
|
|
25
25
|
reason: string;
|
|
26
|
-
/** True when `reason` is the host's own sentence from the candidate. */
|
|
27
|
-
reasonFromHost: boolean;
|
|
28
26
|
/** Unnormalised. Only the ordering is meaningful. */
|
|
29
27
|
score: number;
|
|
30
28
|
}
|
|
@@ -44,10 +42,8 @@ const SCORE_WEIGHTS = {
|
|
|
44
42
|
/** Beyond this many shared tags, more overlap says nothing new. */
|
|
45
43
|
const MAX_TAG_OVERLAP = 3;
|
|
46
44
|
|
|
47
|
-
/** Assumed rating for a product the catalog does not rate. */
|
|
48
45
|
const UNRATED = 3.5;
|
|
49
46
|
|
|
50
|
-
/** Tags on the products this shopper has actually engaged with. */
|
|
51
47
|
function engagedTags(input: TrackingInput, digest: SignalDigest): Set<string> {
|
|
52
48
|
const engagedSkus = new Set([
|
|
53
49
|
...digest.likedSkus,
|
|
@@ -100,12 +96,6 @@ function basisFor(
|
|
|
100
96
|
return { basis: 'popular', reason: `Popular in ${product.category}` };
|
|
101
97
|
}
|
|
102
98
|
|
|
103
|
-
/**
|
|
104
|
-
* Scores every eligible candidate and returns them best first.
|
|
105
|
-
*
|
|
106
|
-
* Ties break on SKU so the order is total: two runs over the same payload
|
|
107
|
-
* produce the same list, which is what makes the control arm reproducible.
|
|
108
|
-
*/
|
|
109
99
|
/** How the picks are ordered once the unplaceable ones are gone. */
|
|
110
100
|
export type RankOrder = 'signals' | 'given';
|
|
111
101
|
|
|
@@ -131,7 +121,7 @@ export function selectProducts(
|
|
|
131
121
|
// same thing whether a shopper has two signals or two hundred.
|
|
132
122
|
const strongestAffinity = Math.max(1, ...affinityByCategory.values());
|
|
133
123
|
const tags = engagedTags(input, digest);
|
|
134
|
-
const excluded = neverRecommend(
|
|
124
|
+
const excluded = neverRecommend(input);
|
|
135
125
|
const viewsBySku = new Map(digest.topViewed.map((viewed) => [viewed.sku, viewed.views]));
|
|
136
126
|
|
|
137
127
|
const picks: ProductPick[] = [];
|
|
@@ -164,7 +154,6 @@ export function selectProducts(
|
|
|
164
154
|
product,
|
|
165
155
|
basis,
|
|
166
156
|
reason: product.reason ?? reason,
|
|
167
|
-
reasonFromHost: product.reason !== undefined,
|
|
168
157
|
score,
|
|
169
158
|
});
|
|
170
159
|
}
|
package/src/provider.ts
CHANGED
|
@@ -74,14 +74,7 @@ export interface ComponentProvider {
|
|
|
74
74
|
generate(request: ProviderRequest): Promise<ProviderResult>;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* A provider that returns a fixed spec and never touches the network.
|
|
79
|
-
*
|
|
80
|
-
* This is the benchmark's control: it isolates the cost of the framework from
|
|
81
|
-
* the latency of a model, which is the only way to report what server-side
|
|
82
|
-
* rendering itself costs. It is also what the generator's tests run against,
|
|
83
|
-
* since a test that needs an API key is a test nobody runs.
|
|
84
|
-
*/
|
|
77
|
+
/** A provider that returns a fixed spec and never touches the network. */
|
|
85
78
|
export function createFixedSpecProvider(spec: GeneratedSpec): ComponentProvider {
|
|
86
79
|
return {
|
|
87
80
|
name: 'fixed',
|