Compare commits
No commits in common. "f7d5b20aa5a1bc2cd8bef1771e8848b7e7c32e75" and "ccbbe58bd4b7f7d47d55ce65c9582ee099f5d296" have entirely different histories.
f7d5b20aa5
...
ccbbe58bd4
6 changed files with 5 additions and 109 deletions
22
.env.example
22
.env.example
|
|
@ -54,28 +54,6 @@ SNIPE_DB=data/snipe.db
|
||||||
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
# own ID; the CF cloud instance uses CF's campaign ID (disclosed in the UI).
|
||||||
# EBAY_AFFILIATE_CAMPAIGN_ID=
|
# 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) ────────────────────────────────────────────────────
|
# ── In-app feedback (beta) ────────────────────────────────────────────────────
|
||||||
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
|
||||||
# Leave unset to silently hide the button (demo/offline deployments).
|
# Leave unset to silently hide the button (demo/offline deployments).
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ class TrustScorer:
|
||||||
signal_scores, is_dup, seller,
|
signal_scores, is_dup, seller,
|
||||||
listing_id=listing.id or 0,
|
listing_id=listing.id or 0,
|
||||||
listing_title=listing.title,
|
listing_title=listing.title,
|
||||||
listing_condition=listing.condition,
|
|
||||||
times_seen=listing.times_seen,
|
times_seen=listing.times_seen,
|
||||||
first_seen_at=listing.first_seen_at,
|
first_seen_at=listing.first_seen_at,
|
||||||
price=listing.price,
|
price=listing.price,
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ _SCRATCH_DENT_KEYWORDS = frozenset([
|
||||||
"crack", "cracked", "chip", "chipped",
|
"crack", "cracked", "chip", "chipped",
|
||||||
"damage", "damaged", "cosmetic damage",
|
"damage", "damaged", "cosmetic damage",
|
||||||
"blemish", "wear", "worn", "worn in",
|
"blemish", "wear", "worn", "worn in",
|
||||||
# Parts / condition catch-alls (also matches eBay condition field strings verbatim)
|
# Parts / condition catch-alls
|
||||||
"as is", "for parts", "parts only", "spares or repair", "parts or repair",
|
"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
|
# Evasive redirects — seller hiding damage detail in listing body
|
||||||
"see description", "read description", "read listing", "see listing",
|
"see description", "read description", "read listing", "see listing",
|
||||||
"see photos for", "see pics for", "see images for",
|
"see photos for", "see pics for", "see images for",
|
||||||
|
|
@ -73,7 +72,6 @@ class Aggregator:
|
||||||
seller: Optional[Seller],
|
seller: Optional[Seller],
|
||||||
listing_id: int = 0,
|
listing_id: int = 0,
|
||||||
listing_title: str = "",
|
listing_title: str = "",
|
||||||
listing_condition: str = "",
|
|
||||||
times_seen: int = 1,
|
times_seen: int = 1,
|
||||||
first_seen_at: Optional[str] = None,
|
first_seen_at: Optional[str] = None,
|
||||||
price: float = 0.0,
|
price: float = 0.0,
|
||||||
|
|
@ -139,9 +137,7 @@ class Aggregator:
|
||||||
)
|
)
|
||||||
if photo_hash_duplicate and not is_established_retailer:
|
if photo_hash_duplicate and not is_established_retailer:
|
||||||
red_flags.append("duplicate_photo")
|
red_flags.append("duplicate_photo")
|
||||||
if (listing_title and _has_damage_keywords(listing_title)) or (
|
if listing_title and _has_damage_keywords(listing_title):
|
||||||
listing_condition and _has_damage_keywords(listing_condition)
|
|
||||||
):
|
|
||||||
red_flags.append("scratch_dent_mentioned")
|
red_flags.append("scratch_dent_mentioned")
|
||||||
|
|
||||||
# Staging DB signals
|
# Staging DB signals
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ version = "0.1.0"
|
||||||
description = "Auction listing monitor and trust scorer"
|
description = "Auction listing monitor and trust scorer"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"circuitforge-core>=0.8.0",
|
"circuitforge-core>=0.7.0",
|
||||||
"streamlit>=1.32",
|
"streamlit>=1.32",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"imagehash>=4.3",
|
"imagehash>=4.3",
|
||||||
|
|
|
||||||
|
|
@ -80,45 +80,6 @@ def test_suspicious_price_flagged_when_price_genuinely_low():
|
||||||
assert "suspicious_price" in result.red_flags_json
|
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():
|
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."""
|
"""account_age_days=None (scraper tier) must NOT trigger new_account or account_under_30_days."""
|
||||||
agg = Aggregator()
|
agg = Aggregator()
|
||||||
|
|
|
||||||
|
|
@ -91,17 +91,6 @@
|
||||||
aria-label="Search filters"
|
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 ─────────────────────────────────────── -->
|
<!-- ── eBay Search Parameters ─────────────────────────────────────── -->
|
||||||
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
<!-- These are sent to eBay. Changes require a new search to take effect. -->
|
||||||
<h2 class="filter-section-heading filter-section-heading--search">
|
<h2 class="filter-section-heading filter-section-heading--search">
|
||||||
|
|
@ -416,7 +405,7 @@ onMounted(() => {
|
||||||
|
|
||||||
// ── Filters ──────────────────────────────────────────────────────────────────
|
// ── Filters ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_FILTERS: SearchFilters = {
|
const filters = reactive<SearchFilters>({
|
||||||
minTrustScore: 0,
|
minTrustScore: 0,
|
||||||
minPrice: undefined,
|
minPrice: undefined,
|
||||||
maxPrice: undefined,
|
maxPrice: undefined,
|
||||||
|
|
@ -435,13 +424,7 @@ const DEFAULT_FILTERS: SearchFilters = {
|
||||||
mustExclude: '',
|
mustExclude: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
adapter: 'auto' as 'auto' | 'api' | 'scraper',
|
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
|
// Parse comma-separated keyword strings into trimmed, lowercase, non-empty term arrays
|
||||||
const parsedMustInclude = computed(() =>
|
const parsedMustInclude = computed(() =>
|
||||||
|
|
@ -784,27 +767,6 @@ async function onSearch() {
|
||||||
margin-bottom: var(--space-2);
|
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 */
|
/* Section headings that separate eBay Search params from local filters */
|
||||||
.filter-section-heading {
|
.filter-section-heading {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue