Spaces:
Sleeping
Sleeping
File size: 15,327 Bytes
ffdf96a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 | """Trace MVP: persistent records, PIN sessions, vision assessments and chain verification."""
import base64
import hashlib
import hmac
import io
import json
import os
import secrets
import sqlite3
import threading
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import requests
from dotenv import load_dotenv
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.responses import Response
from pydantic import BaseModel, Field
load_dotenv(Path(__file__).resolve().with_name('.env'))
router = APIRouter(prefix="/api/trace", tags=["UzoAgro Trace"])
lock = threading.RLock()
security = HTTPBearer(auto_error=False)
def now():
return datetime.now(timezone.utc).isoformat()
def fingerprint(record):
return hashlib.sha256(json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
def db():
if os.getenv("DATABASE_URL"):
import psycopg2
try:
conn = psycopg2.connect(os.environ["DATABASE_URL"], connect_timeout=10)
except psycopg2.Error:
raise HTTPException(503, 'The Neon database is temporarily unavailable. Please try again.')
elif os.getenv("TRACE_SQLITE_PATH"):
conn = sqlite3.connect(os.getenv("TRACE_SQLITE_PATH", str(Path(__file__).parent / "trace.db")), timeout=20)
else:
raise HTTPException(503, "Neon database is not configured. Set DATABASE_URL on the backend.")
with conn.cursor() if os.getenv("DATABASE_URL") else conn as cur:
if os.getenv("DATABASE_URL"):
cur.execute("CREATE TABLE IF NOT EXISTS trace_records (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
else:
conn.execute("CREATE TABLE IF NOT EXISTS trace_records (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")
conn.commit()
return conn
def put(key, value):
conn = db()
try:
cur = conn.cursor()
mark = "%s" if os.getenv("DATABASE_URL") else "?"
cur.execute(f"INSERT INTO trace_records(id,payload) VALUES ({mark},{mark}) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", (key, json.dumps(value)))
conn.commit()
finally:
conn.close()
def get(key):
conn = db()
try:
cur = conn.cursor()
cur.execute("SELECT payload FROM trace_records WHERE id=" + ("%s" if os.getenv("DATABASE_URL") else "?"), (key,))
row = cur.fetchone()
return json.loads(row[0]) if row else None
finally:
conn.close()
def all_records(prefix):
conn = db()
try:
cur = conn.cursor()
cur.execute("SELECT payload FROM trace_records WHERE id LIKE " + ("%s" if os.getenv("DATABASE_URL") else "?"), (prefix + "%",))
return [json.loads(row[0]) for row in cur.fetchall()]
finally:
conn.close()
# Share the existing authenticated farmer identity.
from auth_api import farmer as user
@router.get("/config")
def config():
return {"contract_address": os.getenv("TRACE_CONTRACT_ADDRESS", ""), "chain_id": 84532, "rpc_url": os.getenv("BASE_SEPOLIA_RPC_URL", "https://sepolia.base.org"), "vision_ready": bool(os.getenv("OPENAI_API_KEY")), "storage": "postgresql" if os.getenv("DATABASE_URL") else "test sqlite" if os.getenv("TRACE_SQLITE_PATH") else "not configured"}
@router.get("/qr")
def qr(url: str, download: bool = False):
import qrcode
import qrcode.image.svg
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or len(url) > 1000:
raise HTTPException(400, "Invalid verification URL.")
image = qrcode.make(url, image_factory=qrcode.image.svg.SvgPathImage, border=3)
stream = io.BytesIO()
image.save(stream)
headers = {"Content-Disposition": 'attachment; filename="uzoagro-batch.svg"'} if download else {}
return Response(stream.getvalue(), media_type="image/svg+xml", headers=headers)
class Assessment(BaseModel):
crop: str = Field(max_length=80)
condition: str = Field(max_length=200)
confidence: float = Field(ge=0, le=1)
recommendation: str = Field(max_length=2000)
@router.post("/diagnose")
async def diagnose(file: UploadFile = File(...), owner=Depends(user)):
if not os.getenv("OPENAI_API_KEY"):
raise HTTPException(503, "Vision model is not configured. Use the labeled sample assessment to explore the workflow.")
content = await file.read(8 * 1024 * 1024 + 1)
if len(content) > 8 * 1024 * 1024:
raise HTTPException(413, "Choose an image smaller than 8 MB.")
try:
from PIL import Image
image = Image.open(io.BytesIO(content))
if image.format not in ("JPEG", "PNG", "WEBP"):
raise ValueError()
image.verify()
mime = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}[image.format]
except Exception:
raise HTTPException(400, "Upload a valid JPG, PNG or WebP crop image.")
try:
response = requests.post("https://api.openai.com/v1/responses", headers={"Authorization": "Bearer " + os.environ["OPENAI_API_KEY"]}, json={"model": os.getenv("VISION_MODEL", "gpt-4.1-mini"), "store": False, "input": [{"role": "user", "content": [{"type": "input_text", "text": 'Assess this crop image. Return ONLY JSON with crop, condition, confidence (0 to 1, an uncalibrated model estimate), recommendation. Be cautious about uncertainty. If not a crop, state that. Recommend consulting an extension professional, never prescribe pesticide concentrations or destructive actions.'}, {"type": "input_image", "image_url": "data:" + mime + ";base64," + base64.b64encode(content).decode()}]}]}, timeout=60)
response.raise_for_status()
output = "".join(c.get("text", "") for item in response.json().get("output", []) for c in item.get("content", []) if c.get("type") == "output_text")
assessment = Assessment.model_validate_json(output.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()).model_dump()
except Exception:
raise HTTPException(502, "The vision service could not return an assessment. Please try again.")
record = assessment | {"id": "DIA-" + secrets.token_hex(6).upper(), "created_at": now(), "sample": False, "image_hash": hashlib.sha256(content).hexdigest()}
record["hash"] = fingerprint(record)
put("diagnosis:" + record["id"], {"owner": owner["phone"], "report": record})
return record
@router.post("/diagnose/sample")
def sample_diagnosis(owner=Depends(user)):
record = {"id": "DIA-" + secrets.token_hex(6).upper(), "crop": "Cassava", "condition": "Possible mosaic symptoms — sample", "confidence": 0.86, "recommendation": "This is a sample report, not an analysis of your image. For a real assessment, consult an agricultural extension professional.", "created_at": now(), "sample": True}
record["hash"] = fingerprint(record)
put("diagnosis:" + record["id"], {"owner": owner["phone"], "report": record})
return record
class NewBatch(BaseModel):
crop: str = Field(min_length=2, max_length=64)
quantity: float = Field(gt=0, le=10000000)
community: str = Field(min_length=2, max_length=120)
harvest_date: date
diagnosis_id: str | None = None
def batch_or_404(batch_id):
batch = get("batch:" + batch_id)
if not batch:
raise HTTPException(404, "Batch not found. Check the ID and try again.")
return batch
def owned(batch_id, owner):
batch = batch_or_404(batch_id)
if batch["owner"] != owner["phone"]:
raise HTTPException(403, "Only the batch owner can update this record.")
return batch
def public(batch):
return {k: v for k, v in batch.items() if k != "owner"}
@router.post("/batches")
def create_batch(data: NewBatch, owner=Depends(user)):
if data.harvest_date > date.today():
raise HTTPException(422, "Harvest date cannot be in the future.")
diagnosis = None
if data.diagnosis_id:
stored = get("diagnosis:" + data.diagnosis_id)
if not stored or stored["owner"] != owner["phone"]:
raise HTTPException(404, "Assessment not found.")
diagnosis = stored["report"]
batch_id = "UZA-" + secrets.token_hex(4).upper()
metadata = data.model_dump(mode="json", exclude={"diagnosis_id"}) | {"id": batch_id, "farmer": owner["name"], "created_at": now(), "diagnosis": diagnosis}
batch = {"id": batch_id, "owner": owner["phone"], "metadata": metadata, "hash": fingerprint(metadata), "chain_key": hashlib.sha256(batch_id.encode()).hexdigest(), "transaction": None, "checkpoints": []}
put("batch:" + batch_id, batch)
return public(batch)
@router.get("/batches")
def list_batches(owner=Depends(user)):
return sorted([public(b) for b in all_records("batch:") if b["owner"] == owner["phone"]], key=lambda b: b["metadata"]["created_at"], reverse=True)
@router.get("/batches/{batch_id}")
def read_batch(batch_id: str):
return public(batch_or_404(batch_id))
@router.get("/batches/{batch_id}/history")
def history(batch_id: str):
batch = batch_or_404(batch_id)
return {"batch_id": batch_id, "created_at": batch["metadata"]["created_at"], "checkpoints": batch["checkpoints"]}
class NewCheckpoint(BaseModel):
stage: str = Field(pattern="^(Picked up|Received at hub|Delivered to buyer)$")
location: str = Field(min_length=2, max_length=120)
@router.post("/batches/{batch_id}/checkpoints")
def add_checkpoint(batch_id: str, data: NewCheckpoint, owner=Depends(user)):
with lock:
batch = owned(batch_id, owner)
expected = ["Picked up", "Received at hub", "Delivered to buyer"]
if len(batch["checkpoints"]) >= 3 or data.stage != expected[len(batch["checkpoints"])]:
raise HTTPException(409, "Record checkpoints in order: pickup, aggregation, delivery.")
if any(not cp["transaction"] for cp in batch["checkpoints"]) and batch["transaction"]:
raise HTTPException(409, "Anchor the previous checkpoint first.")
record = data.model_dump() | {"id": "CP-" + secrets.token_hex(6).upper(), "batch_id": batch_id, "recorded_by": owner["name"], "created_at": now()}
cp = {"record": record, "hash": fingerprint(record), "transaction": None}
batch["checkpoints"].append(cp)
put("batch:" + batch_id, batch)
return public(batch)
ABI = [
{"type": "function", "name": "getBatch", "stateMutability": "view", "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"type": "tuple", "components": [{"name": "creator", "type": "address"}, {"name": "crop", "type": "string"}, {"name": "dataHash", "type": "bytes32"}, {"name": "createdAt", "type": "uint256"}, {"name": "exists", "type": "bool"}]}]},
{"type": "function", "name": "getBatchCheckpoints", "stateMutability": "view", "inputs": [{"name": "id", "type": "bytes32"}], "outputs": [{"type": "tuple[]", "components": [{"name": "stage", "type": "string"}, {"name": "location", "type": "string"}, {"name": "dataHash", "type": "bytes32"}, {"name": "timestamp", "type": "uint256"}, {"name": "recordedBy", "type": "address"}]}]},
]
def chain():
from web3 import Web3
address = os.getenv("TRACE_CONTRACT_ADDRESS")
if not address:
raise HTTPException(503, "Deploy the contract and configure TRACE_CONTRACT_ADDRESS first.")
w3 = Web3(Web3.HTTPProvider(os.getenv("BASE_SEPOLIA_RPC_URL", "https://sepolia.base.org"), request_kwargs={"timeout": 15}))
if w3.eth.chain_id != 84532:
raise HTTPException(503, "RPC must use Base Sepolia.")
return w3, w3.eth.contract(address=Web3.to_checksum_address(address), abi=ABI)
class Anchor(BaseModel):
transaction: str = Field(pattern=r"^0x[0-9a-fA-F]{64}$")
checkpoint_id: str | None = None
@router.post("/batches/{batch_id}/anchor")
def anchor(batch_id: str, data: Anchor, owner=Depends(user)):
with lock:
batch = owned(batch_id, owner)
target = batch
index = None
if data.checkpoint_id:
matches = [(i, cp) for i, cp in enumerate(batch["checkpoints"]) if cp["record"]["id"] == data.checkpoint_id]
if not matches or not batch["transaction"]:
raise HTTPException(409, "Anchor the batch before its checkpoints.")
index, target = matches[0]
try:
w3, contract = chain()
receipt = w3.eth.get_transaction_receipt(data.transaction)
tx = w3.eth.get_transaction(data.transaction)
if receipt["status"] != 1 or not tx["to"] or tx["to"].lower() != contract.address.lower():
raise HTTPException(422, "Transaction was not a successful call to this contract.")
expected_event = w3.keccak(text="CheckpointAdded(bytes32,uint256,bytes32)" if index is not None else "BatchCreated(bytes32,address,bytes32)")
logs = [entry for entry in receipt["logs"] if entry["address"].lower() == contract.address.lower() and entry["topics"][0] == expected_event and entry["topics"][1].hex().removeprefix("0x") == batch["chain_key"]]
if not logs:
raise HTTPException(422, "Transaction does not contain this batch event.")
event_data = bytes(logs[0]["data"])
if event_data[-32:].hex() != target["hash"] or (index is not None and int.from_bytes(event_data[:32]) != index):
raise HTTPException(422, "Transaction fingerprint does not match this record.")
state = contract.functions.getBatch(bytes.fromhex(batch["chain_key"])).call()
if not state[4] or bytes(state[2]).hex() != batch["hash"]:
raise HTTPException(422, "On-chain batch fingerprint does not match.")
except HTTPException:
raise
except Exception:
raise HTTPException(502, "Cannot confirm the transaction yet. Retry after confirmation.")
target["transaction"] = data.transaction
put("batch:" + batch_id, batch)
return public(batch)
@router.get("/batches/{batch_id}/verify")
def verify(batch_id: str):
batch = batch_or_404(batch_id)
intact = hmac.compare_digest(fingerprint(batch["metadata"]), batch["hash"]) and all(fingerprint(cp["record"]) == cp["hash"] for cp in batch["checkpoints"])
result = {"integrity": intact, "blockchain_verified": False, "checkpoints_verified": False, "status": "Unanchored", "batch": public(batch)}
if not intact:
result["status"] = "Fingerprint mismatch"
elif batch["transaction"]:
try:
_, contract = chain()
state = contract.functions.getBatch(bytes.fromhex(batch["chain_key"])).call()
result["blockchain_verified"] = bool(state[4]) and bytes(state[2]).hex() == batch["hash"]
checkpoints = contract.functions.getBatchCheckpoints(bytes.fromhex(batch["chain_key"])).call()
result["checkpoints_verified"] = len(checkpoints) == len(batch["checkpoints"]) and all(bytes(chain_cp[2]).hex() == cp["hash"] for chain_cp, cp in zip(checkpoints, batch["checkpoints"]))
result["status"] = "Blockchain verified" if result["blockchain_verified"] else "Fingerprint mismatch"
except Exception:
result["status"] = "Network unavailable — verification pending"
return result
|