@factorypure/client-helpers 1.2.0 → 1.3.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/dist/index.d.ts +58 -25
- package/dist/index.js +345 -136
- package/dist/repricing.d.ts +156 -0
- package/dist/repricing.js +411 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { startOfDay, subDays } from 'date-fns';
|
|
|
3
3
|
import { Index } from 'flexsearch';
|
|
4
4
|
import EnglishPreset from 'flexsearch/lang/en';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
export * from './repricing.js';
|
|
6
7
|
// GOTCHA must restart client
|
|
7
8
|
// npm install /Users/kokkoalex/Documents/GitHub/npm-packages/client-helpers
|
|
8
9
|
// npm uninstall @factorypure/client-helpers
|
|
@@ -217,6 +218,20 @@ export var VisibilityState;
|
|
|
217
218
|
VisibilityState["VISIBLE_WITH_WARNINGS"] = "VISIBLE_WITH_WARNINGS";
|
|
218
219
|
VisibilityState["VISIBLE"] = "VISIBLE";
|
|
219
220
|
})(VisibilityState || (VisibilityState = {}));
|
|
221
|
+
/**
|
|
222
|
+
* Result groups that replace VisibilityState with richer classification.
|
|
223
|
+
* Each result is assigned a group after all filter rules run:
|
|
224
|
+
* MATCH — no active blocks (shown; undercut determination is a separate downstream step)
|
|
225
|
+
* LOW_LIKELIHOOD — medium confidence only (50–79); shown in "needs review" if cheaper
|
|
226
|
+
* NO_MATCH — low confidence or has an active block rule (hidden)
|
|
227
|
+
*/
|
|
228
|
+
export var ResultGroup;
|
|
229
|
+
(function (ResultGroup) {
|
|
230
|
+
ResultGroup["MATCH"] = "match";
|
|
231
|
+
ResultGroup["LOW_LIKELIHOOD"] = "low_likelihood";
|
|
232
|
+
ResultGroup["NO_MATCH"] = "no_match";
|
|
233
|
+
ResultGroup["EXCLUDED"] = "excluded";
|
|
234
|
+
})(ResultGroup || (ResultGroup = {}));
|
|
220
235
|
/**
|
|
221
236
|
* Structured result from a filter rule evaluation
|
|
222
237
|
*/
|
|
@@ -264,7 +279,7 @@ export const scrapeResultsSchema = z.object({
|
|
|
264
279
|
hide_override_reasons: z.array(z.string()),
|
|
265
280
|
// New filter system fields
|
|
266
281
|
filter_results: z.array(filterResultSchema).optional(),
|
|
267
|
-
visibility_state: z.nativeEnum(
|
|
282
|
+
visibility_state: z.nativeEnum(ResultGroup).optional(),
|
|
268
283
|
regexUnitResults: z.nullable(regexUnitResultsSchema),
|
|
269
284
|
// add these properties as null to make ts play nice with the ImmersiveScrapeResultsType union
|
|
270
285
|
google_product_link: z.null().optional(),
|
|
@@ -335,7 +350,7 @@ export const immersiveScrapeResultsSchema = z.object({
|
|
|
335
350
|
hide_override_reasons: z.array(z.string()),
|
|
336
351
|
// New filter system fields
|
|
337
352
|
filter_results: z.array(filterResultSchema).optional(),
|
|
338
|
-
visibility_state: z.nativeEnum(
|
|
353
|
+
visibility_state: z.nativeEnum(ResultGroup).optional(),
|
|
339
354
|
brand: z.string().nullable(),
|
|
340
355
|
company_id: z.number().nullable(),
|
|
341
356
|
regexUnitResults: z.nullable(regexUnitResultsSchema),
|
|
@@ -439,6 +454,8 @@ export const HIDE_REASONS = {
|
|
|
439
454
|
CALCULATED_BRAND_MISMATCH: 'Calculated Brand Mismatch',
|
|
440
455
|
SCAM_SOURCE_EXCLUSION: 'Scam Source Exclusion',
|
|
441
456
|
AI_COMPARISON_MISMATCH: 'AI Comparison Mismatch',
|
|
457
|
+
ABSOLUTE_LOW_PRICE_OUTLIER: 'Extreme Price Outlier',
|
|
458
|
+
ABSOLUTE_HIGH_PRICE_OUTLIER: 'Extreme High Price',
|
|
442
459
|
};
|
|
443
460
|
export const HIDE_OVERRIDE_REASONS = {
|
|
444
461
|
SKU_MATCH: 'SKU Match',
|
|
@@ -467,6 +484,8 @@ const HIDE_ALWAYS_MAP = {
|
|
|
467
484
|
[HIDE_REASONS.VENDOR_EXCLUSION]: false,
|
|
468
485
|
[HIDE_REASONS.SCAM_SOURCE_EXCLUSION]: true,
|
|
469
486
|
[HIDE_REASONS.AI_COMPARISON_MISMATCH]: false,
|
|
487
|
+
[HIDE_REASONS.ABSOLUTE_LOW_PRICE_OUTLIER]: true,
|
|
488
|
+
[HIDE_REASONS.ABSOLUTE_HIGH_PRICE_OUTLIER]: true,
|
|
470
489
|
};
|
|
471
490
|
export const TOO_CHEAP_MULTIPLIER = 0.75;
|
|
472
491
|
export const TOO_EXPENSIVE_MULTIPLIER = 1.15;
|
|
@@ -476,10 +495,27 @@ export const TOO_EXPENSIVE_MULTIPLIER = 1.15;
|
|
|
476
495
|
*/
|
|
477
496
|
export const ABSOLUTE_BLOCK_RULE_IDS = [
|
|
478
497
|
'manually_ignored',
|
|
498
|
+
'manually_excluded',
|
|
499
|
+
'out_of_stock',
|
|
479
500
|
'scam_source_exclusion',
|
|
480
501
|
'duplicate',
|
|
481
502
|
'high_price_outlier',
|
|
482
503
|
'low_price_outlier',
|
|
504
|
+
'absolute_low_price_outlier',
|
|
505
|
+
'absolute_high_price_outlier',
|
|
506
|
+
];
|
|
507
|
+
/**
|
|
508
|
+
* Rule IDs that represent explicit admin/security decisions.
|
|
509
|
+
* These are the ONLY blocks respected when a confidence override or match confirmation is present.
|
|
510
|
+
* Operational blocks (out_of_stock, price outliers, etc.) are superseded by overrides.
|
|
511
|
+
*/
|
|
512
|
+
export const MANUAL_EXCLUSION_RULE_IDS = [
|
|
513
|
+
'manually_ignored',
|
|
514
|
+
'manually_excluded',
|
|
515
|
+
'scam_source_exclusion',
|
|
516
|
+
'duplicate',
|
|
517
|
+
'absolute_low_price_outlier',
|
|
518
|
+
'absolute_high_price_outlier',
|
|
483
519
|
];
|
|
484
520
|
/**
|
|
485
521
|
* Rule IDs that can be overridden when the matching-engine confidence is high enough.
|
|
@@ -498,17 +534,11 @@ export const CONFIDENCE_OVERRIDABLE_RULE_IDS = [
|
|
|
498
534
|
'skip_sku',
|
|
499
535
|
];
|
|
500
536
|
/**
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
* - `'show_with_override'`— result failed soft filters but confidence is high enough to surface it anyway
|
|
507
|
-
*
|
|
508
|
-
* @param filterResults The filter_results array from the filter engine
|
|
509
|
-
* @param visibilityState The visibility_state computed by the filter engine
|
|
510
|
-
* @param confidence Matching-engine confidence score (0–100)
|
|
511
|
-
* @param overrideThreshold Minimum confidence to override a soft BLOCK rule (default 80)
|
|
537
|
+
* @deprecated Confidence overrides are now handled by the filter engine.
|
|
538
|
+
* Pass a `confidenceMap` to `filterScrapeResultsV2` — the engine injects a synthetic
|
|
539
|
+
* `high_confidence_override` rule result when confidence ≥ 80, which is listed in the
|
|
540
|
+
* `overridableBy` array of all soft block/warning rules. `visibility_state` on each result
|
|
541
|
+
* is then accurate and final before `getShownResults` is called.
|
|
512
542
|
*/
|
|
513
543
|
export function evaluateConfidenceOverride(filterResults, visibilityState, confidence, overrideThreshold = 80) {
|
|
514
544
|
// Absolute blocks can never be overridden regardless of confidence
|
|
@@ -610,7 +640,21 @@ export class FilterEngine {
|
|
|
610
640
|
/**
|
|
611
641
|
* Evaluate rules for a batch of results
|
|
612
642
|
*/
|
|
613
|
-
evaluateBatch(results, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, comparisonMap, matchConfirmationMap) {
|
|
643
|
+
evaluateBatch(results, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, comparisonMap, matchConfirmationMap, matchResult) {
|
|
644
|
+
// Build a map of result id → best matching candidate so we can look up confidence
|
|
645
|
+
// and attach candidate_result to each filtered result.
|
|
646
|
+
const candidateMap = new Map();
|
|
647
|
+
for (const c of matchResult?.candidates ?? []) {
|
|
648
|
+
candidateMap.set(c.candidateId.toString(), c);
|
|
649
|
+
}
|
|
650
|
+
for (const c of matchResult?.insufficientDataCandidates ?? []) {
|
|
651
|
+
if (!candidateMap.has(c.candidateId.toString()))
|
|
652
|
+
candidateMap.set(c.candidateId.toString(), c);
|
|
653
|
+
}
|
|
654
|
+
for (const c of matchResult?.nonMatchCandidates ?? []) {
|
|
655
|
+
if (!candidateMap.has(c.candidateId.toString()))
|
|
656
|
+
candidateMap.set(c.candidateId.toString(), c);
|
|
657
|
+
}
|
|
614
658
|
return results.map((result) => {
|
|
615
659
|
const context = {
|
|
616
660
|
result,
|
|
@@ -624,11 +668,66 @@ export class FilterEngine {
|
|
|
624
668
|
const filterResults = this.evaluateResult(context);
|
|
625
669
|
// Merge with existing filter_results from batch processing (duplicates, search exclusions, etc.)
|
|
626
670
|
const existingFilterResults = result.filter_results || [];
|
|
627
|
-
|
|
628
|
-
|
|
671
|
+
let allFilterResults = [...existingFilterResults, ...filterResults];
|
|
672
|
+
// Look up the matching-engine candidate for this result and attach it so downstream
|
|
673
|
+
// consumers have access to match metadata (confidence, scores, reasons, etc.).
|
|
674
|
+
const candidate = candidateMap.get(result.id.toString());
|
|
675
|
+
if (candidate)
|
|
676
|
+
result.candidate_result = candidate;
|
|
677
|
+
const confidence = candidate?.confidence;
|
|
678
|
+
// Inject a confidence-tier filter_result so calculateVisibility is the single source
|
|
679
|
+
// of truth for visibility — no confidence gate is needed in getShownResults:
|
|
680
|
+
// ≥ 80 → high_confidence_override (INFO, isOverride: true) — cancels soft blocks
|
|
681
|
+
// listed in each rule's overridableBy (e.g. ai_comparison_mismatch)
|
|
682
|
+
// 50–79 → medium_confidence (BLOCK) — hidden; overridable by match_confirmation_override
|
|
683
|
+
// < 50 or not evaluated → low_confidence (BLOCK) — hidden; overridable by match_confirmation_override
|
|
684
|
+
if (confidence !== undefined && confidence >= 80) {
|
|
685
|
+
allFilterResults = [
|
|
686
|
+
...allFilterResults,
|
|
687
|
+
{
|
|
688
|
+
ruleId: 'high_confidence_override',
|
|
689
|
+
severity: FilterSeverity.INFO,
|
|
690
|
+
message: 'High Confidence Match',
|
|
691
|
+
metadata: { isOverride: true, confidence },
|
|
692
|
+
timestamp: new Date().toISOString(),
|
|
693
|
+
},
|
|
694
|
+
];
|
|
695
|
+
}
|
|
696
|
+
else if (confidence !== undefined && confidence >= 50) {
|
|
697
|
+
allFilterResults = [
|
|
698
|
+
...allFilterResults,
|
|
699
|
+
{
|
|
700
|
+
ruleId: 'medium_confidence',
|
|
701
|
+
severity: FilterSeverity.BLOCK,
|
|
702
|
+
message: 'Medium Confidence Match',
|
|
703
|
+
metadata: { confidence },
|
|
704
|
+
timestamp: new Date().toISOString(),
|
|
705
|
+
},
|
|
706
|
+
];
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
allFilterResults = [
|
|
710
|
+
...allFilterResults,
|
|
711
|
+
{
|
|
712
|
+
ruleId: 'low_confidence',
|
|
713
|
+
severity: FilterSeverity.BLOCK,
|
|
714
|
+
message: 'Low Confidence Match',
|
|
715
|
+
metadata: { confidence },
|
|
716
|
+
timestamp: new Date().toISOString(),
|
|
717
|
+
},
|
|
718
|
+
];
|
|
719
|
+
}
|
|
720
|
+
const resultPrice = result.extracted_total || result.extracted_price || 0;
|
|
721
|
+
const group = this.calculateGroup(allFilterResults, resultPrice, variant.price);
|
|
722
|
+
// confidence_override_active: true when high confidence cancelled a soft block on this result
|
|
723
|
+
const hadBlocksBefore = confidence !== undefined &&
|
|
724
|
+
confidence >= 80 &&
|
|
725
|
+
allFilterResults.some((fr) => fr.severity === FilterSeverity.BLOCK && fr.ruleId !== 'high_confidence_override');
|
|
629
726
|
// Populate new fields
|
|
630
727
|
result.filter_results = allFilterResults;
|
|
631
|
-
result.visibility_state =
|
|
728
|
+
result.visibility_state = group;
|
|
729
|
+
result.confidence_override_active =
|
|
730
|
+
hadBlocksBefore && group !== ResultGroup.NO_MATCH && group !== ResultGroup.EXCLUDED ? true : undefined;
|
|
632
731
|
// Maintain backward compatibility with old fields
|
|
633
732
|
result.hide_reasons = allFilterResults
|
|
634
733
|
.filter((fr) => fr.severity === FilterSeverity.BLOCK || fr.severity === FilterSeverity.WARNING)
|
|
@@ -636,67 +735,41 @@ export class FilterEngine {
|
|
|
636
735
|
result.hide_override_reasons = allFilterResults
|
|
637
736
|
.filter((fr) => fr.metadata?.isOverride)
|
|
638
737
|
.map((fr) => fr.message);
|
|
639
|
-
result.ignore_result =
|
|
738
|
+
result.ignore_result = group === ResultGroup.NO_MATCH || group === ResultGroup.EXCLUDED;
|
|
640
739
|
return result;
|
|
641
740
|
});
|
|
642
741
|
}
|
|
643
742
|
/**
|
|
644
|
-
* Calculate
|
|
743
|
+
* Calculate which result group a scrape result belongs to.
|
|
744
|
+
* MATCH — no manual blocks, and either an override is present or no blocks at all
|
|
745
|
+
* LOW_LIKELIHOOD — no overrides, medium_confidence is the only block (worth reviewing if cheaper)
|
|
746
|
+
* NO_MATCH — manual/security block present, or no overrides and more than one active block
|
|
747
|
+
*
|
|
748
|
+
* Key rule: any override (high_confidence_override, match_confirmation_override, sku_match, etc.)
|
|
749
|
+
* supersedes ALL operational blocks (out_of_stock, price outliers, mismatch rules, etc.).
|
|
750
|
+
* Only explicit admin decisions in MANUAL_EXCLUSION_RULE_IDS can block an overridden result.
|
|
645
751
|
*/
|
|
646
|
-
|
|
647
|
-
if (filterResults.length === 0) {
|
|
648
|
-
return VisibilityState.VISIBLE;
|
|
649
|
-
}
|
|
650
|
-
// Check for override results
|
|
752
|
+
calculateGroup(filterResults, resultPrice, variantPrice) {
|
|
651
753
|
const overrideResults = filterResults.filter((fr) => fr.metadata?.isOverride);
|
|
652
754
|
const blockResults = filterResults.filter((fr) => fr.severity === FilterSeverity.BLOCK);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
return
|
|
665
|
-
});
|
|
666
|
-
if (nonOverridableBlocks.length > 0) {
|
|
667
|
-
return VisibilityState.HIDDEN;
|
|
668
|
-
}
|
|
669
|
-
// Check for overridable blocks
|
|
670
|
-
const overridableBlocks = blockResults.filter((br) => {
|
|
671
|
-
const rule = this.registry.getRule(br.ruleId);
|
|
672
|
-
if (!rule || !rule.canBeOverridden)
|
|
673
|
-
return false;
|
|
674
|
-
const canBeOverridden = overrideResults.some((or) => {
|
|
675
|
-
const overrideRule = this.registry.getRule(or.ruleId);
|
|
676
|
-
return overrideRule && rule.overridableBy.includes(overrideRule.id);
|
|
677
|
-
});
|
|
678
|
-
return !canBeOverridden; // Still blocked if no override present
|
|
679
|
-
});
|
|
680
|
-
if (overridableBlocks.length > 0) {
|
|
681
|
-
return VisibilityState.HIDDEN;
|
|
682
|
-
}
|
|
683
|
-
// Check for warnings that haven't been overridden
|
|
684
|
-
const nonOverriddenWarnings = warningResults.filter((wr) => {
|
|
685
|
-
const rule = this.registry.getRule(wr.ruleId);
|
|
686
|
-
if (!rule || !rule.canBeOverridden)
|
|
687
|
-
return true;
|
|
688
|
-
// Check if any override can override this warning
|
|
689
|
-
const canBeOverridden = overrideResults.some((or) => {
|
|
690
|
-
const overrideRule = this.registry.getRule(or.ruleId);
|
|
691
|
-
return overrideRule && rule.overridableBy.includes(overrideRule.id);
|
|
692
|
-
});
|
|
693
|
-
return !canBeOverridden; // Still a warning if no override present
|
|
694
|
-
});
|
|
695
|
-
// If we have non-overridden warnings, show with warnings
|
|
696
|
-
if (nonOverriddenWarnings.length > 0) {
|
|
697
|
-
return VisibilityState.VISIBLE_WITH_WARNINGS;
|
|
755
|
+
// Manual and security exclusions are always respected, regardless of confidence or overrides.
|
|
756
|
+
const hasManualBlock = blockResults.some((br) => MANUAL_EXCLUSION_RULE_IDS.includes(br.ruleId));
|
|
757
|
+
if (hasManualBlock)
|
|
758
|
+
return ResultGroup.EXCLUDED;
|
|
759
|
+
// Any override (high_confidence_override, match_confirmation_override, sku_match, etc.)
|
|
760
|
+
// supersedes all remaining operational blocks — high-confidence / confirmed results always show.
|
|
761
|
+
if (overrideResults.length > 0) {
|
|
762
|
+
return ResultGroup.MATCH;
|
|
763
|
+
}
|
|
764
|
+
// No overrides present — with nothing to cancel them, all remaining blocks are active.
|
|
765
|
+
if (blockResults.length === 0) {
|
|
766
|
+
return ResultGroup.MATCH;
|
|
698
767
|
}
|
|
699
|
-
|
|
768
|
+
// Medium confidence is the only block → low likelihood (worth reviewing if cheaper)
|
|
769
|
+
if (blockResults.length === 1 && blockResults[0].ruleId === 'medium_confidence') {
|
|
770
|
+
return ResultGroup.LOW_LIKELIHOOD;
|
|
771
|
+
}
|
|
772
|
+
return ResultGroup.NO_MATCH;
|
|
700
773
|
}
|
|
701
774
|
}
|
|
702
775
|
// ===============================
|
|
@@ -716,7 +789,7 @@ class HighPriceOutlierRule {
|
|
|
716
789
|
overridableBy = [];
|
|
717
790
|
evaluate(context) {
|
|
718
791
|
const multiplier = TOO_EXPENSIVE_MULTIPLIER;
|
|
719
|
-
const isMoreExpensive = context.result.extracted_price > context.variant.price * multiplier;
|
|
792
|
+
const isMoreExpensive = (context.result.extracted_total ?? context.result.extracted_price ?? 0) > context.variant.price * multiplier;
|
|
720
793
|
if (isMoreExpensive) {
|
|
721
794
|
return {
|
|
722
795
|
ruleId: this.id,
|
|
@@ -724,7 +797,7 @@ class HighPriceOutlierRule {
|
|
|
724
797
|
message: HIDE_REASONS.HIGH_PRICE_OUTLIER,
|
|
725
798
|
metadata: {
|
|
726
799
|
variantPrice: context.variant.price,
|
|
727
|
-
resultPrice: context.result.extracted_price,
|
|
800
|
+
resultPrice: context.result.extracted_total ?? context.result.extracted_price ?? 0,
|
|
728
801
|
threshold: multiplier,
|
|
729
802
|
},
|
|
730
803
|
timestamp: new Date().toISOString(),
|
|
@@ -764,6 +837,72 @@ class LowPriceOutlierRule {
|
|
|
764
837
|
return null;
|
|
765
838
|
}
|
|
766
839
|
}
|
|
840
|
+
/**
|
|
841
|
+
* Rule for filtering results that are below an absolute price floor (50% of variant price).
|
|
842
|
+
* This rule cannot be overridden by confidence or any override — it always results in NO_MATCH.
|
|
843
|
+
*/
|
|
844
|
+
class AbsoluteLowPriceOutlierRule {
|
|
845
|
+
id = 'absolute_low_price_outlier';
|
|
846
|
+
name = 'Absolute Low Price Outlier';
|
|
847
|
+
description = 'Filters results below 50% of the variant price — this floor cannot be overridden';
|
|
848
|
+
severity = FilterSeverity.BLOCK;
|
|
849
|
+
priority = 99;
|
|
850
|
+
enabled = true;
|
|
851
|
+
canBeOverridden = false;
|
|
852
|
+
overridableBy = [];
|
|
853
|
+
evaluate(context) {
|
|
854
|
+
const ABSOLUTE_CUTOFF = 0.5;
|
|
855
|
+
const resultPrice = context.result.extracted_total ?? context.result.extracted_price ?? 0;
|
|
856
|
+
const isBelowAbsoluteFloor = resultPrice > 0 && resultPrice < context.variant.price * ABSOLUTE_CUTOFF;
|
|
857
|
+
if (isBelowAbsoluteFloor) {
|
|
858
|
+
return {
|
|
859
|
+
ruleId: this.id,
|
|
860
|
+
severity: this.severity,
|
|
861
|
+
message: HIDE_REASONS.ABSOLUTE_LOW_PRICE_OUTLIER,
|
|
862
|
+
metadata: {
|
|
863
|
+
variantPrice: context.variant.price,
|
|
864
|
+
resultPrice,
|
|
865
|
+
threshold: ABSOLUTE_CUTOFF,
|
|
866
|
+
},
|
|
867
|
+
timestamp: new Date().toISOString(),
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Rule for filtering results above an absolute price ceiling (200% of variant price).
|
|
875
|
+
* This rule cannot be overridden by confidence or any override — it always results in NO_MATCH.
|
|
876
|
+
*/
|
|
877
|
+
class AbsoluteHighPriceOutlierRule {
|
|
878
|
+
id = 'absolute_high_price_outlier';
|
|
879
|
+
name = 'Absolute High Price Outlier';
|
|
880
|
+
description = 'Filters results above 200% of the variant price — this ceiling cannot be overridden';
|
|
881
|
+
severity = FilterSeverity.BLOCK;
|
|
882
|
+
priority = 99;
|
|
883
|
+
enabled = true;
|
|
884
|
+
canBeOverridden = false;
|
|
885
|
+
overridableBy = [];
|
|
886
|
+
evaluate(context) {
|
|
887
|
+
const ABSOLUTE_CUTOFF = 2.0;
|
|
888
|
+
const resultPrice = context.result.extracted_total ?? context.result.extracted_price ?? 0;
|
|
889
|
+
const isAbsoluteCeiling = resultPrice > 0 && resultPrice > context.variant.price * ABSOLUTE_CUTOFF;
|
|
890
|
+
if (isAbsoluteCeiling) {
|
|
891
|
+
return {
|
|
892
|
+
ruleId: this.id,
|
|
893
|
+
severity: this.severity,
|
|
894
|
+
message: HIDE_REASONS.ABSOLUTE_HIGH_PRICE_OUTLIER,
|
|
895
|
+
metadata: {
|
|
896
|
+
variantPrice: context.variant.price,
|
|
897
|
+
resultPrice,
|
|
898
|
+
threshold: ABSOLUTE_CUTOFF,
|
|
899
|
+
},
|
|
900
|
+
timestamp: new Date().toISOString(),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
767
906
|
/**
|
|
768
907
|
* Rule for filtering outdated listings
|
|
769
908
|
*/
|
|
@@ -807,7 +946,7 @@ class CompetitorExclusionRule {
|
|
|
807
946
|
priority = 80;
|
|
808
947
|
enabled = true;
|
|
809
948
|
canBeOverridden = true;
|
|
810
|
-
overridableBy = ['match_confirmation_override'];
|
|
949
|
+
overridableBy = ['match_confirmation_override', 'high_confidence_override'];
|
|
811
950
|
evaluate(context) {
|
|
812
951
|
const lowerSource = context.result.source.toLowerCase();
|
|
813
952
|
const isExcluded = context.variantScrapeOptions.competitor_exclusions.some((exclusion) => exclusion && lowerSource === exclusion.toLowerCase());
|
|
@@ -834,7 +973,13 @@ class AIComparisonMismatchRule {
|
|
|
834
973
|
priority = 85;
|
|
835
974
|
enabled = true;
|
|
836
975
|
canBeOverridden = true;
|
|
837
|
-
overridableBy = [
|
|
976
|
+
overridableBy = [
|
|
977
|
+
'sku_match',
|
|
978
|
+
'calculated_sku_match',
|
|
979
|
+
'alt_sku_match',
|
|
980
|
+
'match_confirmation_override',
|
|
981
|
+
'high_confidence_override',
|
|
982
|
+
];
|
|
838
983
|
evaluate(context) {
|
|
839
984
|
// Only applies to results with listing_hash (primarily immersive results)
|
|
840
985
|
const result = context.result;
|
|
@@ -901,7 +1046,13 @@ class SearchExclusionRule {
|
|
|
901
1046
|
priority = 60;
|
|
902
1047
|
enabled = true;
|
|
903
1048
|
canBeOverridden = true;
|
|
904
|
-
overridableBy = [
|
|
1049
|
+
overridableBy = [
|
|
1050
|
+
'sku_match',
|
|
1051
|
+
'calculated_sku_match',
|
|
1052
|
+
'alt_sku_match',
|
|
1053
|
+
'match_confirmation_override',
|
|
1054
|
+
'high_confidence_override',
|
|
1055
|
+
];
|
|
905
1056
|
evaluate(context) {
|
|
906
1057
|
// This is handled in batch processing due to FlexSearch requirement
|
|
907
1058
|
return null;
|
|
@@ -919,7 +1070,7 @@ class SkipSkuRule {
|
|
|
919
1070
|
priority = 50;
|
|
920
1071
|
enabled = true;
|
|
921
1072
|
canBeOverridden = true;
|
|
922
|
-
overridableBy = ['sku_match', 'calculated_sku_match', 'alt_sku_match'];
|
|
1073
|
+
overridableBy = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'high_confidence_override'];
|
|
923
1074
|
evaluate(context) {
|
|
924
1075
|
const variantSkuLower = context.variant.sku.toLowerCase();
|
|
925
1076
|
const allSkus = [
|
|
@@ -971,7 +1122,13 @@ class VendorExclusionRule {
|
|
|
971
1122
|
priority = 50;
|
|
972
1123
|
enabled = true;
|
|
973
1124
|
canBeOverridden = true;
|
|
974
|
-
overridableBy = [
|
|
1125
|
+
overridableBy = [
|
|
1126
|
+
'sku_match',
|
|
1127
|
+
'calculated_sku_match',
|
|
1128
|
+
'alt_sku_match',
|
|
1129
|
+
'match_confirmation_override',
|
|
1130
|
+
'high_confidence_override',
|
|
1131
|
+
];
|
|
975
1132
|
// evaluate(context: FilterContext): FilterResult | null {
|
|
976
1133
|
// const nots: string[] = []
|
|
977
1134
|
// const formatted = context.vendorScrapeOptions.default_skip_vendors
|
|
@@ -1102,7 +1259,13 @@ class BrandMismatchRule {
|
|
|
1102
1259
|
priority = 40;
|
|
1103
1260
|
enabled = true;
|
|
1104
1261
|
canBeOverridden = true;
|
|
1105
|
-
overridableBy = [
|
|
1262
|
+
overridableBy = [
|
|
1263
|
+
'sku_match',
|
|
1264
|
+
'calculated_sku_match',
|
|
1265
|
+
'alt_sku_match',
|
|
1266
|
+
'match_confirmation_override',
|
|
1267
|
+
'high_confidence_override',
|
|
1268
|
+
];
|
|
1106
1269
|
evaluate(context) {
|
|
1107
1270
|
if (!context.result.brand) {
|
|
1108
1271
|
return null;
|
|
@@ -1165,7 +1328,13 @@ class CalculatedSkuMismatchRule {
|
|
|
1165
1328
|
priority = 30;
|
|
1166
1329
|
enabled = true;
|
|
1167
1330
|
canBeOverridden = true;
|
|
1168
|
-
overridableBy = [
|
|
1331
|
+
overridableBy = [
|
|
1332
|
+
'sku_match',
|
|
1333
|
+
'calculated_sku_match',
|
|
1334
|
+
'alt_sku_match',
|
|
1335
|
+
'match_confirmation_override',
|
|
1336
|
+
'high_confidence_override',
|
|
1337
|
+
];
|
|
1169
1338
|
evaluate(context) {
|
|
1170
1339
|
const result = context.result;
|
|
1171
1340
|
if (!result.calculated_sku) {
|
|
@@ -1208,7 +1377,13 @@ class AISkuMismatchRule {
|
|
|
1208
1377
|
priority = 30;
|
|
1209
1378
|
enabled = true;
|
|
1210
1379
|
canBeOverridden = true;
|
|
1211
|
-
overridableBy = [
|
|
1380
|
+
overridableBy = [
|
|
1381
|
+
'sku_match',
|
|
1382
|
+
'calculated_sku_match',
|
|
1383
|
+
'alt_sku_match',
|
|
1384
|
+
'match_confirmation_override',
|
|
1385
|
+
'high_confidence_override',
|
|
1386
|
+
];
|
|
1212
1387
|
evaluate(context) {
|
|
1213
1388
|
const result = context.result;
|
|
1214
1389
|
if (!result.ai_extracted_sku) {
|
|
@@ -1251,7 +1426,13 @@ class CriticalSpecMismatchRule {
|
|
|
1251
1426
|
priority = 35;
|
|
1252
1427
|
enabled = true;
|
|
1253
1428
|
canBeOverridden = true;
|
|
1254
|
-
overridableBy = [
|
|
1429
|
+
overridableBy = [
|
|
1430
|
+
'sku_match',
|
|
1431
|
+
'calculated_sku_match',
|
|
1432
|
+
'alt_sku_match',
|
|
1433
|
+
'match_confirmation_override',
|
|
1434
|
+
'high_confidence_override',
|
|
1435
|
+
];
|
|
1255
1436
|
evaluate(context) {
|
|
1256
1437
|
if (!context.result.regexUnitResults || !context.variant.regexUnitResults) {
|
|
1257
1438
|
return null;
|
|
@@ -1529,6 +1710,45 @@ class MatchConfirmationOverrideRule {
|
|
|
1529
1710
|
};
|
|
1530
1711
|
}
|
|
1531
1712
|
}
|
|
1713
|
+
class LowConfidenceRule {
|
|
1714
|
+
id = 'low_confidence';
|
|
1715
|
+
name = 'Low Confidence Match';
|
|
1716
|
+
description = 'Result did not meet the minimum matching confidence threshold';
|
|
1717
|
+
severity = FilterSeverity.BLOCK;
|
|
1718
|
+
priority = 200;
|
|
1719
|
+
enabled = true;
|
|
1720
|
+
canBeOverridden = true;
|
|
1721
|
+
overridableBy = ['match_confirmation_override'];
|
|
1722
|
+
evaluate() {
|
|
1723
|
+
return null; // injected synthetically by FilterEngine.evaluateBatch
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
class MediumConfidenceRule {
|
|
1727
|
+
id = 'medium_confidence';
|
|
1728
|
+
name = 'Medium Confidence Match';
|
|
1729
|
+
description = 'Result meets the review confidence threshold but not the display threshold';
|
|
1730
|
+
severity = FilterSeverity.BLOCK;
|
|
1731
|
+
priority = 200;
|
|
1732
|
+
enabled = true;
|
|
1733
|
+
canBeOverridden = true;
|
|
1734
|
+
overridableBy = ['match_confirmation_override'];
|
|
1735
|
+
evaluate() {
|
|
1736
|
+
return null; // injected synthetically by FilterEngine.evaluateBatch
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
class HighConfidenceOverrideRule {
|
|
1740
|
+
id = 'high_confidence_override';
|
|
1741
|
+
name = 'High Confidence Override';
|
|
1742
|
+
description = 'Injected when matching-engine confidence meets the display threshold; cancels soft blocks via overridableBy';
|
|
1743
|
+
severity = FilterSeverity.INFO;
|
|
1744
|
+
priority = 200;
|
|
1745
|
+
enabled = true;
|
|
1746
|
+
canBeOverridden = false;
|
|
1747
|
+
overridableBy = [];
|
|
1748
|
+
evaluate() {
|
|
1749
|
+
return null; // injected synthetically by FilterEngine.evaluateBatch
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1532
1752
|
// ===============================
|
|
1533
1753
|
// Registry Factory
|
|
1534
1754
|
// ===============================
|
|
@@ -1540,6 +1760,8 @@ export function createDefaultFilterRegistry() {
|
|
|
1540
1760
|
// Register all filter rules
|
|
1541
1761
|
registry.registerRule(new HighPriceOutlierRule());
|
|
1542
1762
|
registry.registerRule(new LowPriceOutlierRule());
|
|
1763
|
+
registry.registerRule(new AbsoluteLowPriceOutlierRule());
|
|
1764
|
+
registry.registerRule(new AbsoluteHighPriceOutlierRule());
|
|
1543
1765
|
registry.registerRule(new DateOutlierRule());
|
|
1544
1766
|
registry.registerRule(new CompetitorExclusionRule());
|
|
1545
1767
|
registry.registerRule(new AIComparisonMismatchRule());
|
|
@@ -1562,6 +1784,10 @@ export function createDefaultFilterRegistry() {
|
|
|
1562
1784
|
registry.registerRule(new AltSkuMatchOverrideRule());
|
|
1563
1785
|
registry.registerRule(new ProductIdMatchOverrideRule());
|
|
1564
1786
|
registry.registerRule(new MatchConfirmationOverrideRule());
|
|
1787
|
+
// Register confidence-tier rules (injected synthetically in FilterEngine.evaluateBatch)
|
|
1788
|
+
registry.registerRule(new LowConfidenceRule());
|
|
1789
|
+
registry.registerRule(new MediumConfidenceRule());
|
|
1790
|
+
registry.registerRule(new HighConfidenceOverrideRule());
|
|
1565
1791
|
return registry;
|
|
1566
1792
|
}
|
|
1567
1793
|
/**
|
|
@@ -1653,7 +1879,7 @@ export function createCustomFilterRule(config) {
|
|
|
1653
1879
|
* Filter scrape results using the new rule-based engine
|
|
1654
1880
|
* This provides more flexibility and better extensibility than the legacy filterScrapeResults
|
|
1655
1881
|
*/
|
|
1656
|
-
export const filterScrapeResultsV2 = ({ scrapeResults, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, filterConfig, customRules, comparisonMap, matchConfirmationMap, }) => {
|
|
1882
|
+
export const filterScrapeResultsV2 = ({ scrapeResults, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, filterConfig, customRules, comparisonMap, matchConfirmationMap, matchResult, }) => {
|
|
1657
1883
|
if (scrapeResults.length === 0 || !scrapeResults) {
|
|
1658
1884
|
return [];
|
|
1659
1885
|
}
|
|
@@ -1671,7 +1897,7 @@ export const filterScrapeResultsV2 = ({ scrapeResults, variant, variantScrapeOpt
|
|
|
1671
1897
|
let results = handleDuplicatesV2(scrapeResults);
|
|
1672
1898
|
results = handleSearchExclusionsV2(results, variantScrapeOptions, variant, registry);
|
|
1673
1899
|
// Evaluate all other rules
|
|
1674
|
-
results = engine.evaluateBatch(results, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, comparisonMap, matchConfirmationMap);
|
|
1900
|
+
results = engine.evaluateBatch(results, variant, variantScrapeOptions, vendorScrapeOptions, globalScrapeOptions, comparisonMap, matchConfirmationMap, matchResult);
|
|
1675
1901
|
return results;
|
|
1676
1902
|
};
|
|
1677
1903
|
/**
|
|
@@ -2265,67 +2491,50 @@ export function findUnitVariations(data) {
|
|
|
2265
2491
|
* confidence is at or above overrideThreshold.
|
|
2266
2492
|
* Results are sorted by price ascending by default; pass sortDirection to override.
|
|
2267
2493
|
*/
|
|
2268
|
-
export function getShownResults(
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
if (candidate.confidence >= minConfidence) {
|
|
2273
|
-
if (!foundResult)
|
|
2274
|
-
return;
|
|
2275
|
-
foundResult.candidate_result = candidate;
|
|
2276
|
-
const decision = evaluateConfidenceOverride(foundResult.filter_results ?? [], foundResult.visibility_state ?? VisibilityState.VISIBLE, candidate.confidence, overrideThreshold);
|
|
2277
|
-
if (decision === 'hide')
|
|
2278
|
-
return;
|
|
2279
|
-
if (decision === 'show_with_override') {
|
|
2280
|
-
foundResult.confidence_override_active = true;
|
|
2281
|
-
}
|
|
2282
|
-
shownResults.push(foundResult);
|
|
2283
|
-
}
|
|
2284
|
-
else if (foundResult &&
|
|
2285
|
-
foundResult.filter_results?.some((fr) => fr?.metadata?.isOverride === true)
|
|
2286
|
-
// foundResult.filter_results?.map((fr: FilterResult) => fr.ruleId).includes('match_confirmation_override')
|
|
2287
|
-
) {
|
|
2288
|
-
shownResults.push(foundResult);
|
|
2289
|
-
}
|
|
2290
|
-
});
|
|
2291
|
-
shownResults.sort((a, b) => {
|
|
2494
|
+
export function getShownResults(sortedResults, sortDirection = 'asc') {
|
|
2495
|
+
return [...sortedResults]
|
|
2496
|
+
.filter((r) => r.visibility_state === ResultGroup.MATCH)
|
|
2497
|
+
.sort((a, b) => {
|
|
2292
2498
|
const aPrice = a.extracted_total || a.extracted_price || 0;
|
|
2293
2499
|
const bPrice = b.extracted_total || b.extracted_price || 0;
|
|
2294
2500
|
return sortDirection === 'asc' ? aPrice - bPrice : bPrice - aPrice;
|
|
2295
2501
|
});
|
|
2296
|
-
return shownResults.filter((r) => r.source !== 'FactoryPure');
|
|
2297
2502
|
}
|
|
2298
2503
|
/**
|
|
2299
|
-
*
|
|
2300
|
-
*
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2504
|
+
* Extracts undercut results from the MATCH group — competitors selling strictly cheaper
|
|
2505
|
+
* than the variant price (price < variantPrice).
|
|
2506
|
+
*
|
|
2507
|
+
* This is the downstream step that divides getShownResults output into two categories:
|
|
2508
|
+
* - Undercuts: price < variantPrice (competitors beating our price)
|
|
2509
|
+
* - Other matches: price ≥ variantPrice (competitors at or above our price)
|
|
2510
|
+
*
|
|
2511
|
+
* @param shownResults Output of getShownResults (already filtered to MATCH group)
|
|
2512
|
+
* @param variantPrice The variant's current selling price
|
|
2513
|
+
*/
|
|
2514
|
+
export function getUndercutResults(shownResults, variantPrice) {
|
|
2515
|
+
return shownResults.filter((r) => {
|
|
2516
|
+
const price = r.extracted_total || r.extracted_price || 0;
|
|
2517
|
+
const isOutOfStock = r.filter_results?.some((fr) => fr.ruleId === 'out_of_stock');
|
|
2518
|
+
return price > 0 && r.source !== 'FactoryPure' && price <= variantPrice && !isOutOfStock;
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Derives the needs-review results from sortedResults.
|
|
2523
|
+
* Returns LOW_LIKELIHOOD results (medium confidence, no other active blocks) that are
|
|
2524
|
+
* cheaper than both the variant price and the cheapest shown result.
|
|
2304
2525
|
* Results are sorted by price ascending.
|
|
2305
2526
|
*/
|
|
2306
|
-
export function getNeedsReviewResults(
|
|
2527
|
+
export function getNeedsReviewResults(sortedResults, shownResults, variantPrice) {
|
|
2307
2528
|
const firstValidResult = shownResults[0];
|
|
2308
2529
|
const cheapestShownPrice = firstValidResult?.extracted_total ?? firstValidResult?.extracted_price ?? Infinity;
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
if (
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
if (priceTotal < variantPrice && priceTotal < cheapestShownPrice) {
|
|
2320
|
-
foundResult.candidate_result = candidate;
|
|
2321
|
-
needsReviewResults.push(foundResult);
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2324
|
-
});
|
|
2325
|
-
needsReviewResults.sort((a, b) => {
|
|
2326
|
-
const aPrice = a.extracted_total || a.extracted_price || 0;
|
|
2327
|
-
const bPrice = b.extracted_total || b.extracted_price || 0;
|
|
2328
|
-
return aPrice - bPrice;
|
|
2329
|
-
});
|
|
2330
|
-
return needsReviewResults.filter((r) => r.source !== 'FactoryPure');
|
|
2530
|
+
return sortedResults
|
|
2531
|
+
.filter((r) => {
|
|
2532
|
+
if (r.visibility_state !== ResultGroup.LOW_LIKELIHOOD)
|
|
2533
|
+
return false;
|
|
2534
|
+
if (r.source === 'FactoryPure')
|
|
2535
|
+
return false;
|
|
2536
|
+
const price = r.extracted_total || r.extracted_price || 0;
|
|
2537
|
+
return price > 0 && r.source !== 'FactoryPure' && price <= variantPrice && price <= cheapestShownPrice;
|
|
2538
|
+
})
|
|
2539
|
+
.sort((a, b) => (a.extracted_total || a.extracted_price || 0) - (b.extracted_total || b.extracted_price || 0));
|
|
2331
2540
|
}
|