Compare commits

..

14 commits

Author SHA1 Message Date
bc930ac1b9 feat(snipe): eBay trust scoring MVP — search, filters, enrichment, comps
Core trust scoring:
- Five metadata signals (account age, feedback count/ratio, price vs market,
  category history), composited 0–100
- CV-based price signal suppression for heterogeneous search results
  (e.g. mixed laptop generations won't false-positive suspicious_price)
- Expanded scratch/dent title detection: evasive redirects, functional problem
  phrases, DIY/repair indicators
- Hard filters: new_account, established_bad_actor
- Soft flags: low_feedback, suspicious_price, duplicate_photo, scratch_dent,
  long_on_market, significant_price_drop

Search & filtering:
- Browse API adapter (up to 200 items/page) + Playwright scraper fallback
- OR-group query expansion for comprehensive variant coverage
- Must-include (AND/ANY/groups), must-exclude, category, price range filters
- Saved searches with full filter round-trip via URL params

Seller enrichment:
- Background BTF /itm/ scraping for account age (Kasada-safe headed Chromium)
- On-demand enrichment: POST /api/enrich + ListingCard ↻ button
- Category history derived from Browse API categories field (free, no extra calls)
- Shopping API GetUserProfile inline enrichment for API adapter

Market comps:
- eBay Marketplace Insights API with Browse API fallback (catches 403 + 404)
- Comps prioritised in ThreadPoolExecutor (submitted first)

Infrastructure:
- Staging DB fields: times_seen, first_seen_at, price_at_first_seen, category_name
- Migrations 004 (staging tracking) + 005 (listing category)
- eBay webhook handler stub
- Cloud compose stack (compose.cloud.yml)
- Vue frontend: search store, saved searches store, ListingCard, filter sidebar

Docs:
- README fully rewritten to reflect MVP status + full feature documentation
- Roadmap table linked to all 13 Forgejo issues
2026-03-26 23:37:09 -07:00
1a06d55f70 feat(snipe): cloud deployment under menagerie.circuitforge.tech/snipe
- compose.cloud.yml: snipe-cloud project, proper Docker bridge network
  (api is internal-only, no host port), port 8514 for nginx
- docker/web/Dockerfile: VITE_BASE_URL + VITE_API_BASE build args so
  Vite bakes the /snipe path prefix into the bundle at cloud build time
- docker/web/nginx.cloud.conf: upstream api:8510 via Docker network
  (vs 172.17.0.1:8510 in dev which uses host networking)
- manage.sh: cloud-start/stop/restart/status/logs/build commands
- stores/search.ts: VITE_API_BASE prefix on all /api fetch calls

Gate: Caddy basicauth (username: cf) — temporary gate while proper
Heimdall license validation UI is built. Password stored at
/devl/snipe-cloud-data/.beta-password (host-only, not in repo).

Note: Caddyfile updated separately (caddy-proxy volume, not this repo).
2026-03-26 08:14:01 -07:00
e7d8a589fb feat(snipe): keyword must-include/must-exclude filtering
- Two sidebar fields: 'Must include' and 'Must exclude' (comma-separated)
- Must-exclude terms forwarded to eBay _nkw as -term prefixes (native eBay
  support) so exclusions reduce the eBay result set at the source — improves
  market comp quality as a side effect
- Must-include applied client-side only (substring, case-insensitive)
- Both applied client-side via passesFilter() for instant response without
  re-fetching (cache-friendly)
- Exclude input has subtle red border tint (color-mix) to signal intent
- Hint text: 're-search to apply to eBay' reminds user negatives need a
  new search to take effect at the eBay level
2026-03-25 22:54:24 -07:00
4d9a945dbd docs: add CLAUDE.md with architecture, gotchas, and pending work 2026-03-25 22:30:14 -07:00
83b68ac435 feat(snipe): parallel search+comps, pagination, title fix, price flag fix
- Parallel execution: search() and get_completed_sales() now run
  concurrently via ThreadPoolExecutor — each gets its own Store/SQLite
  connection for thread safety. First cold search time ~halved.

- Pagination: SearchFilters.pages (default 1) controls how many eBay
  result pages are fetched. Both search and sold-comps support up to 3
  parallel Playwright sessions per call (capped to avoid Xvfb overload).
  UI: segmented 1/2/3/5 pages selector in filter sidebar with cost hint.

- True median: get_completed_sales() now averages the two middle values
  for even-length price lists instead of always taking the lower bound.

- Fix suspicious_price false positive: aggregator now checks
  signal_scores.get("price_vs_market") == 0 (pre-None-substitution)
  so listings without market data are never flagged as suspicious.

- Fix title pollution: scraper strips eBay's hidden screen-reader span
  ("Opens in a new window or tab") from listing titles via regex.
  Lazy-imports playwright/playwright_stealth inside _get() so pure
  parsing functions are importable without the full browser stack.

- Tests: 48 pass on host (scraper tests now runnable without Docker),
  new regression guards for all three bug fixes.
2026-03-25 22:16:08 -07:00
6c16a81bf5 fix: account_age_days=None for scraper tier, stop false new_account flags
Scraper can't fetch seller profile age without following each listing's
seller link. Using 0 as sentinel caused every scraped seller to trigger
new_account and account_under_30_days red flags erroneously.

- Seller.account_age_days: int → Optional[int] (None = not yet fetched)
- Migration 003: recreate sellers table without NOT NULL constraint
- MetadataScorer: return None for unknown age → score_is_partial=True
- Aggregator: gate age flags on is not None
- Scraper: account_age_days=None instead of 0
2026-03-25 20:36:43 -07:00
06601cf672 feat(snipe): FastAPI layer, Playwright+Xvfb scraper, caching, tests
- FastAPI service (port 8510) wrapping scraper + trust scorer
- Playwright+Xvfb+stealth transport to bypass eBay Kasada bot protection
- li.s-card selector migration (eBay markup change from li.s-item)
- Three-layer caching: HTML (5min), phash (permanent), market comp (6h SQLite)
- Batch DB writes (executemany + single commit) — warm requests <1s
- Unique Xvfb display counter (:200–:299) prevents lock file collisions
- Vue 3 nginx web service (port 8509) proxying /api/ to FastAPI
- Auction card de-emphasis: opacity 0.72 for listings with >1h remaining
- 35 scraper unit tests updated for new li.s-card fixture markup
- tests/ volume-mounted in compose.override.yml for live test editing
2026-03-25 20:09:30 -07:00
2ff69cbe9e chore: remove node_modules from tracking 2026-03-25 15:13:06 -07:00
f02c4e9f02 chore: gitignore web/node_modules and web/dist 2026-03-25 15:12:57 -07:00
68a1a9d73c feat(snipe): Vue 3 frontend scaffold + Docker web service
- web/: Vue 3 + Vite + UnoCSS + Pinia, dark tactical theme (amber/#0d1117)
- AppNav, ListingCard, SearchView with filters/sort, composables
  (useSnipeMode, useKonamiCode, useMotion), Pinia search store
- Steal shimmer, auction countdown, Snipe Mode easter egg all native in Vue
- docker/web/: nginx + multi-stage Dockerfile (node build → nginx serve)
- compose.yml: api (8510) + web (8509) services
- Dockerfile CMD updated to uvicorn for upcoming FastAPI layer
- Clean build: 0 TS errors, 380 modules
2026-03-25 15:11:35 -07:00
5ac5777356 fix: rename app/app.py → streamlit_app.py to resolve package shadowing 2026-03-25 15:05:12 -07:00
f4e6f049ac feat(snipe): auction support + easter eggs (Konami, The Steal, de-emphasis)
Auction metadata:
- Listing model gains buying_format + ends_at fields
- Migration 002 adds columns to existing databases
- scraper.py: parse s-item__time-left → absolute ends_at ISO timestamp
- normaliser.py: extract buyingOptions + itemEndDate from Browse API
- store.py: save/get updated for new fields

Easter eggs (app/ui/components/easter_eggs.py):
- Konami code detector (JS → URL param → Streamlit rerun)
- Web Audio API snipe call synthesis, gated behind sidebar checkbox
  (disabled by default for safety/accessibility)
- "The Steal" gold shimmer: trust ≥ 90, price 15–30% below market,
  no suspicious_price flag
- Auction de-emphasis: soft caption when > 1h remaining

UI updates:
- listing_row: steal banner + auction notice per row
- Search: inject CSS, check snipe mode, "Ending soon" sort option,
  pass market_price from comp cache to row renderer
- app.py: Konami detector + audio enable/disable sidebar toggle

Tests: 22 new tests (72 total, all green)
2026-03-25 14:27:02 -07:00
8aaac0c47c feat: add scraper adapter with auto-detect fallback and partial score logging 2026-03-25 14:12:29 -07:00
997eb6143e feat: Snipe MVP v0.1 — eBay trust scorer with faceted filter UI 2026-03-25 13:09:49 -07:00
4 changed files with 3691 additions and 0 deletions

97
CLAUDE.md Normal file
View file

@ -0,0 +1,97 @@
# Snipe — Developer Context
> eBay listing monitor with seller trust scoring and auction sniping.
## Stack
| Layer | Tech | Port |
|-------|------|------|
| Frontend | Vue 3 + Pinia + UnoCSS + Vite (nginx) | 8509 |
| API | FastAPI (uvicorn, `network_mode: host`) | 8510 |
| Scraper | Playwright + playwright-stealth + Xvfb | — |
| DB | SQLite (`data/snipe.db`) | — |
| Core | circuitforge-core (editable install) | — |
## CLI
```bash
./manage.sh start|stop|restart|status|logs|open|build|test
```
## Docker
```bash
docker compose up -d # start
docker compose build api web # rebuild after Python/Vue changes
docker compose logs -f api # tail API logs
```
`compose.override.yml` bind-mounts `./tests` and `./app` for hot reload.
nginx proxies `/api/``172.17.0.1:8510` (Docker bridge IP — api uses host networking).
## Critical Gotchas
**Kasada bot protection:** eBay blocks `requests`, `curl_cffi`, headless Playwright, and all `/usr/` and `/fdbk/` seller profile pages. Only headed Chromium via Xvfb passes. `/itm/` listing pages DO load and contain a BTF (below the fold) seller card with "Joined {Mon} {Year}" — use this for account age enrichment.
**Xvfb display counter:** Module-level `itertools.cycle(range(200, 300))` issues unique display numbers (`:200``:299`) per `_get()` call to prevent lock file collisions when multiple Playwright sessions run in parallel.
**HTML cache:** 5-minute in-memory cache keyed by full URL. Prevents duplicate 15s Playwright scrapes within one session. Cleared on restart.
**SQLite thread safety:** Each concurrent thread (search + comps run in parallel) must have its own `Store` instance — `sqlite3.connect()` is not thread-safe across threads. See `api/main.py`.
**nginx rebuild gotcha:** nginx config is baked into the image at build time. After editing `docker/web/nginx.conf`, always `docker compose build web`.
**Playwright imports are lazy:** `sync_playwright` and `Stealth` import inside `_get()` — not at module level — so the pure parsing functions (`scrape_listings`, `scrape_sellers`) can be imported on the host without Docker's browser stack installed.
## DB Migrations
Auto-applied by `Store.__init__()` via `circuitforge_core.db.run_migrations`.
Migration files: `app/db/migrations/001_init.sql`, `002_buying_format.sql`, `003_nullable_account_age.sql`
## Tests
```bash
# Host (no Docker needed — pure parsing tests)
conda run -n job-seeker python -m pytest tests/ -v --ignore=tests/test_integration.py
# In container
./manage.sh test
```
48 tests. Scraper tests run on host thanks to lazy Playwright imports.
## Trust Scoring Architecture
```
TrustScorer
├── MetadataScorer → 5 signals × 020 = 0100 composite
│ account_age, feedback_count, feedback_ratio,
│ price_vs_market (vs sold comps), category_history
├── PhotoScorer → phash dedup (free); vision analysis (paid stub)
└── Aggregator → composite score, red flags, hard filters
```
**Red flag sentinel gotcha:** `signal_scores` uses `None` for missing data; `clean` dict substitutes `None → 0` for arithmetic. Always check `signal_scores.get("key")` (not `clean["key"]`) when gating hard-filter flags — otherwise absent data fires false positives.
## Key Files
| File | Purpose |
|------|---------|
| `api/main.py` | FastAPI endpoint — parallel search+comps, serialization |
| `app/platforms/ebay/scraper.py` | Playwright scraper, HTML cache, page parser |
| `app/trust/aggregator.py` | Composite score, red flags, hard filters |
| `app/trust/metadata.py` | 5 metadata signals |
| `app/db/store.py` | SQLite read/write (batch methods) |
| `web/src/views/SearchView.vue` | Filter sidebar + results layout |
| `web/src/stores/search.ts` | Pinia store — API calls, result state |
| `web/src/components/ListingCard.vue` | Listing card + auction dim style |
| `web/src/assets/theme.css` | Central theme (CSS custom properties) |
## Pending Work
- **Seller enrichment** — BTF `/itm/` scrape for `account_age_days` + `_ssn` search page scrape for `category_history_json` both implemented as a combined background daemon thread (`_trigger_scraper_enrichment`). For the API adapter, `enrich_sellers_shopping_api()` fills `account_age_days` inline via Shopping API `GetUserProfile` (app-level Bearer token, no user OAuth). Second search gets full scores. "Jump the queue" on-demand enrichment: `POST /api/enrich` + ↻ button on ListingCard.
- **SSE/WebSocket live score push** — currently enriched data only appears on re-search. Future: background enrichment threads emit events via SSE or WebSocket; frontend updates scores live without re-search. Tracked: Circuit-Forge/snipe#1.
- **"Connect eBay Account" OAuth** — Trading API `GetUser` returns `RegistrationDate` + `sellerFeedbackSummary.feedbackByCategory` for any public seller, but only with a **User Access Token** (OAuth Authorization Code flow). Defer until eBay OAuth is generalized across the menagerie. Tracked: Circuit-Forge/snipe#2.
- **Scammer database + batch eBay Trust & Safety reporting** — local blocklist, batch report deep-links, CF community blocklist (cloud). Tracked: Circuit-Forge/snipe#4.
- **Snipe scheduling** — configurable bid-time offset, human approval gate

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,322 @@
# Snipe MVP + circuitforge-core Extraction — Design Spec
**Date:** 2026-03-25
**Status:** Approved
**Products:** `snipe` (new), `circuitforge-core` (new), `peregrine` (updated)
---
## 1. Overview
This spec covers two parallel workstreams:
1. **circuitforge-core extraction** — hoist the shared scaffold from Peregrine into a private, locally-installable Python package. Peregrine becomes the first downstream consumer. All future CF products depend on it.
2. **Snipe MVP** — eBay listing monitor + seller trust scorer, built on top of circuitforge-core. Solves the immediate problem: filtering scam accounts when searching for used GPU listings on eBay.
Design principle: *cry once*. Pay the extraction cost now while there are only two products; every product after this benefits for free.
---
## 2. circuitforge-core
### 2.1 Repository
- **Repo:** `git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core` (private)
- **Local path:** `/Library/Development/CircuitForge/circuitforge-core/`
- **Install method:** `pip install -e ../circuitforge-core` (editable local package; graduate to Forgejo Packages private PyPI at product #3)
- **License:** BSL 1.1 for AI features, MIT for pipeline/utility layers
### 2.2 Package Structure
```
circuitforge-core/
circuitforge_core/
pipeline/ # SQLite staging DB, status machine, background task runner
llm/ # LLM router: fallback chain, BYOK support, vision-aware routing
vision/ # Vision model wrapper — moondream2 (local) + Claude vision (cloud) [NET-NEW]
wizard/ # First-run onboarding framework, tier gating, crash recovery
tiers/ # Tier system (Free/Paid/Premium/Ultra) + Heimdall license client
db/ # SQLite base class, migration runner
config/ # Settings loader, env validation, secrets management
pyproject.toml
README.md
```
### 2.3 Extraction from Peregrine
The following Peregrine modules are **extracted** (migrated from Peregrine, not net-new):
| Peregrine source | → Core module | Notes |
|---|---|---|
| `app/wizard/` | `circuitforge_core/wizard/` | |
| `scripts/llm_router.py` | `circuitforge_core/llm/router.py` | Path is `scripts/`, not `app/` |
| `app/wizard/tiers.py` | `circuitforge_core/tiers/` | |
| SQLite pipeline base | `circuitforge_core/pipeline/` | |
**`circuitforge_core/vision/`** is **net-new** — no vision module exists in Peregrine to extract. It is built fresh in core.
**Peregrine dependency management:** Peregrine uses `requirements.txt`, not `pyproject.toml`. The migration adds `circuitforge-core` to `requirements.txt` as a local path entry: `-e ../circuitforge-core`. Snipe is greenfield and uses `pyproject.toml` from the start. There is no requirement to migrate Peregrine to `pyproject.toml` as part of this work.
### 2.4 Docker Build Strategy
Docker build contexts cannot reference paths outside the context directory (`COPY ../` is forbidden). Both Peregrine and Snipe resolve this by setting the compose build context to the parent directory:
```yaml
# compose.yml (snipe or peregrine)
services:
app:
build:
context: .. # /Library/Development/CircuitForge/
dockerfile: snipe/Dockerfile
```
```dockerfile
# snipe/Dockerfile
COPY circuitforge-core/ ./circuitforge-core/
RUN pip install -e ./circuitforge-core
COPY snipe/ ./snipe/
RUN pip install -e ./snipe
```
In development, `compose.override.yml` bind-mounts `../circuitforge-core` so local edits to core are immediately live without rebuild.
---
## 3. Snipe MVP
### 3.1 Scope
**In (v0.1 MVP):**
- eBay listing search (Browse API + Seller API)
- Metadata trust scoring (free tier)
- Perceptual hash duplicate photo detection within a search result set (free tier)
- Faceted filter UI with dynamic, data-driven filter options and sliders
- On-demand search only
- `SavedSearch` DB schema scaffolded but monitoring not wired up
**Out (future versions):**
- Background polling / saved search alerts (v0.2)
- Photo analysis via vision model — real vs marketing shot, EM bag detection (v0.2, paid)
- Serial number consistency check (v0.2, paid)
- AI-generated image detection (v0.3, paid)
- Reverse image search (v0.4, paid)
- Additional platforms: HiBid, CT Bids, AuctionZip (v0.3+)
- Bid scheduling / snipe execution (v0.4+)
### 3.2 Repository
- **Repo:** `git.opensourcesolarpunk.com/Circuit-Forge/snipe` (public discovery layer)
- **Local path:** `/Library/Development/CircuitForge/snipe/`
- **License:** MIT (discovery/pipeline), BSL 1.1 (AI features)
- **Product code:** `CFG-SNPE`
- **Port:** 8506
### 3.3 Tech Stack
Follows Peregrine as the reference implementation:
- **UI:** Streamlit (Python)
- **DB:** SQLite via `circuitforge_core.db`
- **LLM/Vision:** `circuitforge_core.llm` / `circuitforge_core.vision`
- **Tiers:** `circuitforge_core.tiers`
- **Containerisation:** Docker + `compose.yml`, managed via `manage.sh`
- **Python env:** `conda run -n job-seeker` (shared CF env)
### 3.4 Application Structure
```
snipe/
app/
platforms/
__init__.py # PlatformAdapter abstract base class
ebay/
adapter.py # eBay Browse API + Seller API client
auth.py # OAuth2 client credentials token manager
normaliser.py # Raw API response → Listing / Seller schema
trust/
__init__.py # TrustScorer orchestrator
metadata.py # Account age, feedback, price vs market, category history
photo.py # Perceptual hash dedup (free); vision analysis (paid, v0.2+)
aggregator.py # Weighted composite score + red flag extraction
ui/
Search.py # Main search + results page
components/
filters.py # Dynamic faceted filter sidebar
listing_row.py # Listing card with trust badge + red flags + error state
db/
models.py # Listing, Seller, Search, TrustScore, SavedSearch schemas
migrations/
wizard/ # First-run onboarding (thin wrapper on core wizard)
snipe/ # Bid engine placeholder (v0.4)
manage.sh
compose.yml
compose.override.yml
Dockerfile
pyproject.toml
```
### 3.5 eBay API Credentials
eBay Browse API and Seller API require OAuth 2.0 app-level tokens (client credentials flow — no user auth needed, but a registered eBay developer account and app credentials are required).
**Token lifecycle:**
- App token fetched at startup and cached in memory with expiry
- `auth.py` handles refresh automatically on expiry (tokens last 2 hours)
- On token fetch failure: search fails with a user-visible error; no silent fallback
**Credentials storage:** `.env` file (gitignored), never hardcoded.
```
EBAY_CLIENT_ID=...
EBAY_CLIENT_SECRET=...
EBAY_ENV=production # or sandbox
```
**Rate limits:** eBay Browse API — 5,000 calls/day (sandbox), higher on production. Completed sales comps results are cached in SQLite with a 6-hour TTL to avoid redundant calls and stay within limits. Cache miss triggers a fresh fetch; fetch failure degrades gracefully (price vs market signal skipped, score noted as partial).
**API split:** `get_seller()` uses the eBay Seller API (different endpoint, same app token). Rate limits are tracked separately. The `PlatformAdapter` interface does not expose this distinction; it is an internal concern of the eBay adapter.
### 3.6 Data Model
**`Listing`**
```
id, platform, platform_listing_id, title, price, currency,
condition, seller_id, url, photo_urls (JSON), listing_age_days,
fetched_at, trust_score_id
```
**`Seller`**
```
id, platform, platform_seller_id, username,
account_age_days, feedback_count, feedback_ratio,
category_history_json, fetched_at
```
**`TrustScore`**
```
id, listing_id, composite_score,
account_age_score, feedback_count_score, feedback_ratio_score,
price_vs_market_score, category_history_score,
photo_hash_duplicate (bool),
photo_analysis_json (paid, nullable),
red_flags_json, scored_at, score_is_partial (bool)
```
**`MarketComp`** *(price comps cache)*
```
id, platform, query_hash, median_price, sample_count, fetched_at, expires_at
```
**`SavedSearch`** *(schema scaffolded in v0.1; monitoring not wired until v0.2)*
```
id, name, query, platform, filters_json, created_at, last_run_at
```
**`PhotoHash`** *(perceptual hash store for cross-search dedup, v0.2+)*
```
id, listing_id, photo_url, phash, first_seen_at
```
### 3.7 Platform Adapter Interface
```python
class PlatformAdapter:
def search(self, query: str, filters: SearchFilters) -> list[Listing]: ...
def get_seller(self, seller_id: str) -> Seller: ...
def get_completed_sales(self, query: str) -> list[Listing]: ...
```
Adding HiBid or CT Bids later = new adapter, zero changes to trust scorer or UI.
### 3.8 Trust Scorer
#### Metadata Signals (Free)
Five signals, each scored 020, equal weight. Composite = sum (0100).
| Signal | Source | Red flag threshold | Score 0 condition |
|---|---|---|---|
| Account age | eBay Seller API | < 30 days | < 7 days (also hard-filter) |
| Feedback count | eBay Seller API | < 10 | < 3 |
| Feedback ratio | eBay Seller API | < 95% | < 80% with count > 20 |
| Price vs market | Completed sales comps | > 30% below median | > 50% below median |
| Category history | Seller past sales | No prior electronics sales | No prior sales at all |
**Hard filters** (auto-hide regardless of composite score):
- Account age < 7 days
- Feedback ratio < 80% with feedback count > 20
**Partial scores:** If any signal's data source is unavailable (API failure, rate limit), that signal contributes 0 and `score_is_partial = True` is set on the `TrustScore` record. The UI surfaces a "⚠ Partial score" indicator on affected listings.
#### Photo Signals — Anti-Gotcha Layer
| Signal | Tier | Version | Method |
|---|---|---|---|
| Perceptual hash dedup within result set | **Free** | v0.1 MVP | Compare phashes across all listings in the current search response; flag duplicates |
| Real photo vs marketing shot | **Paid / Local vision** | v0.2 | Vision model classification |
| Open box + EM antistatic bag (proof of possession) | **Paid / Local vision** | v0.2 | Vision model classification |
| Serial number consistency across photos | **Paid / Local vision** | v0.2 | Vision model OCR + comparison |
| AI-generated image detection | **Paid** | v0.3 | Classifier model |
| Reverse image search | **Paid** | v0.4 | Google Lens / TinEye API |
**v0.1 dedup scope:** Perceptual hash comparison is within the current search result set only (not across historical searches). Cross-session dedup uses the `PhotoHash` table and is a v0.2 feature. Photos are not downloaded to disk in v0.1 — hashes are computed from the image bytes in memory during the search request.
### 3.9 Tier Gating
Photo analysis features use `LOCAL_VISION_UNLOCKABLE` (analogous to `BYOK_UNLOCKABLE` in Peregrine's `tiers.py`) — they unlock for free-tier users who have a local vision model (moondream2) configured. This is distinct from BYOK (text LLM key), which does not unlock vision features.
| Feature | Free | Paid | Local vision unlock |
|---|---|---|---|
| Metadata trust scoring | ✓ | ✓ | — |
| Perceptual hash dedup (within result set) | ✓ | ✓ | — |
| Photo analysis (real/marketing/EM bag) | — | ✓ | ✓ |
| Serial number consistency | — | ✓ | ✓ |
| AI generation detection | — | ✓ | — |
| Reverse image search | — | ✓ | — |
| Saved searches + background monitoring | — | ✓ | — |
Locked features are shown (disabled) in the filter sidebar so free users see what's available. Clicking a locked filter shows a tier upgrade prompt.
### 3.10 UI — Results Page
**Search bar:** keywords, max price, condition selector, search button. Sort: trust score (default), price ↑/↓, listing age.
**Filter sidebar** — all options and counts generated dynamically from the result set. Options with 0 results are hidden (not greyed):
- Trust score — range slider (min/max from results); colour-band summary (safe/review/skip + counts)
- Price — min/max text inputs + market avg/median annotation
- Seller account age — min slider
- Feedback count — min slider
- Positive feedback % — min slider
- Condition — checkboxes (options from data: New, Open Box, Used, For Parts)
- Photo signals — checkboxes: Real photo, EM bag visible, Open box, No AI-generated (locked, paid)
- Hide if flagged — checkboxes: New account (<30d), Marketing photo, >30% below market, Duplicate photo
- Shipping — Free shipping, Local pickup
- Reset filters button
**Listing row (happy path):** thumbnail · title · seller summary (username, feedback count, ratio, tenure) · red flag badges · trust score badge (colour-coded: green 80+, amber 5079, red <50) · `score_is_partial` indicator if applicable · price · "Open eBay ↗" link. Left border colour matches score band.
**Listing row (error states):**
- Seller data unavailable: seller summary shows "Seller data unavailable" in muted text; affected signals show "" and partial score indicator is set
- Photo URL 404: thumbnail shows placeholder icon; hash dedup skipped for that photo
- Trust scoring failed entirely: listing shown with score "?" badge in neutral grey; error logged; "Could not score this listing" tooltip
**Hidden results:** count shown at bottom ("N results hidden by filters · show anyway"). Clicking reveals them in-place at reduced opacity.
---
## 4. Build Order
1. **circuitforge-core** — scaffold repo, extract wizard/llm/tiers/pipeline from Peregrine, build vision module net-new, update Peregrine `requirements.txt`
2. **Snipe scaffold** — repo init, Dockerfile, compose.yml (parent context), manage.sh, DB migrations, wizard first-run, `.env` template
3. **eBay adapter** — OAuth2 token manager, Browse API search, Seller API, completed sales comps with cache
4. **Metadata trust scorer** — all five signals, aggregator, hard filters, partial score handling
5. **Perceptual hash dedup** — in-memory within-result-set comparison
6. **Results UI** — search page, listing rows (happy + error states), dynamic filter sidebar
7. **Tier gating** — lock photo signals, `LOCAL_VISION_UNLOCKABLE` gate, upsell prompts in UI
---
## 5. Documentation Locations
- Product spec: `snipe/docs/superpowers/specs/2026-03-25-snipe-circuitforge-core-design.md` *(this file)*
- Internal copy: `circuitforge-plans/snipe/2026-03-25-snipe-circuitforge-core-design.md`
- Roadmap: `Circuit-Forge/roadmap` issues #14 (snipe) and #21 (circuitforge-core)
- Org-level context: `/Library/Development/CircuitForge/CLAUDE.md`