Spaces:
Running
Running
| """GreenProof verification service. | |
| The ONE new surface in the stack. Everything else is Supabase called directly | |
| from the browser; this exists because a client-computed verification result is | |
| forgeable, so scoring has to happen somewhere the user cannot reach. | |
| Runs on Hugging Face Spaces (Docker SDK). Host-agnostic: the same container | |
| runs on a laptop, Cloud Run, or anywhere else that can run Docker. | |
| GET / service metadata | |
| GET /health liveness + whether the model is loaded | |
| POST /score score one check-in by id | |
| POST /advise species + care advice for one check-in (advisory only) | |
| POST /backfill score every pending check-in (used after T0) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import secrets | |
| import threading | |
| from fastapi import FastAPI, Header, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel, Field | |
| from greenproof_ml import embed as embed_mod | |
| from greenproof_ml import store | |
| from greenproof_ml.pipeline import score_checkin | |
| from greenproof_ml.scoring import MODEL_NAME, MODEL_VERSION | |
| logging.basicConfig( | |
| level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" | |
| ) | |
| log = logging.getLogger("greenproof") | |
| app = FastAPI(title="GreenProof verification service", version=MODEL_VERSION) | |
| # The PWA is served from Vercel, so this is a genuine cross-origin call. | |
| # Set ALLOWED_ORIGINS to the Vercel URL in the Space's variables; the default | |
| # is permissive so the pilot is never blocked by a CORS typo at 6am, and the | |
| # endpoints carry no secrets a caller could extract — the service key stays | |
| # server-side and every write is derived from stored photos, not from the | |
| # request body. | |
| origins = os.environ.get("ALLOWED_ORIGINS", "*").split(",") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[o.strip() for o in origins if o.strip()], | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| _model_ready = threading.Event() | |
| def _warm() -> None: | |
| """Load the model in the background. | |
| Spaces health-check the container early. Blocking startup on a ~90 MB model | |
| download makes the Space look dead and get restarted, which restarts the | |
| download — a loop that has eaten whole afternoons. | |
| """ | |
| def run() -> None: | |
| try: | |
| embed_mod.warm() | |
| _model_ready.set() | |
| log.info("model ready: %s %s", MODEL_NAME, MODEL_VERSION) | |
| except Exception: | |
| log.exception("model failed to load") | |
| threading.Thread(target=run, daemon=True).start() | |
| class ScoreRequest(BaseModel): | |
| checkin_id: str = Field(..., description="checkins.id to score") | |
| class ScoreResponse(BaseModel): | |
| checkin_id: str | |
| confidence: int | |
| verdict: str | |
| signals: dict | |
| def root() -> dict: | |
| return { | |
| "service": "greenproof-verification", | |
| "model": MODEL_NAME, | |
| "version": MODEL_VERSION, | |
| "docs": "/docs", | |
| } | |
| def health() -> dict: | |
| return { | |
| "ok": True, | |
| "model_ready": _model_ready.is_set(), | |
| "supabase_configured": bool( | |
| os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_SERVICE_KEY") | |
| ), | |
| } | |
| # Generate advice automatically whenever a check-in is scored. | |
| # | |
| # WHY THIS EXISTS: WITHOUT IT, NOTHING EVER CALLS /advise. | |
| # | |
| # The endpoint and the backfill tool were both built, and neither was ever | |
| # triggered by the act of checking in - so a planter completed a visit, got a | |
| # verdict, and saw no advice at all unless somebody ran a script by hand | |
| # afterwards. The feature worked and was invisible, which is the same thing as | |
| # not working. | |
| # | |
| # WHY HERE AND NOT IN THE PIPELINE. `pipeline.py` still does not import the | |
| # advisor, and must not: that import boundary is what guarantees a slow, | |
| # rate-limited or hallucinating model can never affect a verdict. So the trigger | |
| # sits HERE, in the transport layer, and only AFTER score_checkin has returned | |
| # and the verdict is already committed to the database. | |
| # | |
| # Three properties this deliberately preserves: | |
| # | |
| # - /score latency is unchanged; the call returns while advice is still | |
| # running, exactly as before | |
| # - a failure cannot touch the verdict, because the verdict is already written | |
| # - scoring stays replayable offline with no external dependency | |
| ADVISE_ON_SCORE = os.environ.get("ADVISE_ON_SCORE", "1").strip() not in ("0", "false", "no") | |
| def _advise_in_background(checkin_id: str) -> None: | |
| """Fire and forget. Never raises, never blocks the caller.""" | |
| if not ADVISE_ON_SCORE: | |
| return | |
| def run() -> None: | |
| try: | |
| from greenproof_ml import advisor | |
| result = advisor.advise_checkin(checkin_id) | |
| log.info( | |
| "advice for %s: %s", checkin_id, "written" if result else "nothing written" | |
| ) | |
| except Exception: # noqa: BLE001 - advisory only, never fatal | |
| log.exception("background advice failed for %s", checkin_id) | |
| threading.Thread(target=run, daemon=True).start() | |
| def score(req: ScoreRequest) -> ScoreResponse: | |
| """Score one check-in. | |
| Takes only an id. The request body cannot influence the outcome — every | |
| input is re-read from the database and from storage. A caller can ask for a | |
| check-in to be scored; it can never say what the score should be. | |
| """ | |
| if not _model_ready.is_set(): | |
| raise HTTPException(503, "Model still loading, retry shortly") | |
| try: | |
| result = score_checkin(req.checkin_id) | |
| except LookupError as e: | |
| raise HTTPException(404, str(e)) from e | |
| except Exception as e: # noqa: BLE001 | |
| log.exception("scoring failed for %s", req.checkin_id) | |
| raise HTTPException(500, f"Scoring failed: {e}") from e | |
| # Advice is generated AFTER the verdict is written, in a background thread. | |
| _advise_in_background(req.checkin_id) | |
| return ScoreResponse( | |
| checkin_id=req.checkin_id, | |
| confidence=result.confidence, | |
| verdict=result.verdict, | |
| signals=result.signals, | |
| ) | |
| # Optional shared secret for /advise. Unset means the endpoint is open. | |
| # | |
| # WHY THIS EXISTS, AND WHY ONLY ON THIS ENDPOINT. | |
| # | |
| # /score is safe to leave open: it takes an id, re-reads every input from the | |
| # database, and costs us nothing but a few seconds of our own CPU. /advise is | |
| # different in one specific way - IT SPENDS MONEY. Each call is about 3 cents of | |
| # Anthropic usage. | |
| # | |
| # On a Cloudflare quick tunnel that barely mattered: the hostname rotated and the | |
| # service was up for minutes at a time. A permanent public Space URL is a | |
| # different proposition, and an endpoint that bills the operator per request is | |
| # worth a lock even when the realistic risk is low. | |
| # | |
| # Unset by default so local runs and `uvicorn app:app` need no configuration. | |
| # Set it in the Space's secrets and in .env, and tools/advise_pilot.py sends it. | |
| ADVISE_TOKEN = os.environ.get("ADVISE_TOKEN", "").strip() | |
| def advise(req: ScoreRequest, x_advise_token: str = Header(default="")) -> dict: | |
| """Species identification and care advice for one check-in. | |
| SEPARATE FROM /score ON PURPOSE, and the separation is the design. | |
| Scoring is fast, local, offline-capable and replayable - it re-runs over the | |
| whole pilot dataset whenever a threshold moves, and it must never acquire a | |
| dependency on an external API that can be slow, rate-limited or down. This | |
| endpoint is none of those things: it makes a paid network call to a model | |
| whose error rate we have not measured. | |
| So they share a service and nothing else. This writes only `species_guess` | |
| and `advice`; it cannot move a confidence or a verdict, and a failure here | |
| leaves the check-in exactly as scoring left it. | |
| Note it does NOT require the DINOv2 model to be loaded - the two paths have | |
| no components in common, so a cold model should not block advice. | |
| """ | |
| # compare_digest, not ==, so a wrong token cannot be recovered by timing. | |
| if ADVISE_TOKEN and not secrets.compare_digest(x_advise_token, ADVISE_TOKEN): | |
| raise HTTPException(401, "Missing or invalid X-Advise-Token") | |
| from greenproof_ml import advisor | |
| try: | |
| result = advisor.advise_checkin(req.checkin_id) | |
| except LookupError as e: | |
| raise HTTPException(404, str(e)) from e | |
| except Exception as e: # noqa: BLE001 | |
| log.exception("advice failed for %s", req.checkin_id) | |
| raise HTTPException(500, f"Advice failed: {e}") from e | |
| if result is None: | |
| # Not an error. Either the tree shows no decline and the policy skipped | |
| # it, or the model declined to answer. Both leave the row untouched. | |
| return {"checkin_id": req.checkin_id, "written": False} | |
| return {"checkin_id": req.checkin_id, "written": True, **result} | |
| def backfill(limit: int = 50) -> dict: | |
| """Score everything still pending. | |
| This is what makes T0 safe to run before the service exists: registration | |
| only captures and uploads, and the photos sit as `pending` until this is | |
| called. Nothing about the pilot depends on the ML service being live on the | |
| day. | |
| """ | |
| if not _model_ready.is_set(): | |
| raise HTTPException(503, "Model still loading, retry shortly") | |
| rows = store.list_pending(limit) | |
| done, failed = [], [] | |
| for row in rows: | |
| try: | |
| result = score_checkin(row["id"]) | |
| _advise_in_background(row["id"]) | |
| done.append({"id": row["id"], "verdict": result.verdict, "confidence": result.confidence}) | |
| except Exception as e: # noqa: BLE001 | |
| log.exception("backfill failed for %s", row["id"]) | |
| failed.append({"id": row["id"], "error": str(e)}) | |
| return {"scored": len(done), "failed": len(failed), "results": done, "errors": failed} | |