@factorypure/client-helpers 1.2.0 → 1.2.1

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