@rudra-js/core 0.4.0 → 0.5.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 +88 -11
- package/dist/component-generator.d.ts.map +1 -1
- package/dist/component-generator.js +12 -11
- package/dist/component-generator.js.map +1 -1
- package/dist/fit-to-shopper.d.ts +1 -1
- package/dist/fit-to-shopper.d.ts.map +1 -1
- package/dist/fit-to-shopper.js +4 -3
- package/dist/fit-to-shopper.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 +15 -3
- package/dist/model-prompt.js.map +1 -1
- package/dist/product-selection.d.ts +1 -3
- package/dist/product-selection.d.ts.map +1 -1
- package/dist/product-selection.js +0 -1
- package/dist/product-selection.js.map +1 -1
- package/dist/reconciliation.d.ts +19 -4
- package/dist/reconciliation.d.ts.map +1 -1
- package/dist/reconciliation.js +125 -32
- package/dist/reconciliation.js.map +1 -1
- package/package.json +3 -1
- package/src/component-generator.ts +12 -11
- package/src/fit-to-shopper.ts +4 -2
- package/src/model-prompt.ts +16 -3
- package/src/product-selection.ts +1 -4
- package/src/reconciliation.ts +145 -31
package/src/reconciliation.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { verify } from '@rudra-js/attested';
|
|
2
|
+
import { offeredCandidates } from './model-prompt.js';
|
|
1
3
|
import type {
|
|
2
4
|
Block,
|
|
3
5
|
GeneratedSpec,
|
|
@@ -152,14 +154,11 @@ function verifyBasis(basis: RecommendationBasis, product: Product, digest: Signa
|
|
|
152
154
|
* model writes is checked here — a heading is not a safer place for a claim
|
|
153
155
|
* than the small print under it.
|
|
154
156
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* the first — "20% off", not "20%"; "rated 4.8", not "rated". A careful
|
|
161
|
-
* rewording will get past this, and that is the trade we want: missing one
|
|
162
|
-
* claim is better than deleting honest copy on every page.
|
|
157
|
+
* These patterns are the first of three passes and the only one that names which
|
|
158
|
+
* of the five kinds a sentence claimed, so they read the domain: "20% off", not
|
|
159
|
+
* "20%"; "rated 4.8", not "rated". A specification walks past all of them and is
|
|
160
|
+
* then weighed by @rudra-js/attested, which reads no domain at all and asks only
|
|
161
|
+
* whether the shop supplied the number.
|
|
163
162
|
*
|
|
164
163
|
* One rule per line, because each line is a separate judgement about where the
|
|
165
164
|
* boundary sits and each one wants its own reason written next to it.
|
|
@@ -184,14 +183,20 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
184
183
|
/\brated\s+(?:[0-5]\.\d|(?:[0-5]|three|four|five)\s+(?:stars?|out of))\b/,
|
|
185
184
|
// How many other people bought or liked it is a customer claim too.
|
|
186
185
|
/\bbest[\s-]?sell(?:er|ers|ing)\b/,
|
|
186
|
+
// The words a model reaches for when "best" is banned. Each branch carries its own
|
|
187
|
+
// \b — one in front of the group demands a word character before the #, never there.
|
|
188
|
+
/(?:\bnumber one|\bno\.? ?1|#\s?1)[\s-]?sell(?:er|ers|ing)\b/,
|
|
187
189
|
/\bloved by (?:thousands|hundreds|millions|\d)/,
|
|
188
190
|
],
|
|
189
191
|
},
|
|
190
192
|
{
|
|
191
193
|
kind: 'price',
|
|
192
194
|
patterns: [
|
|
193
|
-
|
|
195
|
+
// No digit beside it: asking for one let "$thirty-nine and it is yours" through.
|
|
196
|
+
/[$£€¥₹]/,
|
|
194
197
|
/\b\d+(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|pounds?|euros?)\b/,
|
|
198
|
+
// The same spelled out. "pounds" is left off on purpose — a pack weighs two of those.
|
|
199
|
+
/\bdollars?\b|\beuros?\b/,
|
|
195
200
|
// The same codes on the other side of the number: "USD 20", "EUR 5.99".
|
|
196
201
|
/\b(?:usd|eur|gbp)\s?\d/,
|
|
197
202
|
// The krona is spelled out rather than drawn, so it needs a number beside it.
|
|
@@ -297,9 +302,10 @@ const CONFUSABLES: Record<string, string> = {
|
|
|
297
302
|
* points and the controls. Cf and Default_Ignorable each hold characters the
|
|
298
303
|
* other does not.
|
|
299
304
|
*
|
|
300
|
-
* The same class @rudra-js/attested strips in its own `hidden.ts`.
|
|
301
|
-
*
|
|
302
|
-
*
|
|
305
|
+
* The same class @rudra-js/attested strips in its own `hidden.ts`. The second copy stays
|
|
306
|
+
* now that core calls attested, because it feeds core's own patterns and those reach two
|
|
307
|
+
* things attested's phrase list does not hold: "20% οff" spelled with a Greek omicron,
|
|
308
|
+
* and "PRİCED to move". Widening one and not the other is still the mistake to watch for.
|
|
303
309
|
*/
|
|
304
310
|
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
305
311
|
|
|
@@ -338,6 +344,80 @@ function normaliseForClaims(text: string): string {
|
|
|
338
344
|
return folded;
|
|
339
345
|
}
|
|
340
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Wording attested bans that this project has already ruled is not a claim. An allowance
|
|
349
|
+
* forgives a banned phrase sitting inside it, so an entry ending on a preposition — "extra
|
|
350
|
+
* clearance for" — forgives whatever follows it. Exported so a test can hold the list
|
|
351
|
+
* against attested's own.
|
|
352
|
+
*/
|
|
353
|
+
export const ALLOWED_PHRASES = ['does not feel cheap', "doesn't feel cheap", 'last few miles'];
|
|
354
|
+
|
|
355
|
+
/** Only a string with one of these in it can stand behind a numeral. */
|
|
356
|
+
const DIGIT = /\p{Nd}/u;
|
|
357
|
+
|
|
358
|
+
/** Anything attested reads as a numeral — ½ ² Ⅲ included, which it reports rather than ignores. */
|
|
359
|
+
const NUMERAL = /[\p{Nd}\p{No}\p{Nl}]/u;
|
|
360
|
+
|
|
361
|
+
const NO_FACTS: readonly string[] = [];
|
|
362
|
+
|
|
363
|
+
/** A fact whose whole content is a number in exponent notation. attested lays those out in place. */
|
|
364
|
+
const BARE_EXPONENT = /^[+-]?\d+(?:\.\d+)?[eE]([+-]?\d+)$/;
|
|
365
|
+
|
|
366
|
+
/** Wide enough for every finite JavaScript number, and for anything a shop sells. */
|
|
367
|
+
const MAX_EXPONENT = 1000;
|
|
368
|
+
|
|
369
|
+
// `1e2000000000` is twelve characters and lays out into a run long enough to end the process.
|
|
370
|
+
// attested caps that itself, but it is a peer dependency and a host's copy of it may be older.
|
|
371
|
+
function canLayOut(fact: string): boolean {
|
|
372
|
+
const match = BARE_EXPONENT.exec(fact.trim());
|
|
373
|
+
if (match === null) return true;
|
|
374
|
+
return Math.abs(Number(match[1] ?? '')) <= MAX_EXPONENT;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The numbers the shop published about one product: its category and tags, only the strings
|
|
379
|
+
* carrying a digit. Exported so a test can pin what never reaches attested.
|
|
380
|
+
*/
|
|
381
|
+
export function productFacts(product: Product): string[] {
|
|
382
|
+
const facts: string[] = [];
|
|
383
|
+
if (DIGIT.test(product.category) && canLayOut(product.category)) facts.push(product.category);
|
|
384
|
+
for (const tag of product.tags) {
|
|
385
|
+
if (DIGIT.test(tag) && canLayOut(tag)) facts.push(tag);
|
|
386
|
+
}
|
|
387
|
+
return facts;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface HostFacts {
|
|
391
|
+
pooled: string[];
|
|
392
|
+
bySku: Map<string, string[]>;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The shop's own numbers for this request, for attested to check numerals against.
|
|
397
|
+
*
|
|
398
|
+
* `offeredCandidates` rather than `input.candidates` — out of stock or past the prompt's cap
|
|
399
|
+
* means a product the model never saw. A title and a rating it is shown but told never to
|
|
400
|
+
* repeat, so a numeral out of one is one it was told not to write. The browsed category comes
|
|
401
|
+
* from the request rather than the catalogue, so a host passing a URL segment through would
|
|
402
|
+
* hand this list to whoever types the URL.
|
|
403
|
+
*
|
|
404
|
+
* `pooled` is the soft part of the check: one candidate's "40 litre" backs "take 40 off"
|
|
405
|
+
* written about another. A `reason` and a `badge` read `bySku`, because they sit under a
|
|
406
|
+
* named product — off the pooled list, a badge reading "Only 2" stood on a tent's tag.
|
|
407
|
+
*/
|
|
408
|
+
function hostFacts(input: TrackingInput): HostFacts {
|
|
409
|
+
const pooled = new Set<string>();
|
|
410
|
+
const bySku = new Map<string, string[]>();
|
|
411
|
+
|
|
412
|
+
for (const product of offeredCandidates(input)) {
|
|
413
|
+
const own = productFacts(product);
|
|
414
|
+
bySku.set(product.sku, own);
|
|
415
|
+
for (const fact of own) pooled.add(fact);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { pooled: [...pooled], bySku };
|
|
419
|
+
}
|
|
420
|
+
|
|
341
421
|
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
342
422
|
function claimIn(text: string): string | null {
|
|
343
423
|
const normalised = normaliseForClaims(text);
|
|
@@ -357,13 +437,15 @@ function claimIn(text: string): string | null {
|
|
|
357
437
|
* through every function. The budget and the de-duplication set are global to a
|
|
358
438
|
* spec, not to a block, which is the part that is easy to get wrong.
|
|
359
439
|
*/
|
|
360
|
-
function createPlacementTracker(maxItems: number) {
|
|
440
|
+
function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
361
441
|
const placedSkus = new Set<string>();
|
|
362
442
|
const violations: string[] = [];
|
|
363
443
|
let remaining = maxItems;
|
|
364
444
|
|
|
365
445
|
return {
|
|
366
446
|
violations,
|
|
447
|
+
facts: facts.pooled,
|
|
448
|
+
factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
|
|
367
449
|
get remaining() {
|
|
368
450
|
return remaining;
|
|
369
451
|
},
|
|
@@ -386,19 +468,41 @@ type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
|
386
468
|
* Runs after clamping, so what is screened is exactly what would have rendered.
|
|
387
469
|
* Dropping means what it means everywhere else here: this field becomes null
|
|
388
470
|
* and the rest of the block carries on.
|
|
471
|
+
*
|
|
472
|
+
* Three passes, most specific first. Core's patterns name one of the five kinds; `quantity`
|
|
473
|
+
* is the only proof in the stack, every numeral having to be one the shop supplied; `wording`
|
|
474
|
+
* is a second denylist and the weakest, so it answers last. `facts` defaults to the pooled
|
|
475
|
+
* list, and a field that names one product passes that product's own.
|
|
389
476
|
*/
|
|
390
477
|
function screenClaim(
|
|
391
478
|
value: string | null,
|
|
392
479
|
field: string,
|
|
393
480
|
tracker: PlacementTracker,
|
|
481
|
+
facts: readonly string[] = tracker.facts,
|
|
394
482
|
): string | null {
|
|
395
483
|
if (value === null) return null;
|
|
396
484
|
|
|
397
485
|
const kind = claimIn(value);
|
|
398
|
-
if (kind
|
|
486
|
+
if (kind !== null) {
|
|
487
|
+
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
399
490
|
|
|
400
|
-
|
|
401
|
-
|
|
491
|
+
// Nothing for the quantity layer to weigh without a numeral, whatever the facts say,
|
|
492
|
+
// and reading the fact list to prove that again per field is most of what this costs.
|
|
493
|
+
const weighed = NUMERAL.test(value) ? facts : NO_FACTS;
|
|
494
|
+
|
|
495
|
+
const result = verify(value, { values: weighed, allowedPhrases: ALLOWED_PHRASES });
|
|
496
|
+
if (!result.quantity.supported) {
|
|
497
|
+
tracker.record(`unverifiable-claim:quantity:${field}`);
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
if (!result.wording.supported) {
|
|
501
|
+
tracker.record(`unverifiable-claim:wording:${field}`);
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return value;
|
|
402
506
|
}
|
|
403
507
|
|
|
404
508
|
/**
|
|
@@ -439,7 +543,7 @@ function reconcileItems(
|
|
|
439
543
|
candidatesBySku: Map<string, Product>,
|
|
440
544
|
digest: SignalDigest,
|
|
441
545
|
tracker: PlacementTracker,
|
|
442
|
-
|
|
546
|
+
ourReasons: ReadonlyMap<string, string>,
|
|
443
547
|
): ProductReference[] {
|
|
444
548
|
const kept: ProductReference[] = [];
|
|
445
549
|
|
|
@@ -459,8 +563,10 @@ function reconcileItems(
|
|
|
459
563
|
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
460
564
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
461
565
|
|
|
462
|
-
|
|
463
|
-
|
|
566
|
+
// Our sentence, not the model's. It has to match, so a lookalike is still screened.
|
|
567
|
+
const isOurs = item.reason !== null && ourReasons.get(item.sku) === item.reason;
|
|
568
|
+
|
|
569
|
+
const own = tracker.factsFor(item.sku);
|
|
464
570
|
|
|
465
571
|
kept.push({
|
|
466
572
|
sku: item.sku,
|
|
@@ -468,14 +574,19 @@ function reconcileItems(
|
|
|
468
574
|
// The prose exists to state the basis. If the basis did not hold, the
|
|
469
575
|
// prose is a claim we just decided is untrue.
|
|
470
576
|
reason: hasSupportedBasis
|
|
471
|
-
?
|
|
577
|
+
? isOurs
|
|
472
578
|
? clampNullable(item.reason, CLAMP.reason)
|
|
473
|
-
: screenClaim(
|
|
579
|
+
: screenClaim(
|
|
580
|
+
clampNullable(item.reason, CLAMP.reason),
|
|
581
|
+
`reason:${item.sku}`,
|
|
582
|
+
tracker,
|
|
583
|
+
own,
|
|
584
|
+
)
|
|
474
585
|
: null,
|
|
475
586
|
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
476
587
|
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
477
588
|
// read for claims like every other sentence the model writes.
|
|
478
|
-
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
|
|
589
|
+
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
|
|
479
590
|
emphasis: item.emphasis,
|
|
480
591
|
});
|
|
481
592
|
}
|
|
@@ -566,7 +677,7 @@ export function bundleForShopper(
|
|
|
566
677
|
): Bundle | undefined {
|
|
567
678
|
const allowlist = buildAllowlist(input, digest);
|
|
568
679
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
569
|
-
const tracker = createPlacementTracker(digest.maxItems);
|
|
680
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
570
681
|
|
|
571
682
|
for (const sku of spokenFor) tracker.place(sku);
|
|
572
683
|
|
|
@@ -608,7 +719,7 @@ function reconcileBlock(
|
|
|
608
719
|
digest: SignalDigest,
|
|
609
720
|
bundles: readonly Bundle[],
|
|
610
721
|
tracker: PlacementTracker,
|
|
611
|
-
|
|
722
|
+
ourReasons: ReadonlyMap<string, string>,
|
|
612
723
|
): Block | null {
|
|
613
724
|
switch (block.kind) {
|
|
614
725
|
case 'hero': {
|
|
@@ -651,7 +762,7 @@ function reconcileBlock(
|
|
|
651
762
|
candidatesBySku,
|
|
652
763
|
digest,
|
|
653
764
|
tracker,
|
|
654
|
-
|
|
765
|
+
ourReasons,
|
|
655
766
|
);
|
|
656
767
|
if (items.length === 0) {
|
|
657
768
|
tracker.record('empty-block:grid');
|
|
@@ -673,7 +784,7 @@ function reconcileBlock(
|
|
|
673
784
|
candidatesBySku,
|
|
674
785
|
digest,
|
|
675
786
|
tracker,
|
|
676
|
-
|
|
787
|
+
ourReasons,
|
|
677
788
|
);
|
|
678
789
|
if (items.length === 0) {
|
|
679
790
|
tracker.record('empty-block:carousel');
|
|
@@ -751,15 +862,18 @@ export function reconcileSpec(
|
|
|
751
862
|
input: TrackingInput,
|
|
752
863
|
digest: SignalDigest,
|
|
753
864
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
865
|
+
* The reason this request wrote itself, by SKU: the host's own `reason` on a
|
|
866
|
+
* candidate, or the sentence the selector wrote when there was none. Neither is the
|
|
867
|
+
* model's words, and the deterministic component renders the same sentence unscreened.
|
|
868
|
+
*
|
|
869
|
+
* Only `fitToShopper` fills it, so in `per-shopper` mode it is empty and every
|
|
756
870
|
* reason is the model's, including one that happens to read the same.
|
|
757
871
|
*/
|
|
758
|
-
|
|
872
|
+
ourReasons: ReadonlyMap<string, string> = new Map(),
|
|
759
873
|
): ReconcileResult {
|
|
760
874
|
const allowlist = buildAllowlist(input, digest);
|
|
761
875
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
762
|
-
const tracker = createPlacementTracker(digest.maxItems);
|
|
876
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
763
877
|
|
|
764
878
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
765
879
|
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
@@ -774,7 +888,7 @@ export function reconcileSpec(
|
|
|
774
888
|
digest,
|
|
775
889
|
input.bundles,
|
|
776
890
|
tracker,
|
|
777
|
-
|
|
891
|
+
ourReasons,
|
|
778
892
|
);
|
|
779
893
|
if (reconciled !== null) blocks.push(reconciled);
|
|
780
894
|
}
|