diff --git a/backend/main.py b/backend/main.py index 8f61096..13cb72e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,14 +25,14 @@ # Inference/fusion require PyTorch — import lazily so server starts without it try: from inference import load_models, predict_stream_a, predict_stream_b - from fusion import process_and_fuse + from backend.fusion import process_and_fuse _torch_available = True except ModuleNotFoundError: _torch_available = False print("WARNING: PyTorch not installed. Scan endpoints will return 503.") -from auth import get_current_user, get_google_oauth_url, exchange_code_for_session +from backend.auth import get_current_user, get_google_oauth_url, exchange_code_for_session # ── Configuration ───────────────────────────────────────────────────────────── # All secrets MUST come from environment variables — no hardcoded fallbacks. @@ -100,16 +100,8 @@ async def lifespan(app: FastAPI): if CORS_ALLOW_ALL else [ FRONTEND_URL, - # Current production frontend — always allow so a stale FRONTEND_URL - # env var doesn't lock out users. - "https://fresh-scanai.vercel.app", - # Extra comma-separated origins from env (e.g. preview deployments). - # ADDITIONAL_CORS_ORIGINS=https://preview.vercel.app,https://staging.example.com - *[ - o.strip() - for o in os.environ.get("ADDITIONAL_CORS_ORIGINS", "").split(",") - if o.strip() - ], + # Always allow Vercel preview deployments + "https://fresh-scan-ai-sage.vercel.app", ] ) @@ -514,7 +506,7 @@ async def scan_auto( return {"success": True, "scan": payload} # ── Real inference path ─────────────────────────────────────────────────── - from router import classify_image_type, ImageType + from backend.router import classify_image_type, ImageType img = Image.open(io.BytesIO(image_bytes)).convert("RGB") image_type = classify_image_type(img) @@ -671,46 +663,6 @@ async def get_vendors(): raise HTTPException(status_code=500, detail=str(exc)) -@app.get("/api/v1/vendors/leaderboard") -async def get_leaderboard(): - try: - fields = "id, name, address, avg_freshness_score, total_scans" - resp = ( - _db() - .table("vendors") - .select(fields) - .order("avg_freshness_score", desc=True) - .limit(10) - .execute() - ) - - leaderboard = [] - for v in (resp.data or []): - score = v.get("avg_freshness_score") or 0 - if score >= 85: - badge = "gold" - elif score >= 70: - badge = "silver" - elif score >= 50: - badge = "bronze" - else: - badge = "unranked" - - leaderboard.append({ - "id": v["id"], - "name": v["name"], - "address": v["address"] or "Unknown Location", - "avg_freshness_score": score, - "total_scans": v.get("total_scans") or 0, - "trust_badge": badge, - "trend": "stable", - }) - - return {"success": True, "leaderboard": leaderboard} - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) - - # ── MAP ─────────────────────────────────────────────────────────────────────── @@ -843,7 +795,7 @@ async def generate_gradcam( # ── Real Grad-CAM path ────────────────────────────────────────────────────── import torch # noqa: F401 import numpy as np - from inference import stream_a_model, stream_a_transforms, device + from backend.inference import stream_a_model, stream_a_transforms, device from router import is_valid_fish_image # Fish validity gate — same gate used by /api/v1/scan-auto @@ -933,4 +885,6 @@ def _bwd_hook(_module, _grad_in, grad_out): from vendors import router as vendors_router, register_routes register_routes(vendors_router, _db) +from markets import router as markets_router +app.include_router(markets_router) app.include_router(vendors_router) diff --git a/backend/markets.py b/backend/markets.py new file mode 100644 index 0000000..d5447a5 --- /dev/null +++ b/backend/markets.py @@ -0,0 +1,119 @@ +""" +Real-world fish market locations via OpenStreetMap Overpass API. +Endpoint: GET /api/v1/maps/markets/live?lat=...&lng=...&radius=5000 +""" + +import httpx +import math +from fastapi import APIRouter, Query + +router = APIRouter(prefix="/api/v1/maps/markets", tags=["markets"]) + +OVERPASS_URL = "https://overpass-api.de/api/interpreter" + +OVERPASS_QUERY_TEMPLATE = """ +[out:json][timeout:25]; +( + node["shop"="seafood"]({south},{west},{north},{east}); + node["shop"="fish"]({south},{west},{north},{east}); + node["amenity"="marketplace"]["name"~"fish|seafood|market",i]({south},{west},{north},{east}); + node["landuse"="retail"]["name"~"fish|seafood",i]({south},{west},{north},{east}); +); +out body; +""" + + +def _lat_lng_to_bbox(lat: float, lng: float, radius_m: float): + """Convert center + radius to bounding box (south, west, north, east).""" + delta_lat = radius_m / 111320 + import math + delta_lng = radius_m / (111320 * abs(math.cos(math.radians(lat))) + 1e-9) + return { + "south": lat - delta_lat, + "west": lng - delta_lng, + "north": lat + delta_lat, + "east": lng + delta_lng, + } + + +def _parse_overpass(elements: list, fallback_score: int = 70) -> list: + """Normalize Overpass API elements into the MarketMapPage format.""" + markets = [] + for i, el in enumerate(elements): + tags = el.get("tags", {}) + name = ( + tags.get("name") + or tags.get("name:en") + or tags.get("shop") + or "Fish Market" + ) + lat = el.get("lat") + lon = el.get("lon") + if lat is None or lon is None: + continue + markets.append({ + "id": el.get("id", i + 1), + "name": name, + "score": fallback_score, + "lat": float(lat), + "lng": float(lon), + "vendors": 1, + "source": "openstreetmap", + "address": tags.get("addr:full") or tags.get("addr:street") or "", + }) + return markets + + +@router.get("/live") +async def get_live_markets( + lat: float = Query(..., description="Latitude of user location"), + lng: float = Query(..., description="Longitude of user location"), + radius: int = Query(default=5000, ge=500, le=50000, description="Search radius in meters"), +): + """ + Fetch real-world fish markets near a location using OpenStreetMap Overpass API. + Falls back to empty list if Overpass is unavailable. + """ + bbox = _lat_lng_to_bbox(lat, lng, radius) + query = OVERPASS_QUERY_TEMPLATE.format(**bbox) + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + OVERPASS_URL, + data={"data": query}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "FreshScanAI/1.0 (https://github.com/jpdevhub/FreshScanAi)", + }, + ) + response.raise_for_status() + data = response.json() + + elements = data.get("elements", []) + markets = _parse_overpass(elements) + + return { + "success": True, + "source": "openstreetmap", + "count": len(markets), + "lat": lat, + "lng": lng, + "radius_m": radius, + "markets": markets, + } + + except httpx.TimeoutException: + return { + "success": False, + "source": "openstreetmap", + "error": "Overpass API timed out. Try again or reduce radius.", + "markets": [], + } + except Exception as exc: + return { + "success": False, + "source": "openstreetmap", + "error": str(exc), + "markets": [], + } diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx index 9566ab1..dccf4b5 100644 --- a/src/pages/Leaderboard.tsx +++ b/src/pages/Leaderboard.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import Skeleton from "../components/Skeleton"; const API_BASE = import.meta.env.VITE_API_URL ?? ""; @@ -17,33 +16,22 @@ interface Vendor { } const BADGE: Record = { - gold: { label: "[ GOLD ]", color: "var(--color-neon-yellow, #f59e0b)" }, - silver: { label: "[ SILVER ]", color: "var(--color-on-surface, #9ca3af)" }, - bronze: { label: "[ BRONZE ]", color: "var(--color-neon-orange, #f97316)" }, - unranked: { - label: "[ UNRANKED ]", - color: "var(--color-outline-variant, #6b7280)", - }, + gold: { label: "[ GOLD ]", color: "var(--color-neon-yellow, #f59e0b)" }, + silver: { label: "[ SILVER ]", color: "var(--color-on-surface, #9ca3af)" }, + bronze: { label: "[ BRONZE ]", color: "var(--color-neon-orange, #f97316)" }, + unranked: { label: "[ UNRANKED ]", color: "var(--color-outline-variant, #6b7280)" }, }; const TREND: Record = { - up: { - icon: "^", - color: "var(--color-neon-green, #22c55e)", - label: "Improving", - }, - down: { icon: "v", color: "var(--color-error, #ef4444)", label: "Declining" }, - stable: { - icon: "-", - color: "var(--color-on-surface, #9ca3af)", - label: "Stable", - }, + up: { icon: "^", color: "var(--color-neon-green, #22c55e)", label: "Improving" }, + down: { icon: "v", color: "var(--color-error, #ef4444)", label: "Declining" }, + stable: { icon: "-", color: "var(--color-on-surface, #9ca3af)", label: "Stable" }, }; export default function Leaderboard() { const [vendors, setVendors] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); useEffect(() => { fetch(`${API_BASE}/api/v1/vendors/leaderboard`) @@ -52,68 +40,33 @@ export default function Leaderboard() { return r.json(); }) .then((data) => setVendors(data.leaderboard || [])) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); }, []); - if (loading) - return ( -
- {/* Title & Subtitle Skeletons */} - - - - {/* List Skeletons - Generating 5 placeholder rows */} -
- {[...Array(5)].map((_, i) => ( -
- {/* Index Number */} - - - {/* Badge */} - - - {/* Name & Address */} -
- - -
- - {/* Score & Scans */} -
- - -
- - {/* Trend Icon */} - -
- ))} -
-
- ); + if (loading) return ( +
+ LOADING... +
+ ); - if (error) - return ( -
- {error} -
- ); + if (error) return ( +
+ {error} +
+ ); return (
-

+

VENDOR TRUST LEADERBOARD

-

+

RANKINGS BASED ON ANONYMOUS FRESHNESS SCANS ACROSS MARKETS

{vendors.length === 0 ? ( -

+

NO VENDOR DATA YET.

) : ( @@ -126,40 +79,38 @@ export default function Leaderboard() { key={vendor.id} className="flex items-center gap-4 p-4 border border-outline-variant/30 bg-surface-low" > - + {String(index + 1).padStart(2, "0")} {badge.label}
-

+

{vendor.name}

-

+

{vendor.address}

-

+

{(vendor.avg_freshness_score ?? 0).toFixed(1)} - - /100 - + /100

-

+

{vendor.total_scans ?? 0} SCANS

@@ -172,4 +123,4 @@ export default function Leaderboard() { )}
); -} +} \ No newline at end of file