fix: detect eBay condition field for parts/repair listings; add clear-filters btn
- aggregator: also check listing.condition against damage keywords so listings with eBay condition "for parts or not working" flag scratch_dent_mentioned even when the title looks clean - aggregator: add "parts/repair" (slash) + "parts or not working" to keyword set - trust/__init__.py: pass listing.condition into aggregate() - 3 new regression tests (synthetic fixtures, 17 total passing) - SearchView: extract DEFAULT_FILTERS const + resetFilters(); add "Clear filters" button that shows only when activeFilterCount > 0 with count badge - .env.example: document LLM inference env vars (ANTHROPIC/OPENAI/OLLAMA/CF_ORCH_URL) and cf-core wiring notes; closes #17
This commit is contained in:
parent
ccbbe58bd4
commit
bdbcb046cc
5 changed files with 108 additions and 4 deletions
22
.env.example
22
.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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@
|
|||
aria-label="Search filters"
|
||||
>
|
||||
|
||||
<!-- Clear all filters — only shown when at least one filter is active -->
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
type="button"
|
||||
class="filter-clear-btn"
|
||||
@click="resetFilters"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
✕ Clear filters ({{ activeFilterCount }})
|
||||
</button>
|
||||
|
||||
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||
<h2 class="filter-section-heading filter-section-heading--search">
|
||||
|
|
@ -405,7 +416,7 @@ onMounted(() => {
|
|||
|
||||
// ── Filters ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const filters = reactive<SearchFilters>({
|
||||
const DEFAULT_FILTERS: SearchFilters = {
|
||||
minTrustScore: 0,
|
||||
minPrice: undefined,
|
||||
maxPrice: undefined,
|
||||
|
|
@ -424,7 +435,13 @@ const filters = reactive<SearchFilters>({
|
|||
mustExclude: '',
|
||||
categoryId: '',
|
||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
||||
})
|
||||
}
|
||||
|
||||
const filters = reactive<SearchFilters>({ ...DEFAULT_FILTERS })
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, DEFAULT_FILTERS)
|
||||
}
|
||||
|
||||
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
||||
const parsedMustInclude = computed(() =>
|
||||
|
|
@ -767,6 +784,27 @@ async function onSearch() {
|
|||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Clear all filters button */
|
||||
.filter-clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
background: color-mix(in srgb, var(--color-red, #ef4444) 12%, transparent);
|
||||
color: var(--color-red, #ef4444);
|
||||
border: 1px solid color-mix(in srgb, var(--color-red, #ef4444) 30%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.filter-clear-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-red, #ef4444) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Section headings that separate eBay Search params from local filters */
|
||||
.filter-section-heading {
|
||||
font-size: 0.6875rem;
|
||||
|
|
|
|||
Loading…
Reference in a new issue