diff --git a/.env.example b/.env.example index ef7b0cb..e9325e2 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,28 @@ SNIPE_DB=data/snipe.db # own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI). # EBAY_AFFILIATE_CAMPAIGN_ID= +# ── LLM inference (vision / photo analysis) ────────────────────────────────── +# circuitforge-core LLMRouter auto-detects backends from these env vars +# (no llm.yaml required). Backends are tried in this priority order: +# 1. ANTHROPIC_API_KEY → Claude API (cloud; requires Paid tier key) +# 2. OPENAI_API_KEY → OpenAI-compatible endpoint +# 3. OLLAMA_HOST → local Ollama (default: http://localhost:11434) +# Leave all unset to disable LLM features (photo analysis won't run). + +# ANTHROPIC_API_KEY= +# ANTHROPIC_MODEL=claude-haiku-4-5-20251001 + +# OPENAI_API_KEY= +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODEL=gpt-4o-mini + +# OLLAMA_HOST=http://localhost:11434 +# OLLAMA_MODEL=llava:7b + +# CF Orchestrator — managed inference for Paid+ cloud users (internal use only). +# Self-hosted users leave this unset; it has no effect without a valid allocation token. +# CF_ORCH_URL=https://orch.circuitforge.tech + # ── In-app feedback (beta) ──────────────────────────────────────────────────── # When set, a feedback FAB appears in the UI and routes submissions to Forgejo. # Leave unset to silently hide the button (demo/offline deployments). diff --git a/app/trust/__init__.py b/app/trust/__init__.py index 7eb50ff..8caf0a1 100644 --- a/app/trust/__init__.py +++ b/app/trust/__init__.py @@ -52,6 +52,7 @@ class TrustScorer: signal_scores, is_dup, seller, listing_id=listing.id or 0, listing_title=listing.title, + listing_condition=listing.condition, times_seen=listing.times_seen, first_seen_at=listing.first_seen_at, price=listing.price, diff --git a/app/trust/aggregator.py b/app/trust/aggregator.py index b768d62..974c45f 100644 --- a/app/trust/aggregator.py +++ b/app/trust/aggregator.py @@ -23,8 +23,9 @@ _SCRATCH_DENT_KEYWORDS = frozenset([ "crack", "cracked", "chip", "chipped", "damage", "damaged", "cosmetic damage", "blemish", "wear", "worn", "worn in", - # Parts / condition catch-alls + # Parts / condition catch-alls (also matches eBay condition field strings verbatim) "as is", "for parts", "parts only", "spares or repair", "parts or repair", + "parts/repair", "parts or not working", "not working", # Evasive redirects — seller hiding damage detail in listing body "see description", "read description", "read listing", "see listing", "see photos for", "see pics for", "see images for", @@ -72,6 +73,7 @@ class Aggregator: seller: Optional[Seller], listing_id: int = 0, listing_title: str = "", + listing_condition: str = "", times_seen: int = 1, first_seen_at: Optional[str] = None, price: float = 0.0, @@ -137,7 +139,9 @@ class Aggregator: ) if photo_hash_duplicate and not is_established_retailer: red_flags.append("duplicate_photo") - if listing_title and _has_damage_keywords(listing_title): + if (listing_title and _has_damage_keywords(listing_title)) or ( + listing_condition and _has_damage_keywords(listing_condition) + ): red_flags.append("scratch_dent_mentioned") # Staging DB signals diff --git a/tests/trust/test_aggregator.py b/tests/trust/test_aggregator.py index 613fec8..5fff612 100644 --- a/tests/trust/test_aggregator.py +++ b/tests/trust/test_aggregator.py @@ -80,6 +80,45 @@ def test_suspicious_price_flagged_when_price_genuinely_low(): assert "suspicious_price" in result.red_flags_json +def test_scratch_dent_flagged_from_title_slash_variant(): + """Title containing 'parts/repair' (slash variant, no 'or') must trigger scratch_dent_mentioned.""" + agg = Aggregator() + scores = {k: 15 for k in ["account_age", "feedback_count", + "feedback_ratio", "price_vs_market", "category_history"]} + result = agg.aggregate( + scores, photo_hash_duplicate=False, seller=None, + listing_title="Generic Widget XL - Parts/Repair", + ) + assert "scratch_dent_mentioned" in result.red_flags_json + + +def test_scratch_dent_flagged_from_condition_field(): + """eBay formal condition 'for parts or not working' must trigger scratch_dent_mentioned + even when the listing title contains no damage keywords.""" + agg = Aggregator() + scores = {k: 15 for k in ["account_age", "feedback_count", + "feedback_ratio", "price_vs_market", "category_history"]} + result = agg.aggregate( + scores, photo_hash_duplicate=False, seller=None, + listing_title="Generic Widget XL", + listing_condition="for parts or not working", + ) + assert "scratch_dent_mentioned" in result.red_flags_json + + +def test_scratch_dent_not_flagged_for_clean_listing(): + """Clean title + 'New' condition must NOT trigger scratch_dent_mentioned.""" + agg = Aggregator() + scores = {k: 15 for k in ["account_age", "feedback_count", + "feedback_ratio", "price_vs_market", "category_history"]} + result = agg.aggregate( + scores, photo_hash_duplicate=False, seller=None, + listing_title="Generic Widget XL", + listing_condition="new", + ) + assert "scratch_dent_mentioned" not in result.red_flags_json + + def test_new_account_not_flagged_when_age_absent(): """account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days.""" agg = Aggregator() diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index eb47863..9557693 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -91,6 +91,17 @@ aria-label="Search filters" > + + +