Compare commits

..

No commits in common. "main" and "feature/affiliate-links" have entirely different histories.

14 changed files with 143 additions and 538 deletions

View file

@ -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).

View file

@ -4,50 +4,6 @@
**Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next. **Status:** Active — eBay listing intelligence MVP complete (search, trust scoring, affiliate links, feedback FAB, vision task scheduling). Auction sniping engine and multi-platform support are next.
## Quick install (self-hosted)
**Requirements:** Docker with Compose plugin, Git. No API keys needed to get started.
```bash
# One-line install — clones to ~/snipe by default
bash <(curl -fsSL https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/raw/branch/main/install.sh)
# Or clone manually and run the script:
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
bash snipe/install.sh
```
Then open **http://localhost:8509**.
### Manual setup (if you prefer)
Snipe's API image is built from a parent context that includes `circuitforge-core`. Both repos must sit as siblings in the same directory:
```
workspace/
├── snipe/ ← this repo
└── circuitforge-core/ ← required sibling
```
```bash
mkdir snipe-workspace && cd snipe-workspace
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/snipe.git
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git
cd snipe
cp .env.example .env # edit if you have eBay API credentials (optional)
./manage.sh start
```
### Optional: eBay API credentials
Snipe works without any credentials using its Playwright scraper fallback. Adding eBay API credentials unlocks faster searches and inline seller account age (no extra scrape needed):
1. Register at [developer.ebay.com](https://developer.ebay.com/my/keys)
2. Copy your Production **App ID** and **Cert ID** into `.env`
3. Restart: `./manage.sh restart`
---
## What it does ## What it does
Snipe has two layers that work together: Snipe has two layers that work together:

View file

@ -11,7 +11,12 @@ from pathlib import Path
import csv import csv
import io import io
import platform as _platform
import subprocess
from datetime import datetime, timezone
from typing import Literal
import requests as _requests
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -19,7 +24,6 @@ from fastapi.middleware.cors import CORSMiddleware
from circuitforge_core.config import load_env from circuitforge_core.config import load_env
from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url from circuitforge_core.affiliates import wrap_url as _wrap_affiliate_url
from circuitforge_core.api import make_feedback_router as _make_feedback_router
from app.db.store import Store from app.db.store import Store
from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry from app.db.models import SavedSearch as SavedSearchModel, ScammerEntry
from app.platforms import SearchFilters from app.platforms import SearchFilters
@ -77,12 +81,6 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
_feedback_router = _make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
)
app.include_router(_feedback_router, prefix="/api/feedback")
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
@ -647,3 +645,117 @@ async def import_blocklist(
return {"imported": imported, "errors": errors} return {"imported": imported, "errors": errors}
# ── Feedback ────────────────────────────────────────────────────────────────
# Creates Forgejo issues from in-app beta feedback.
# Silently disabled when FORGEJO_API_TOKEN is not set.
_FEEDBACK_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _fb_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_feedback_labels(names: list[str]) -> list[int]:
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in names:
if name in existing:
ids.append(existing[name])
else:
r = _requests.post(
f"{base}/repos/{repo}/labels",
headers=_fb_headers(),
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
view: str = "unknown"
submitter: str = ""
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
@app.get("/api/feedback/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
@app.post("/api/feedback", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""File a Forgejo issue from in-app feedback."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
).strip()
except Exception:
version = "dev"
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
body_lines = [
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
"",
payload.description,
"",
]
if payload.type == "bug" and payload.repro:
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
body_lines += [
"### Context", "",
f"- **view:** {payload.view}",
f"- **version:** {version}",
f"- **platform:** {_platform.platform()}",
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
"",
]
if payload.submitter:
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
labels = ["beta-feedback", "needs-triage",
{"bug": "bug", "feature": "feature-request"}.get(payload.type, "question")]
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
label_ids = _ensure_feedback_labels(labels)
resp = _requests.post(
f"{base}/repos/{repo}/issues",
headers=_fb_headers(),
json={"title": payload.title, "body": "\n".join(body_lines), "labels": label_ids},
timeout=15,
)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
data = resp.json()
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])

View file

@ -1,9 +1,9 @@
-- Staging DB: persistent listing tracking across searches. -- Staging DB: persistent listing tracking across searches.
-- Adds temporal metadata to listings so we can detect stale/repriced/recurring items. -- Adds temporal metadata to listings so we can detect stale/repriced/recurring items.
ALTER TABLE listings ADD COLUMN first_seen_at TEXT; ALTER TABLE listings ADD COLUMN first_seen_at TEXT;
ALTER TABLE listings ADD COLUMN last_seen_at TEXT; ALTER TABLE listings ADD COLUMN last_seen_at TEXT;
ALTER TABLE listings ADD COLUMN times_seen INTEGER NOT NULL DEFAULT 1; ALTER TABLE listings ADD COLUMN times_seen INTEGER NOT NULL DEFAULT 1;
ALTER TABLE listings ADD COLUMN price_at_first_seen REAL; ALTER TABLE listings ADD COLUMN price_at_first_seen REAL;
-- Backfill existing rows so columns are non-null where we have data -- Backfill existing rows so columns are non-null where we have data

View file

@ -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,

View file

@ -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

View file

@ -1,17 +1,21 @@
# compose.override.yml — dev-only additions (auto-applied by Docker Compose in dev).
# Safe to delete on a self-hosted machine — compose.yml is self-contained.
#
# What this adds over compose.yml:
# - Live source mounts so code changes take effect without rebuilding images
# - RELOAD=true to enable uvicorn --reload for the API
# - NOTE: circuitforge-core is NOT mounted here — use `./manage.sh build` to
# pick up cf-core changes. Mounting it as a bind volume would break self-hosted
# installs that don't have the sibling directory.
services: services:
api: api:
build:
context: ..
dockerfile: snipe/Dockerfile
network_mode: host
volumes: volumes:
- ../circuitforge-core:/app/circuitforge-core
- ./api:/app/snipe/api - ./api:/app/snipe/api
- ./app:/app/snipe/app - ./app:/app/snipe/app
- ./data:/app/snipe/data
- ./tests:/app/snipe/tests - ./tests:/app/snipe/tests
environment: environment:
- RELOAD=true - RELOAD=true
web:
build:
context: .
dockerfile: docker/web/Dockerfile
volumes:
- ./web/src:/app/src # not used at runtime but keeps override valid

View file

@ -3,14 +3,11 @@ services:
build: build:
context: .. context: ..
dockerfile: snipe/Dockerfile dockerfile: snipe/Dockerfile
# Host networking lets nginx (in the web container) reach the API at ports:
# 172.17.0.1:8510 (the Docker bridge gateway). Required — nginx.conf - "8510:8510"
# is baked into the image and hard-codes that address.
network_mode: host
env_file: .env env_file: .env
volumes: volumes:
- ./data:/app/snipe/data - ./data:/app/snipe/data
restart: unless-stopped
web: web:
build: build:

View file

@ -1,226 +0,0 @@
#!/usr/bin/env bash
# Snipe — self-hosted install script
#
# Supports two install paths:
# Docker (recommended) — everything in containers, no system Python deps required
# No-Docker — conda or venv + direct uvicorn, for machines without Docker
#
# Usage:
# bash install.sh # installs to ~/snipe
# bash install.sh /opt/snipe # custom install directory
# bash install.sh ~/snipe --no-docker # force no-Docker path even if Docker present
#
# Requirements (Docker path): Docker with Compose plugin, Git
# Requirements (no-Docker path): Python 3.11+, Node.js 20+, Git, xvfb (system)
set -euo pipefail
INSTALL_DIR="${1:-$HOME/snipe}"
FORCE_NO_DOCKER="${2:-}"
FORGEJO="https://git.opensourcesolarpunk.com/Circuit-Forge"
CONDA_ENV="cf"
info() { echo " [snipe] $*"; }
ok() { echo "$*"; }
warn() { echo "! $*"; }
fail() { echo "$*" >&2; exit 1; }
hr() { echo "────────────────────────────────────────────────────────"; }
echo ""
echo " Snipe — self-hosted installer"
echo " Install directory: $INSTALL_DIR"
echo ""
# ── Detect capabilities ──────────────────────────────────────────────────────
HAS_DOCKER=false
HAS_CONDA=false
HAS_PYTHON=false
HAS_NODE=false
docker compose version >/dev/null 2>&1 && HAS_DOCKER=true
conda --version >/dev/null 2>&1 && HAS_CONDA=true
python3 --version >/dev/null 2>&1 && HAS_PYTHON=true
node --version >/dev/null 2>&1 && HAS_NODE=true
command -v git >/dev/null 2>&1 || fail "Git is required. Install with: sudo apt-get install git"
# Honour --no-docker flag
[[ "$FORCE_NO_DOCKER" == "--no-docker" ]] && HAS_DOCKER=false
if $HAS_DOCKER; then
INSTALL_PATH="docker"
ok "Docker found — using Docker install path (recommended)"
elif $HAS_PYTHON; then
INSTALL_PATH="python"
warn "Docker not found — using no-Docker path (conda or venv)"
else
fail "Docker or Python 3.11+ is required. Install Docker: https://docs.docker.com/get-docker/"
fi
# ── Clone repos ──────────────────────────────────────────────────────────────
# compose.yml and the Dockerfile both use context: .. (parent directory), so
# snipe/ and circuitforge-core/ must be siblings inside INSTALL_DIR.
SNIPE_DIR="$INSTALL_DIR/snipe"
CORE_DIR="$INSTALL_DIR/circuitforge-core"
if [[ -d "$SNIPE_DIR" ]]; then
info "Snipe already cloned — pulling latest..."
git -C "$SNIPE_DIR" pull --ff-only
else
info "Cloning Snipe..."
mkdir -p "$INSTALL_DIR"
git clone "$FORGEJO/snipe.git" "$SNIPE_DIR"
fi
ok "Snipe → $SNIPE_DIR"
if [[ -d "$CORE_DIR" ]]; then
info "circuitforge-core already cloned — pulling latest..."
git -C "$CORE_DIR" pull --ff-only
else
info "Cloning circuitforge-core (shared library)..."
git clone "$FORGEJO/circuitforge-core.git" "$CORE_DIR"
fi
ok "circuitforge-core → $CORE_DIR"
# ── Configure environment ────────────────────────────────────────────────────
ENV_FILE="$SNIPE_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
cp "$SNIPE_DIR/.env.example" "$ENV_FILE"
# Safe defaults for local installs — no eBay registration, no Heimdall
sed -i 's/^EBAY_WEBHOOK_VERIFY_SIGNATURES=true/EBAY_WEBHOOK_VERIFY_SIGNATURES=false/' "$ENV_FILE"
ok ".env created from .env.example"
echo ""
info "Snipe works out of the box with no API keys."
info "Add EBAY_APP_ID / EBAY_CERT_ID later for faster searches (optional)."
echo ""
else
info ".env already exists — skipping (delete it to reset)"
fi
cd "$SNIPE_DIR"
# ── Docker install path ───────────────────────────────────────────────────────
if [[ "$INSTALL_PATH" == "docker" ]]; then
info "Building Docker images (~1 GB download on first run)..."
docker compose build
info "Starting Snipe..."
docker compose up -d
echo ""
ok "Snipe is running!"
hr
echo " Web UI: http://localhost:8509"
echo " API: http://localhost:8510/docs"
echo ""
echo " Manage: cd $SNIPE_DIR && ./manage.sh {start|stop|restart|logs|test}"
hr
echo ""
exit 0
fi
# ── No-Docker install path ───────────────────────────────────────────────────
# System deps: Xvfb is required for Playwright (Kasada bypass via headed Chromium)
if ! command -v Xvfb >/dev/null 2>&1; then
info "Installing Xvfb (required for eBay scraper)..."
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get install -y --no-install-recommends xvfb
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y xorg-x11-server-Xvfb
elif command -v brew >/dev/null 2>&1; then
warn "macOS: Xvfb not available. The scraper fallback may fail."
warn "Add eBay API credentials to .env to use the API adapter instead."
else
warn "Could not install Xvfb automatically. Install it with your package manager."
fi
fi
# ── Python environment setup ─────────────────────────────────────────────────
if $HAS_CONDA; then
info "Setting up conda environment '$CONDA_ENV'..."
if conda env list | grep -q "^$CONDA_ENV "; then
info "Conda env '$CONDA_ENV' already exists — updating..."
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
else
conda create -n "$CONDA_ENV" python=3.11 -y
conda run -n "$CONDA_ENV" pip install --quiet -e "$CORE_DIR"
conda run -n "$CONDA_ENV" pip install --quiet -e "$SNIPE_DIR"
fi
conda run -n "$CONDA_ENV" playwright install chromium
conda run -n "$CONDA_ENV" playwright install-deps chromium
PYTHON_RUN="conda run -n $CONDA_ENV"
ok "Conda environment '$CONDA_ENV' ready"
else
info "Setting up Python venv at $SNIPE_DIR/.venv ..."
python3 -m venv "$SNIPE_DIR/.venv"
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$CORE_DIR"
"$SNIPE_DIR/.venv/bin/pip" install --quiet -e "$SNIPE_DIR"
"$SNIPE_DIR/.venv/bin/playwright" install chromium
"$SNIPE_DIR/.venv/bin/playwright" install-deps chromium
PYTHON_RUN="$SNIPE_DIR/.venv/bin"
ok "Python venv ready at $SNIPE_DIR/.venv"
fi
# ── Frontend ─────────────────────────────────────────────────────────────────
if $HAS_NODE; then
info "Building Vue frontend..."
cd "$SNIPE_DIR/web"
npm ci --prefer-offline --silent
npm run build
cd "$SNIPE_DIR"
ok "Frontend built → web/dist/"
else
warn "Node.js not found — skipping frontend build."
warn "Install Node.js 20+ from https://nodejs.org and re-run install.sh to build the UI."
warn "Until then, you can access the API directly at http://localhost:8510/docs"
fi
# ── Write start/stop scripts ─────────────────────────────────────────────────
cat > "$SNIPE_DIR/start-local.sh" << 'STARTSCRIPT'
#!/usr/bin/env bash
# Start Snipe without Docker (API only — run from the snipe/ directory)
set -euo pipefail
cd "$(dirname "$0")"
if [[ -f .venv/bin/uvicorn ]]; then
UVICORN=".venv/bin/uvicorn"
elif command -v conda >/dev/null 2>&1 && conda env list | grep -q "^cf "; then
UVICORN="conda run -n cf uvicorn"
else
echo "No Python env found. Run install.sh first." >&2; exit 1
fi
mkdir -p data
echo "Starting Snipe API on http://localhost:8510 ..."
$UVICORN api.main:app --host 0.0.0.0 --port 8510 "${@}"
STARTSCRIPT
chmod +x "$SNIPE_DIR/start-local.sh"
# Frontend serving (if built)
cat > "$SNIPE_DIR/serve-ui.sh" << 'UISCRIPT'
#!/usr/bin/env bash
# Serve the pre-built Vue frontend on port 8509 (dev only — use nginx for production)
cd "$(dirname "$0")/web/dist"
python3 -m http.server 8509
UISCRIPT
chmod +x "$SNIPE_DIR/serve-ui.sh"
echo ""
ok "Snipe installed (no-Docker mode)"
hr
echo " Start API: cd $SNIPE_DIR && ./start-local.sh"
echo " Serve UI: cd $SNIPE_DIR && ./serve-ui.sh (separate terminal)"
echo " API docs: http://localhost:8510/docs"
echo " Web UI: http://localhost:8509 (after ./serve-ui.sh)"
echo ""
echo " For production, point nginx at web/dist/ and proxy /api/ to localhost:8510"
hr
echo ""

View file

@ -78,7 +78,7 @@ case "$cmd" in
test) test)
echo "Running test suite..." echo "Running test suite..."
docker compose -f "$COMPOSE_FILE" exec api \ docker compose -f "$COMPOSE_FILE" exec api \
python -m pytest /app/snipe/tests/ -v "${@}" conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}"
;; ;;
# ── Cloud commands ──────────────────────────────────────────────────────── # ── Cloud commands ────────────────────────────────────────────────────────

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

