@rudra-js/core 0.7.2 → 0.7.4

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 CHANGED
@@ -16,7 +16,7 @@ package _is_ zod schemas, and an app on any zod 4 shares one copy with it. An
16
16
  app on zod 3 gets its own copy alongside ours, and the two don't mix: build on
17
17
  the exported schemas with their own methods (`productSchema.array()`) rather
18
18
  than your zod's (`z.array(productSchema)`), and read `error.issues` rather than
19
- checking `instanceof ZodError`. Whichever copy core ends up with writes the tool
19
+ checking `instanceof ZodError`. `@rudra-js/anthropic`'s own zod writes the tool
20
20
  schema we send the model, so its wording shifts a little between releases:
21
21
  before 4.5, a nullable field is written as `anyOf` rather than a type array. It
22
22
  means the same thing either way.
@@ -176,15 +176,6 @@ Which strings, exactly:
176
176
  - A `reason` or a `badge` sits under a named product, so it's read against that
177
177
  product's own tags and category. Every other field reads all the candidates'
178
178
  pooled.
179
- - A tag or category that is nothing but a number in exponent notation, and whose
180
- exponent runs past a thousand, is dropped here. `1e2000000000` is twelve
181
- characters that lay out into a run long enough to end the process, and
182
- `@rudra-js/attested` is a peer dependency, so the copy you have installed may
183
- be one that still tries. `@rudra-js/attested` draws its own line in the same
184
- place but measures the laid-out run rather than the exponent, so a handful of
185
- tags near the margin — `1e1000`, `1.5e1000`, `9999e998` — get past this rule
186
- and then stand behind nothing on the other side of it. Nothing that wide is a
187
- product fact either way.
188
179
 
189
180
  Two caveats worth saying out loud, because they're the shape of the check rather
190
181
  than bugs in it. Pooled means pooled: one product's `40 litre` tag stands behind
@@ -246,13 +237,14 @@ list, and not an `Accept-Language` header.
246
237
 
247
238
  ### Cohorts
248
239
 
249
- By default one generated component is shared between shoppers who look alike, and
250
- each shopper's own products are filled in per request. A cohort is the shopper's
251
- segment, the surface and slot, the locale, the item count, whether they're a
252
- first-time visitor, the category being browsed, and the category they lean
253
- towards. Everything that makes a person an individual stays out of it: who they
254
- are, what they liked, viewed or searched for. That's what lets many page views
255
- reuse one call.
240
+ By default one generated component is shared between shoppers who look alike,
241
+ and each shopper's own products are filled in per request. A cohort is the
242
+ shopper's segment, the surface and slot, the locale, the item count, whether
243
+ they have any history, the category being browsed, and the category they lean
244
+ towards. Who they are, what they searched for and the products they liked or
245
+ viewed stay out of it. For the shared cohort we work out only two things from
246
+ their own data: the category they lean towards and whether they have any
247
+ history. That's what lets many page views reuse one call.
256
248
 
257
249
  The candidate list is part of the cohort too, since the model is shown those
258
250
  products and writes about them. In most shops candidates come from the page, so
@@ -405,7 +397,8 @@ The two generation modes send different things. Cohort is the default.
405
397
  - the category being browsed (`context.currentCategory`)
406
398
  - the name of the category the shopper leans towards most. Just the name, the
407
399
  score stays behind
408
- - whether this shopper has no history at all
400
+ - whether this shopper has no history at all, meaning no likes, dislikes,
401
+ purchases, basket items or views. Searches and other interactions don't count
409
402
  - how many products the component may place (`context.maxItems`)
410
403
  - the candidate list: one line per product, with its SKU, title, category, rating
411
404
  and tags
@@ -421,7 +414,7 @@ Everything above, and:
421
414
  - purchased SKUs, and what is in the basket
422
415
  - the most-viewed SKUs, each with its view count
423
416
  - recent searches
424
- - every category they lean towards, strongest first
417
+ - up to six categories they lean towards, strongest first
425
418
  - the other kinds of interaction, each with a count
426
419
 
427
420
  ### Left out of both
@@ -499,8 +492,14 @@ for `ttlMs`, 60,000 milliseconds by default, so one minute. It holds up to
499
492
  `maxEntries`, 10,000 by default. Once it's full, the entry read longest ago is
500
493
  the first to go.
