trackit / backend /main.py
devZenaight's picture
npm run dev
479f206
Raw
History Blame Contribute Delete
4.87 kB
from fastapi import FastAPI, File, UploadFile, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
import io
import os
import uuid
from datetime import datetime, timezone
from utils.image_processing import crop_receipt_from_image
from utils.ocr import run_hf_ocr
from utils.supabase_helpers import (
fetch_user_from_supabase_token,
upload_bytes_to_supabase_storage,
insert_slip_row,
)
APP_NAME = "Slip Scanner Backend"
try:
from dotenv import load_dotenv # type: ignore
load_dotenv()
except Exception:
# Optional dependency; ignore if not present
pass
app = FastAPI(title=APP_NAME)
frontend_origin = os.getenv("FRONTEND_CORS_ORIGIN", "*")
app.add_middleware(
CORSMiddleware,
allow_origins=[frontend_origin] if frontend_origin != "*" else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
required_env = [
"SUPABASE_URL",
"SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_STORAGE_BUCKET",
"HUGGINGFACE_API_TOKEN",
]
missing = [k for k in required_env if not os.getenv(k)]
return {
"service": APP_NAME,
"status": "ok" if not missing else "degraded",
"missing_env": missing,
}
@app.post("/scan")
async def scan(
file: UploadFile = File(...),
authorization: str | None = Header(default=None, convert_underscores=False),
):
# Validate auth header
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
user_jwt = authorization.split(" ", 1)[1]
user = fetch_user_from_supabase_token(user_jwt)
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Invalid Supabase session")
user_id = user["id"]
# Read upload
original_bytes = await file.read()
if not original_bytes:
raise HTTPException(status_code=400, detail="Empty file upload")
# Crop receipt via edge detection & perspective transform
try:
cropped_bytes, crop_meta = crop_receipt_from_image(original_bytes)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Failed to process image: {exc}")
# OCR via Hugging Face Inference API
try:
ocr_text = run_hf_ocr(cropped_bytes)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"OCR failed: {exc}")
# Extract naive amount (ZAR or generic currency) and a fallback category
amount = _extract_amount(ocr_text)
category = _infer_category(ocr_text)
# Upload cropped image to Supabase Storage
bucket = os.getenv("SUPABASE_STORAGE_BUCKET", "slips")
object_name = f"{user_id}/{uuid.uuid4()}.jpg"
try:
public_url = upload_bytes_to_supabase_storage(
bucket=bucket,
object_path=object_name,
content_bytes=cropped_bytes,
content_type="image/jpeg",
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Upload failed: {exc}")
# Insert DB row into slips table
try:
inserted = insert_slip_row(
{
"user_id": user_id,
"image_path": object_name,
"image_url": public_url,
"raw_text": ocr_text,
"amount": amount,
"category": category,
"created_at": datetime.now(timezone.utc).isoformat(),
"crop_confidence": crop_meta.get("confidence"),
}
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Database insert failed: {exc}")
return JSONResponse(
{
"ok": True,
"slip": inserted,
}
)
def _extract_amount(text: str) -> float | None:
import re
if not text:
return None
# Look for amounts with optional currency symbol/letter (e.g., R250.00, $12.34)
candidates: list[float] = []
for match in re.finditer(r"(?:R|\$)?\s*(\d{1,3}(?:[\,\s]\d{3})*(?:\.\d{2})?|\d+(?:\.\d{2}))", text, flags=re.IGNORECASE):
num_str = match.group(1).replace(",", "").replace(" ", "")
try:
candidates.append(float(num_str))
except ValueError:
continue
return max(candidates) if candidates else None
def _infer_category(text: str) -> str:
text_l = (text or "").lower()
if any(k in text_l for k in ["fuel", "petrol", "gas"]):
return "Fuel"
if any(k in text_l for k in ["restaurant", "meal", "food", "dine"]):
return "Food"
if any(k in text_l for k in ["grocery", "supermarket", "market", "store"]):
return "Grocery"
return "Uncategorized"