@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 +113 -22
- package/dist/claim-screening.d.ts.map +1 -1
- package/dist/claim-screening.js +45 -22
- package/dist/claim-screening.js.map +1 -1
- package/package.json +3 -3
- package/src/claim-screening.ts +45 -23
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`.
|
|
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,
|
|
250
|
-
each shopper's own products are filled in per request. A cohort is the
|
|
251
|
-
segment, the surface and slot, the locale, the item count, whether
|
|
252
|
-
|
|
253
|
-
towards.
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
-
|
|
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
|
|
503
|
-
the
|
|
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;
|
|
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"}
|
package/dist/claim-screening.js
CHANGED
|
@@ -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
|
|
124
|
+
const compatible = text
|
|
106
125
|
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
107
126
|
.replace(/[ϲϹ]/g, 'c')
|
|
108
|
-
.normalize('NFKC')
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 = [
|
|
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)
|
|
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)
|
|
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
|
-
|
|
150
|
-
for (const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAGtD,MAAM,cAAc,GAA2C;IAC7D;QACE,IAAI,EAAE,QAAQ;QACd,QAAQ,EAAE;YACR,2BAA2B;YAC3B,mDAAmD;YACnD,0BAA0B;YAE1B,kEAAkE;YAElE,+BAA+B;YAE/B,0CAA0C;YAC1C,yDAAyD;YAEzD,yEAAyE;YAEzE,kCAAkC;YAElC,6DAA6D;YAC7D,+CAA+C;SAChD;KACF;IACD;QACE,IAAI,EAAE,OAAO;QACb,QAAQ,EAAE;YACR,SAAS;YACT,6DAA6D;YAE7D,yBAAyB;YAEzB,wBAAwB;YAExB,qBAAqB;YACrB,yBAAyB;YAEzB,6DAA6D;YAE7D,qBAAqB;YACrB,4BAA4B;YAE5B,2DAA2D;YAC3D,yBAAyB;YAEzB,kDAAkD;SACnD;KACF;IACD;QACE,IAAI,EAAE,UAAU;QAChB,QAAQ,EAAE;YACR,qEAAqE;YACrE,iGAAiG;YACjG,uBAAuB;YACvB,sCAAsC;YACtC,6BAA6B;YAE7B,8BAA8B;YAE9B,4DAA4D;YAE5D,8GAA8G;SAC/G;KACF;IACD;QACE,IAAI,EAAE,UAAU;QAChB,QAAQ,EAAE;YACR,2BAA2B;YAC3B,iDAAiD;YACjD,kMAAkM;YAClM,qDAAqD;YACrD,iBAAiB;SAClB;KACF;IACD;QACE,IAAI,EAAE,OAAO;QACb,QAAQ,EAAE;YACR,gCAAgC;YAChC,mBAAmB;YACnB,6BAA6B;YAE7B,sFAAsF;YACtF,mEAAmE;SACpE;KACF;CACF,CAAC;AAEF,MAAM,WAAW,GAA2B;IAC1C,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;IACN,CAAC,EAAE,GAAG;CACP,CAAC;AAEF,MAAM,SAAS,GAAG,kDAAkD,CAAC;AAErE,MAAM,OAAO,GAAG,oBAAoB,CAAC;AAErC,MAAM,KAAK,GAAG,kBAAkB,CAAC;AAEjC,SAAS,kBAAkB,CAAC,IAAY;
|
|
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.
|
|
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.
|
|
57
|
+
"@rudra-js/attested": "^0.7.4"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@rudra-js/attested": "0.7.
|
|
60
|
+
"@rudra-js/attested": "0.7.4",
|
|
61
61
|
"@types/node": "^26.4.1"
|
|
62
62
|
}
|
|
63
63
|
}
|
package/src/claim-screening.ts
CHANGED
|
@@ -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
|
|
149
|
+
const compatible = text
|
|
130
150
|
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
131
151
|
.replace(/[ϲϹ]/g, 'c')
|
|
132
|
-
.normalize('NFKC')
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 = [
|
|
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)
|
|
177
|
+
if (DIGIT.test(product.category)) facts.push(product.category);
|
|
159
178
|
for (const tag of product.tags) {
|
|
160
|
-
if (DIGIT.test(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
|
-
|
|
185
|
-
for (const
|
|
186
|
-
|
|
187
|
-
|
|
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;
|