@rudra-js/core 0.3.1 → 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 +202 -31
- 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 +227 -30
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.
|
|
@@ -255,12 +260,170 @@ const CLAIM_PATTERNS: { kind: string; patterns: RegExp[] }[] = [
|
|
|
255
260
|
},
|
|
256
261
|
];
|
|
257
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Letters that render as a Latin letter but are not one. A Cyrillic о in
|
|
265
|
+
* "in stоck" would otherwise carry a stock claim straight past the rule written
|
|
266
|
+
* for it, and the shopper would read the claim anyway.
|
|
267
|
+
*/
|
|
268
|
+
const CONFUSABLES: Record<string, string> = {
|
|
269
|
+
а: 'a',
|
|
270
|
+
в: 'b',
|
|
271
|
+
е: 'e',
|
|
272
|
+
к: 'k',
|
|
273
|
+
м: 'm',
|
|
274
|
+
н: 'h',
|
|
275
|
+
о: 'o',
|
|
276
|
+
р: 'p',
|
|
277
|
+
с: 'c',
|
|
278
|
+
т: 't',
|
|
279
|
+
х: 'x',
|
|
280
|
+
у: 'y',
|
|
281
|
+
ѕ: 's',
|
|
282
|
+
і: 'i',
|
|
283
|
+
ј: 'j',
|
|
284
|
+
ԁ: 'd',
|
|
285
|
+
һ: 'h',
|
|
286
|
+
ӏ: 'l',
|
|
287
|
+
α: 'a',
|
|
288
|
+
ε: 'e',
|
|
289
|
+
ι: 'i',
|
|
290
|
+
κ: 'k',
|
|
291
|
+
ν: 'v',
|
|
292
|
+
ο: 'o',
|
|
293
|
+
ρ: 'p',
|
|
294
|
+
τ: 't',
|
|
295
|
+
υ: 'u',
|
|
296
|
+
χ: 'x',
|
|
297
|
+
ϲ: 'c',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Characters that render as nothing: format characters, default-ignorable code
|
|
302
|
+
* points and the controls. Cf and Default_Ignorable each hold characters the
|
|
303
|
+
* other does not.
|
|
304
|
+
*
|
|
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.
|
|
309
|
+
*/
|
|
310
|
+
const INVISIBLE = /[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Cc}]/gu;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The controls that do take room. Deleting these would read `Only 2\n3 left` as 23.
|
|
314
|
+
*
|
|
315
|
+
* No test pins this line, and none can: clamping collapses every run of whitespace
|
|
316
|
+
* before a claim is screened, so nothing here ever sees one. It is kept so the class
|
|
317
|
+
* reads the same in both copies.
|
|
318
|
+
*/
|
|
319
|
+
const SPACING = /[\t\n\v\f\r\u0085]/;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Marks that hang on the character before them instead of taking a column of their
|
|
323
|
+
* own. Dropped after composition, so an accented e keeps its accent while an
|
|
324
|
+
* overline, which composes with nothing, cannot hide a word from its own rule.
|
|
325
|
+
*/
|
|
326
|
+
const MARKS = /[\p{Mn}\p{Me}]/gu;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* One shape for the text before the patterns read it, so a word that renders as
|
|
330
|
+
* "in stock" is read as "in stock" however it was spelled.
|
|
331
|
+
*
|
|
332
|
+
* Only the reading is normalised. What renders is what the model wrote.
|
|
333
|
+
*/
|
|
334
|
+
function normaliseForClaims(text: string): string {
|
|
335
|
+
const lowered = text
|
|
336
|
+
.replace(INVISIBLE, (char) => (SPACING.test(char) ? char : ''))
|
|
337
|
+
.normalize('NFKC')
|
|
338
|
+
.toLowerCase()
|
|
339
|
+
.normalize('NFC')
|
|
340
|
+
.replace(MARKS, '');
|
|
341
|
+
|
|
342
|
+
let folded = '';
|
|
343
|
+
for (const char of lowered) folded += CONFUSABLES[char] ?? char;
|
|
344
|
+
return folded;
|
|
345
|
+
}
|
|
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
|
+
|
|
258
421
|
/** Names the first forbidden claim the text makes, or null when it makes none. */
|
|
259
422
|
function claimIn(text: string): string | null {
|
|
260
|
-
const
|
|
423
|
+
const normalised = normaliseForClaims(text);
|
|
261
424
|
for (const claim of CLAIM_PATTERNS) {
|
|
262
425
|
for (const pattern of claim.patterns) {
|
|
263
|
-
if (pattern.test(
|
|
426
|
+
if (pattern.test(normalised)) return claim.kind;
|
|
264
427
|
}
|
|
265
428
|
}
|
|
266
429
|
return null;
|
|
@@ -274,13 +437,15 @@ function claimIn(text: string): string | null {
|
|
|
274
437
|
* through every function. The budget and the de-duplication set are global to a
|
|
275
438
|
* spec, not to a block, which is the part that is easy to get wrong.
|
|
276
439
|
*/
|
|
277
|
-
function createPlacementTracker(maxItems: number) {
|
|
440
|
+
function createPlacementTracker(maxItems: number, facts: HostFacts) {
|
|
278
441
|
const placedSkus = new Set<string>();
|
|
279
442
|
const violations: string[] = [];
|
|
280
443
|
let remaining = maxItems;
|
|
281
444
|
|
|
282
445
|
return {
|
|
283
446
|
violations,
|
|
447
|
+
facts: facts.pooled,
|
|
448
|
+
factsFor: (sku: string): readonly string[] => facts.bySku.get(sku) ?? NO_FACTS,
|
|
284
449
|
get remaining() {
|
|
285
450
|
return remaining;
|
|
286
451
|
},
|
|
@@ -303,19 +468,41 @@ type PlacementTracker = ReturnType<typeof createPlacementTracker>;
|
|
|
303
468
|
* Runs after clamping, so what is screened is exactly what would have rendered.
|
|
304
469
|
* Dropping means what it means everywhere else here: this field becomes null
|
|
305
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.
|
|
306
476
|
*/
|
|
307
477
|
function screenClaim(
|
|
308
478
|
value: string | null,
|
|
309
479
|
field: string,
|
|
310
480
|
tracker: PlacementTracker,
|
|
481
|
+
facts: readonly string[] = tracker.facts,
|
|
311
482
|
): string | null {
|
|
312
483
|
if (value === null) return null;
|
|
313
484
|
|
|
314
485
|
const kind = claimIn(value);
|
|
315
|
-
if (kind
|
|
486
|
+
if (kind !== null) {
|
|
487
|
+
tracker.record(`unverifiable-claim:${kind}:${field}`);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
316
490
|
|
|
317
|
-
|
|
318
|
-
|
|
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;
|
|
319
506
|
}
|
|
320
507
|
|
|
321
508
|
/**
|
|
@@ -356,7 +543,7 @@ function reconcileItems(
|
|
|
356
543
|
candidatesBySku: Map<string, Product>,
|
|
357
544
|
digest: SignalDigest,
|
|
358
545
|
tracker: PlacementTracker,
|
|
359
|
-
|
|
546
|
+
ourReasons: ReadonlyMap<string, string>,
|
|
360
547
|
): ProductReference[] {
|
|
361
548
|
const kept: ProductReference[] = [];
|
|
362
549
|
|
|
@@ -376,8 +563,10 @@ function reconcileItems(
|
|
|
376
563
|
const hasSupportedBasis = verifyBasis(item.basis, product, digest);
|
|
377
564
|
if (!hasSupportedBasis) tracker.record(`unsupported-basis:${item.basis}:${item.sku}`);
|
|
378
565
|
|
|
379
|
-
|
|
380
|
-
|
|
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);
|
|
381
570
|
|
|
382
571
|
kept.push({
|
|
383
572
|
sku: item.sku,
|
|
@@ -385,14 +574,19 @@ function reconcileItems(
|
|
|
385
574
|
// The prose exists to state the basis. If the basis did not hold, the
|
|
386
575
|
// prose is a claim we just decided is untrue.
|
|
387
576
|
reason: hasSupportedBasis
|
|
388
|
-
?
|
|
577
|
+
? isOurs
|
|
389
578
|
? clampNullable(item.reason, CLAMP.reason)
|
|
390
|
-
: screenClaim(
|
|
579
|
+
: screenClaim(
|
|
580
|
+
clampNullable(item.reason, CLAMP.reason),
|
|
581
|
+
`reason:${item.sku}`,
|
|
582
|
+
tracker,
|
|
583
|
+
own,
|
|
584
|
+
)
|
|
391
585
|
: null,
|
|
392
586
|
// A badge is the shortest, loudest text on the card, and the schema's own
|
|
393
587
|
// example for it was "Back in stock" — a stock claim. It renders, so it is
|
|
394
588
|
// read for claims like every other sentence the model writes.
|
|
395
|
-
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker),
|
|
589
|
+
badge: screenClaim(clampNullable(item.badge, CLAMP.badge), `badge:${item.sku}`, tracker, own),
|
|
396
590
|
emphasis: item.emphasis,
|
|
397
591
|
});
|
|
398
592
|
}
|
|
@@ -483,7 +677,7 @@ export function bundleForShopper(
|
|
|
483
677
|
): Bundle | undefined {
|
|
484
678
|
const allowlist = buildAllowlist(input, digest);
|
|
485
679
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
486
|
-
const tracker = createPlacementTracker(digest.maxItems);
|
|
680
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
487
681
|
|
|
488
682
|
for (const sku of spokenFor) tracker.place(sku);
|
|
489
683
|
|
|
@@ -525,7 +719,7 @@ function reconcileBlock(
|
|
|
525
719
|
digest: SignalDigest,
|
|
526
720
|
bundles: readonly Bundle[],
|
|
527
721
|
tracker: PlacementTracker,
|
|
528
|
-
|
|
722
|
+
ourReasons: ReadonlyMap<string, string>,
|
|
529
723
|
): Block | null {
|
|
530
724
|
switch (block.kind) {
|
|
531
725
|
case 'hero': {
|
|
@@ -568,7 +762,7 @@ function reconcileBlock(
|
|
|
568
762
|
candidatesBySku,
|
|
569
763
|
digest,
|
|
570
764
|
tracker,
|
|
571
|
-
|
|
765
|
+
ourReasons,
|
|
572
766
|
);
|
|
573
767
|
if (items.length === 0) {
|
|
574
768
|
tracker.record('empty-block:grid');
|
|
@@ -590,7 +784,7 @@ function reconcileBlock(
|
|
|
590
784
|
candidatesBySku,
|
|
591
785
|
digest,
|
|
592
786
|
tracker,
|
|
593
|
-
|
|
787
|
+
ourReasons,
|
|
594
788
|
);
|
|
595
789
|
if (items.length === 0) {
|
|
596
790
|
tracker.record('empty-block:carousel');
|
|
@@ -668,15 +862,18 @@ export function reconcileSpec(
|
|
|
668
862
|
input: TrackingInput,
|
|
669
863
|
digest: SignalDigest,
|
|
670
864
|
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
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
|
|
673
870
|
* reason is the model's, including one that happens to read the same.
|
|
674
871
|
*/
|
|
675
|
-
|
|
872
|
+
ourReasons: ReadonlyMap<string, string> = new Map(),
|
|
676
873
|
): ReconcileResult {
|
|
677
874
|
const allowlist = buildAllowlist(input, digest);
|
|
678
875
|
const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
|
|
679
|
-
const tracker = createPlacementTracker(digest.maxItems);
|
|
876
|
+
const tracker = createPlacementTracker(digest.maxItems, hostFacts(input));
|
|
680
877
|
|
|
681
878
|
if (generated.blocks.length > MAX_BLOCKS) {
|
|
682
879
|
tracker.record(`too-many-blocks:${generated.blocks.length}`);
|
|
@@ -691,7 +888,7 @@ export function reconcileSpec(
|
|
|
691
888
|
digest,
|
|
692
889
|
input.bundles,
|
|
693
890
|
tracker,
|
|
694
|
-
|
|
891
|
+
ourReasons,
|
|
695
892
|
);
|
|
696
893
|
if (reconciled !== null) blocks.push(reconciled);
|
|
697
894
|
}
|