From bccedb1fe52c7338bd8c8bb20bc9362535038aa8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 4 May 2026 09:24:27 -0700 Subject: [PATCH] fix(trust): treat feedback_ratio=0.0 as missing data for buyer-only/returning sellers (#52) eBay omits the 12-month positive percentage for returning sellers and buyer-only accounts with no recent sales. Previously ratio=0.0 with count>0 triggered established_bad_actor; now it returns None from the scorer (score_is_partial=True) and emits a soft no_recent_seller_data flag instead. ratio=0.0 with count=0 is still treated as no-history. --- app/trust/aggregator.py | 7 ++++++- app/trust/metadata.py | 8 +++++++- tests/trust/test_aggregator.py | 34 ++++++++++++++++++++++++++++++++++ tests/trust/test_metadata.py | 23 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/app/trust/aggregator.py b/app/trust/aggregator.py index f86d0fc..45478bd 100644 --- a/app/trust/aggregator.py +++ b/app/trust/aggregator.py @@ -126,7 +126,12 @@ class Aggregator: # Hard filters if seller and seller.account_age_days is not None and seller.account_age_days < HARD_FILTER_AGE_DAYS: red_flags.append("new_account") - if seller and seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD: + if seller and seller.feedback_ratio == 0.0 and seller.feedback_count > 0: + # 12-month ratio missing from page — returning seller or buyer-only account. + # Score will be partial (metadata._feedback_ratio returns None). Soft flag + # only: do NOT fire established_bad_actor on what is likely missing data. + red_flags.append("no_recent_seller_data") + elif seller and seller.feedback_ratio < HARD_FILTER_BAD_RATIO_THRESHOLD: if HARD_FILTER_BAD_RATIO_MIN_COUNT < seller.feedback_count <= HARD_FILTER_BAD_RATIO_MAX_COUNT: # Moderate-volume account with consistently bad ratio → hard flag. red_flags.append("established_bad_actor") diff --git a/app/trust/metadata.py b/app/trust/metadata.py index f672c6d..5d020df 100644 --- a/app/trust/metadata.py +++ b/app/trust/metadata.py @@ -44,7 +44,13 @@ class MetadataScorer: if count < 200: return 15 return 20 - def _feedback_ratio(self, ratio: float, count: int) -> int: + def _feedback_ratio(self, ratio: float, count: int) -> Optional[int]: + # ratio=0.0 with count>0 means the 12-month percentage wasn't on the page — + # eBay omits the ratio for returning/buyer-only sellers with no recent sales. + # Treat as missing rather than "literally 0% positive" (which eBay doesn't allow + # on active accounts — those get suspended long before reaching 0%). + if ratio == 0.0 and count > 0: + return None if ratio < 0.80 and count > 20: return 0 if ratio < 0.90: return 5 if ratio < 0.95: return 10 diff --git a/tests/trust/test_aggregator.py b/tests/trust/test_aggregator.py index b9877a6..03c2d3e 100644 --- a/tests/trust/test_aggregator.py +++ b/tests/trust/test_aggregator.py @@ -296,3 +296,37 @@ def test_non_retailer_does_not_suppress_duplicate_photo(): ) result = agg.aggregate(_ALL_20.copy(), photo_hash_duplicate=True, seller=seller) assert "duplicate_photo" in result.red_flags_json + + +# ── #52: buyer-only / returning seller (ratio=0.0, count>0) ────────────────── + +def test_zero_ratio_with_count_gives_no_recent_seller_data_flag(): + """Seller with 117 lifetime feedbacks (buyer-only) has ratio=0.0 parsed from page. + Must get no_recent_seller_data soft flag, NOT established_bad_actor.""" + agg = Aggregator() + scores = {k: 10 for k in ["account_age", "feedback_count", + "feedback_ratio", "price_vs_market", "category_history"]} + buyer_only = Seller( + platform="ebay", platform_seller_id="u", username="jjcpryz", + account_age_days=1200, feedback_count=117, feedback_ratio=0.0, + category_history_json="{}", + ) + result = agg.aggregate(scores, photo_hash_duplicate=False, seller=buyer_only) + assert "no_recent_seller_data" in result.red_flags_json + assert "established_bad_actor" not in result.red_flags_json + + + +def test_established_bad_actor_still_fires_for_genuinely_bad_ratio(): + """ratio=0.75 (not zero) with moderate count → established_bad_actor still fires.""" + agg = Aggregator() + scores = {k: 10 for k in ["account_age", "feedback_count", + "feedback_ratio", "price_vs_market", "category_history"]} + bad = Seller( + platform="ebay", platform_seller_id="u", username="u", + account_age_days=500, feedback_count=100, feedback_ratio=0.75, + category_history_json="{}", + ) + result = agg.aggregate(scores, photo_hash_duplicate=False, seller=bad) + assert "established_bad_actor" in result.red_flags_json + assert "no_recent_seller_data" not in result.red_flags_json diff --git a/tests/trust/test_metadata.py b/tests/trust/test_metadata.py index 0be40e1..4f8caf6 100644 --- a/tests/trust/test_metadata.py +++ b/tests/trust/test_metadata.py @@ -43,3 +43,26 @@ def test_no_market_data_returns_none(): scores = scorer.score(_seller(), market_median=None, listing_price=950.0) # None signals "data unavailable" — aggregator will set score_is_partial=True assert scores["price_vs_market"] is None + + +def test_zero_ratio_with_nonzero_count_returns_none(): + """ratio=0.0 with count>0 means eBay didn't show a 12-month percentage. + Must return None (missing data) not 0 (catastrophically bad).""" + scorer = MetadataScorer() + scores = scorer.score( + _seller(feedback_ratio=0.0, feedback_count=117), + market_median=None, listing_price=500.0, + ) + assert scores["feedback_ratio"] is None + + +def test_zero_ratio_with_zero_count_scores_low(): + """feedback_ratio=0.0 with count=0 is a real 'no data at all' case, not missing.""" + scorer = MetadataScorer() + scores = scorer.score( + _seller(feedback_ratio=0.0, feedback_count=0), + market_median=None, listing_price=500.0, + ) + # count=0 means zero_feedback; ratio=0 with count=0 is the standard no-history path + # (not the "missing 12-month window" path) + assert scores["feedback_ratio"] == 5 # ratio < 0.90 → 5