View file

@ -1,134 +0,0 @@
"""Tests for the shared feedback router (circuitforge-core) mounted in snipe."""
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
app = FastAPI()
router = make_feedback_router(
repo="Circuit-Forge/snipe",
product="snipe",
demo_mode_fn=demo_mode_fn,
)
app.include_router(router, prefix="/api/feedback")
return TestClient(app)
# ── GET /api/feedback/status ──────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.delenv("DEMO_MODE", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
def test_status_enabled_when_token_set(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": True}
def test_status_disabled_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.get("/api/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /api/feedback ────────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.post("/api/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 503
def test_submit_returns_403_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.post("/api/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 403
def test_submit_creates_issue(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "bug"},
]
issue_response = MagicMock()
issue_response.ok = True
issue_response.json.return_value = {
"number": 7,
"html_url": "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7",
}
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
res = client.post("/api/feedback", json={
"title": "Listing scores wrong",
"description": "Trust score shows 0 when seller has 1000 feedback",
"type": "bug",
"repro": "1. Search for anything\n2. Check trust score",
"tab": "search",
})
assert res.status_code == 200
data = res.json()
assert data["issue_number"] == 7
assert data["issue_url"] == "https://git.opensourcesolarpunk.com/Circuit-Forge/snipe/issues/7"
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "question"},
]
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "internal server error"
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
res = client.post("/api/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})
assert res.status_code == 502

View file

@ -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()

View file

@ -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;