Compare commits

...

5 commits

Author SHA1 Message Date
f7d5b20aa5 chore: bump circuitforge-core dep to >=0.8.0 (orch split) 2026-04-04 22:48:48 -07:00
bdbcb046cc 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
2026-04-04 22:42:56 -07:00
ccbbe58bd4 chore: pin circuitforge-core>=0.7.0 (affiliates + preferences modules) 2026-04-04 19:17:49 -07:00
c5988a059d Merge pull request 'feat: eBay affiliate link builder' (#20) from feature/affiliate-links into main 2026-04-04 19:16:33 -07:00
3f7c2b9135 Merge pull request 'feat: in-app feedback FAB' (#18) from feature/feedback-button into main 2026-04-03 22:01:06 -07:00
6 changed files with 109 additions and 5 deletions

View file

@ -54,6 +54,28 @@ 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).

View file

@ -52,6 +52,7 @@ 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,

View file

@ -23,8 +23,9 @@ _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 # Parts / condition catch-alls (also matches eBay condition field strings verbatim)
"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",
@ -72,6 +73,7 @@ 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,
@ -137,7 +139,9 @@ 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): if (listing_title and _has_damage_keywords(listing_title)) or (
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

View file

@ -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", "circuitforge-core>=0.8.0",
"streamlit>=1.32", "streamlit>=1.32",
"requests>=2.31", "requests>=2.31",
"imagehash>=4.3", "imagehash>=4.3",

View file

@ -80,6 +80,45 @@ 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()

View file

@ -91,6 +91,17 @@
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">
@ -405,7 +416,7 @@ onMounted(() => {
// Filters // Filters
const filters = reactive<SearchFilters>({ const DEFAULT_FILTERS: SearchFilters = {
minTrustScore: 0, minTrustScore: 0,
minPrice: undefined, minPrice: undefined,
maxPrice: undefined, maxPrice: undefined,
@ -424,7 +435,13 @@ const filters = reactive<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(() =>
@ -767,6 +784,27 @@ 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;