501
494
 
502
- An entry holds the generated spec and `generatedAt`, the epoch milliseconds when
503
- the model produced it. That's the whole of it. No payload, no shopper, no prompt.
495
+ An entry holds the generated spec, as the model returned it before screening,
496
+ and `generatedAt`, the epoch milliseconds when the model produced it. No payload
497
+ and no prompt. In cohort mode it holds no shopper id and everyone in the cohort
498
+ shares it, unless its segment or candidates were picked for one shopper. In
499
+ per-shopper mode the model wrote it from that shopper's signals, so its text can
500
+ repeat them, a search term for instance, and the entry is personal data until it
501
+ goes. Expiry is checked when an entry is read, so an expired one stays in memory
502
+ until it's read again or pushed out by a newer one.
504
503
 
505
504
  The port is two methods, and an optional third:
506
505
 
@@ -542,7 +541,99 @@ These are the numbers worth keeping:
542
541
  `cache: 'timeout'`, rather than as a rising bill.
543
542
  - **Spend** — sum `usage` over the events where `calledModel` is true. Requests
544
543
  that joined an in-flight generation carry the same `usage`, so summing over
545
- every event counts one call many times.
544
+ every event counts one call many times. A call the model answered but the
545
+ adapter turned down (a `max_tokens` stop, a refusal, no tool call, or a reply
546
+ that doesn't fit the schema) was billed too, and arrives as a
547
+ `'provider-error'` with no `usage`, so this sum is lower than your bill.
548
+
549
+ ## Running it in a regulated shop
550
+
551
+ This isn't legal advice, and which rules apply depends on where you sell. It's
552
+ what the package does, so you can answer what a privacy or consumer-law review
553
+ will ask.
554
+
555
+ **What leaves your server.** Nothing, unless you pass a `provider`, or a `cache`
556
+ whose store lives somewhere else. With a provider, cohort mode sends the page
557
+ and locale, the category, the segment you set, the shopper's strongest category,
558
+ whether they have any history, and the candidate products. There's no shopper id
559
+ in it, but it can still be personal data: the strongest category and the history
560
+ flag come from that shopper's data, the segment goes exactly as you wrote it,
561
+ and the candidate list is about them too if you pick or order it per shopper.
562
+ Per-shopper mode adds the shopper's own signals. Both lists are under [What the
563
+ model sees](#what-the-model-sees). `user.id` is never sent in either mode. The
564
+ provider should be your processor, under a contract that says so; for Anthropic,
565
+ see the `@rudra-js/anthropic` README.
566
+
567
+ **A shopper who objects to profiling.** Leave `signals` and `user.segment` out
568
+ of their payload, and in per-shopper mode `context.searchQuery` and
569
+ `user.isReturning` too. They get the cold-start component, built from the page
570
+ and the catalog, not from them. With those left out, `generateDeterministic(input)`
571
+ gives the same kind of component without calling the model or the cache. Called
572
+ with signals, it still personalises.
573
+
574
+ **Deleting a shopper's data.** The package keeps nothing about a shopper except
575
+ the cache. In cohort mode an entry holds no shopper id and everyone in the
576
+ cohort shares it, so usually there's no one shopper's entry to delete. The
577
+ exception is a cohort entry whose segment or candidate list was picked for one
578
+ shopper: like every per-shopper entry, it's about that shopper. Nothing maps a
579
+ shopper to their entries, so keep `ttlMs` short or pass `createNullSpecCache()`.
580
+ `onEvent` sends no shopper id, but in per-shopper mode its `key` is derived from
581
+ one, and `violations` can name SKUs from a shopper's basket or purchases, so
582
+ treat logs of it as personal data.
583
+
584
+ **Caching pages.** A personalised page says things like "Goes with your cart" and
585
+ "You looked at this recently" in its HTML. Serve those pages with
586
+ `Cache-Control: private` or `no-store`, so a shared cache never hands one
587
+ shopper's page to another.
588
+
589
+ **Telling shoppers a model wrote the copy.** The wrapper's `data-rudra-source`
590
+ says `llm` or `cache` when a model wrote the copy and `fallback` when it didn't,
591
+ so it's a machine-readable marker you can build on, for example to show a note.
592
+ Whether you must label AI-written copy, and how, depends on where you sell; in
593
+ the EU, Article 50 of the AI Act is the rule to ask about.
594
+
595
+ **Claims.** The copy on your page is your own advertising, whoever wrote it. The
596
+ screen catches prices, discounts, stock, ratings, delivery, urgency and generic
597
+ environmental claims, but it's a list, and rewordings get through (SECURITY.md
598
+ explains why, and the tests list the known ones). Anything it misses is still
599
+ yours to stand behind.
600
+
601
+ **Children.** In the UK, the ICO's Children's Code covers any shop that
602
+ under-18s are more likely than not to use, not only one aimed at them. Its
603
+ standard 12 asks for profiling to be off by default unless you can show a
604
+ compelling reason, judged by the child's best interests, and for safeguards when
605
+ it's on. For a shop like that, don't use per-shopper mode. For a shopper under
606
+ 18, leave `signals` and `user.segment` out of their payload unless they've
607
+ switched personalisation on, as for a shopper who objects to profiling. If you
608
+ can't tell a shopper's age with confidence that fits the risk, treat every
609
+ shopper as under 18. Cohort mode alone doesn't turn profiling off: the default
610
+ `rank: 'signals'` still orders products by the shopper's signals in cohort mode
611
+ and in the fallback component, even with no provider, and cohort mode still
612
+ sends their strongest category, whether they have any history, and any segment
613
+ to the model.
614
+
615
+ **Marketplaces.** If other sellers list products on your site, EU platform rules
616
+ (Article 27 of the Digital Services Act) ask you, unless you're a micro or small
617
+ business, to set out in your terms your recommender's main parameters, why they
618
+ weigh as they do, and any options shoppers have to change them. If shoppers can
619
+ pick between options, such as switching personalisation off, the control has to
620
+ sit where the products are shown. Which parameters apply depends on your mode
621
+ and your `rank` setting. `rank` sets the order in cohort mode and in the
622
+ fallback component. With `rank: 'given'` that's the order you sent, so describe
623
+ your own ranking. With `rank: 'signals'` it comes from the shopper's category
624
+ interest (the category they're browsing counts too), how often they've viewed a
625
+ product, its rating, and tags it shares with products they've engaged with.
626
+ Category weighs most. In per-shopper mode the model picks which candidates to
627
+ show and in what order, from what's under
628
+ [What the model sees](#what-the-model-sees). In cohort mode the model picks the
629
+ hero's product, from the cohort list there. In every mode, anything out of stock, disliked or
630
+ being looked at right now is left out, and so is anything bought or in the
631
+ basket, except as part of a bundle. A bundle is picked by what's in the basket,
632
+ then what they viewed, then the category being browsed.
633
+
634
+ **Accessibility.** Every card is one link with a readable name, and headings
635
+ only render when they have text. Contrast, focus styles and layout come from
636
+ your stylesheet, so those are yours to check.
546
637
 
547
638
  ## Licence
548
639
 
@@ -1 +1 @@
1
- {"version":3,"file":"claim-screening.d.ts","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AA4IlE,eAAO,MAAM,eAAe,UAAkE,CAAC;AAc/F,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAOvD;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CAC9B;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAWzD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQnD"}
1
+ {"version":3,"file":"claim-screening.d.ts","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAoKlE,eAAO,MAAM,eAAe,UAK3B,CAAC;AAIF,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAOvD;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;CAC9B;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,CAWzD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWnD"}
@@ -63,6 +63,7 @@ const CLAIM_PATTERNS = [
63
63
  /\blimited stock\b/,
64
64
  /\brestocked?\b|\bsold out\b/,
65
65
  /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
66
+ /\bonly\s+(?:two|three|four|five|six|seven|eight|nine|ten)\s+(?:left|remain(?:s|ing)?)\b/,
66
67
  /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
67
68
  ],
68
69
  },
@@ -96,40 +97,59 @@ const CONFUSABLES = {
96
97
  τ: 't',
97
98
  υ: 'u',
98
99
  χ: 'x',
100
+ ϳ: 'j',
101
+ ѵ: 'v',
102
+ };
103
+ // Capitals that lowercasing reads wrong: Ν becomes ν, which reads as v.
104
+ const CAPITALS = {
105
+ Β: 'b',
106
+ Ζ: 'z',
107
+ Η: 'h',
108
+ Μ: 'm',
109
+ Ν: 'n',
110
+ Υ: 'y',
111
+ Ү: 'y',
112
+ Ӏ: 'i',
113
+ Ԛ: 'q',
114
+ Ԝ: 'w',
115
+ Ϻ: 'm',
116
+ Ϝ: 'f',
117
+ Ԍ: 'g',
99
118
  };
100
119
  const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
101
120
  const SPACING = /[\t\n\v\f\r\u0085]/;
102
121
  const MARKS = /[\p{Mn}\p{Me}]/gu;
103
- function normaliseForClaims(text) {
122
+ function normaliseForClaims(text, byLook) {
104
123
  // NFKC would turn the lunate sigmas into ς and Σ, which the fold can't read as c.
105
- const lowered = text
124
+ const compatible = text
106
125
  .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
107
126
  .replace(/[ϲϹ]/g, 'c')
108
- .normalize('NFKC')
109
- .toLowerCase()
110
- .normalize('NFC')
111
- .replace(MARKS, '');
127
+ .normalize('NFKC');
128
+ let cased = compatible;
129
+ if (byLook) {
130
+ cased = '';
131
+ for (const char of compatible)
132
+ cased += CAPITALS[char] ?? char;
133
+ }
134
+ const lowered = cased.toLowerCase().normalize('NFC').replace(MARKS, '');
112
135
  let folded = '';
113
136
  for (const char of lowered)
114
137
  folded += CONFUSABLES[char] ?? char;
115
138
  return folded;
116
139
  }
117
- export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
140
+ export const ALLOWED_PHRASES = [
141
+ 'does not feel cheap',
142
+ "doesn't feel cheap",
143
+ 'last few miles',
144
+ 'sustainable pace',
145
+ ];
118
146
  const DIGIT = /\p{Nd}/u;
119
- const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
120
- const MAX_EXPONENT = 1000;
121
- function canLayOut(fact) {
122
- const match = BARE_EXPONENT.exec(fact.trim());
123
- if (match === null)
124
- return true;
125
- return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
126
- }
127
147
  export function productFacts(product) {
128
148
  const facts = [];
129
- if (DIGIT.test(product.category) && canLayOut(product.category))
149
+ if (DIGIT.test(product.category))
130
150
  facts.push(product.category);
131
151
  for (const tag of product.tags) {
132
- if (DIGIT.test(tag) && canLayOut(tag))
152
+ if (DIGIT.test(tag))
133
153
  facts.push(tag);
134
154
  }
135
155
  return facts;
@@ -146,11 +166,14 @@ export function hostFacts(input) {
146
166
  return { pooled: [...pooled], bySku };
147
167
  }
148
168
  export function claimIn(text) {
149
- const normalised = normaliseForClaims(text);
150
- for (const claim of CLAIM_PATTERNS) {
151
- for (const pattern of claim.patterns) {
152
- if (pattern.test(normalised))
153
- return claim.kind;
169
+ // Read twice, since Ν reads as N but ν as v.
170
+ for (const byLook of [false, true]) {
171
+ const normalised = normaliseForClaims(text, byLook);
172
+ for (const claim of CLAIM_PATTERNS) {
173
+ for (const pattern of claim.patterns) {
174
+ if (pattern.test(normalised))
175
+ return claim.kind;
176
+ }
154
177
  }
155
178
  }
156
179
  return null;
@@ -1 +1 @@
1
- {"version":3,"file":"claim-screening.js","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"claim-screening.js","sourceRoot":"","sources":["../src/claim-screening.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rudra-js/core",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "The contracts and logic that turn one tracking payload into one renderable component specification",
5
5
  "keywords": [
6
6
  "llm",
@@ -54,10 +54,10 @@
54
54
  "zod": "^4.0.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@rudra-js/attested": "^0.7.2"
57
+ "@rudra-js/attested": "^0.7.4"
58
58
  },
59
59
  "devDependencies": {
60
- "@rudra-js/attested": "0.7.2",
60
+ "@rudra-js/attested": "0.7.4",
61
61
  "@types/node": "^26.4.1"
62
62
  }
63
63
  }
@@ -82,6 +82,7 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
82
82
  /\brestocked?\b|\bsold out\b/,
83
83
 
84
84
  /\b(?:only\s+)?(?:\d+|a few|a handful|a couple|one|few)\s+(?:left|remain(?:s|ing)?)\b/,
85
+ /\bonly\s+(?:two|three|four|five|six|seven|eight|nine|ten)\s+(?:left|remain(?:s|ing)?)\b/,
85
86
  /\bselling fast\b|\b(?:almost|nearly) gone\b|\bwhile stocks last\b/,
86
87
  ],
87
88
  },
@@ -116,6 +117,25 @@ const CONFUSABLES: Record<string, string> = {
116
117
  τ: 't',
117
118
  υ: 'u',
118
119
  χ: 'x',
120
+ ϳ: 'j',
121
+ ѵ: 'v',
122
+ };
123
+
124
+ // Capitals that lowercasing reads wrong: Ν becomes ν, which reads as v.
125
+ const CAPITALS: Record<string, string> = {
126
+ Β: 'b',
127
+ Ζ: 'z',
128
+ Η: 'h',
129
+ Μ: 'm',
130
+ Ν: 'n',
131
+ Υ: 'y',
132
+ Ү: 'y',
133
+ Ӏ: 'i',
134
+ Ԛ: 'q',
135
+ Ԝ: 'w',
136
+ Ϻ: 'm',
137
+ Ϝ: 'f',
138
+ Ԍ: 'g',
119
139
  };
120
140
 
121
141
  const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
@@ -124,40 +144,39 @@ const SPACING = /[\t\n\v\f\r\u0085]/;
124
144
 
125
145
  const MARKS = /[\p{Mn}\p{Me}]/gu;
126
146
 
127
- function normaliseForClaims(text: string): string {
147
+ function normaliseForClaims(text: string, byLook: boolean): string {
128
148
  // NFKC would turn the lunate sigmas into ς and Σ, which the fold can't read as c.
129
- const lowered = text
149
+ const compatible = text
130
150
  .replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
131
151
  .replace(/[ϲϹ]/g, 'c')
132
- .normalize('NFKC')
133
- .toLowerCase()
134
- .normalize('NFC')
135
- .replace(MARKS, '');
152
+ .normalize('NFKC');
153
+
154
+ let cased = compatible;
155
+ if (byLook) {
156
+ cased = '';
157
+ for (const char of compatible) cased += CAPITALS[char] ?? char;
158
+ }
159
+ const lowered = cased.toLowerCase().normalize('NFC').replace(MARKS, '');
136
160
 
137
161
  let folded = '';
138
162
  for (const char of lowered) folded += CONFUSABLES[char] ?? char;
139
163
  return folded;
140
164
  }
141
165
 
142
- export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
166
+ export const ALLOWED_PHRASES = [
167
+ 'does not feel cheap',
168
+ "doesn't feel cheap",
169
+ 'last few miles',
170
+ 'sustainable pace',
171
+ ];
143
172
 
144
173
  const DIGIT = /\p{Nd}/u;
145
174
 
146
- const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
147
-
148
- const MAX_EXPONENT = 1000;
149
-
150
- function canLayOut(fact: string): boolean {
151
- const match = BARE_EXPONENT.exec(fact.trim());
152
- if (match === null) return true;
153
- return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
154
- }
155
-
156
175
  export function productFacts(product: Product): string[] {
157
176
  const facts: string[] = [];
158
- if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
177
+ if (DIGIT.test(product.category)) facts.push(product.category);
159
178
  for (const tag of product.tags) {
160
- if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
179
+ if (DIGIT.test(tag)) facts.push(tag);
161
180
  }
162
181
  return facts;
163
182
  }
@@ -181,10 +200,13 @@ export function hostFacts(input: TrackingInput): HostFacts {
181
200
  }
182
201
 
183
202
  export function claimIn(text: string): string | null {
184
- const normalised = normaliseForClaims(text);
185
- for (const claim of CLAIM_PATTERNS) {
186
- for (const pattern of claim.patterns) {
187
- if (pattern.test(normalised)) return claim.kind;
203
+ // Read twice, since Ν reads as N but ν as v.
204
+ for (const byLook of [false, true]) {
205
+ const normalised = normaliseForClaims(text, byLook);
206
+ for (const claim of CLAIM_PATTERNS) {
207
+ for (const pattern of claim.patterns) {
208
+ if (pattern.test(normalised)) return claim.kind;
209
+ }
188
210
  }
189
211
  }
190
212
  return null;