@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.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(VisibilityState).optional(),
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(VisibilityState).optional(),
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
- * Evaluates whether a result should be surfaced given its filter state and matching confidence.
502
- *
503
- * Returns:
504
- * - `'hide'` — result must not be shown
505
- * - `'show'` — result passes all filters normally
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
- const allFilterResults = [...existingFilterResults, ...filterResults];
628
- const visibilityState = this.calculateVisibility(allFilterResults);
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 = visibilityState;
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 = visibilityState === VisibilityState.HIDDEN;
738
+ result.ignore_result = group === ResultGroup.NO_MATCH || group === ResultGroup.EXCLUDED;
640
739
  return result;
641
740
  });
642
741
  }
643
742
  /**
644
- * Calculate visibility state based on filter results
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
- calculateVisibility(filterResults) {
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
- const warningResults = filterResults.filter((fr) => fr.severity === FilterSeverity.WARNING);
654
- // Block results that cannot be overridden
655
- const nonOverridableBlocks = blockResults.filter((br) => {
656
- const rule = this.registry.getRule(br.ruleId);
657
- if (!rule || !rule.canBeOverridden)
658
- return true;
659
- // Check if any override can override this block
660
- const canBeOverridden = overrideResults.some((or) => {
661
- const overrideRule = this.registry.getRule(or.ruleId);
662
- return overrideRule && rule.overridableBy.includes(overrideRule.id);
663
- });
664
- return !canBeOverridden;
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
- return VisibilityState.VISIBLE;
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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 = ['sku_match', 'calculated_sku_match', 'alt_sku_match', 'match_confirmation_override'];
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(matchResult, sortedResults, minConfidence, sortDirection = 'asc', overrideThreshold = 80) {
2269
- const shownResults = [];
2270
- matchResult.candidates.forEach((candidate) => {
2271
- const foundResult = sortedResults.find((res) => res.id.toString() === candidate.candidateId.toString());
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
- * Derives the needs-review results from a MatchResult.
2300
- * Includes medium-confidence candidates (reviewConfidenceMin <= confidence < minConfidence)
2301
- * that are cheaper than both the variant price and the cheapest shown result.
2302
- * Also includes high-confidence candidates whose visibility_state is not VISIBLE but meet
2303
- * the same price conditions.
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(matchResult, sortedResults, shownResults, variantPrice, minConfidence, reviewConfidenceMin) {
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
- const needsReviewResults = [];
2310
- matchResult.candidates.forEach((candidate) => {
2311
- if (candidate.confidence >= reviewConfidenceMin && candidate.confidence < minConfidence) {
2312
- const foundResult = sortedResults.find((res) => res.id.toString() === candidate.candidateId.toString());
2313
- if (!foundResult)
2314
- return;
2315
- const hasAbsoluteBlock = (foundResult.filter_results ?? []).some((fr) => ABSOLUTE_BLOCK_RULE_IDS.includes(fr.ruleId));
2316
- if (hasAbsoluteBlock)
2317
- return;
2318
- const priceTotal = foundResult.extracted_total || foundResult.extracted_price || 0;
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
  }