diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c8ff523 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +tests/ +requirements-dev.txt +.env +*.md +.venv/ +venv/ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8ea3d98 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.venv/ +venv/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4301b22 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PORT=8080 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py prompt.py ./ + +EXPOSE 8080 + +# Cloud Run sets PORT; default to 8080 locally. +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6c7e1b6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,137 @@ +# Ultraprocessed AI Proxy (Cloud Run + Vertex AI) + +A small FastAPI service that lets the Ultraprocessed (Zest) Android app stop shipping +user-supplied AI keys. The app sends **OCR-extracted ingredient text** to this proxy; the +proxy calls **Gemini through Vertex AI** using the Cloud Run **service-account identity** +(Application Default Credentials) and returns a NOVA classification, ingredient analysis, +ultra-processing markers, and likely allergens. + +- No user API keys. +- No service-account JSON files — auth is the Cloud Run runtime identity (ADC). +- Images never reach this service; only text, matching the app's privacy model. + +## Endpoints + +### `GET /healthz` +```json +{ "status": "ok" } +``` + +### `POST /analyze` +Request: +```json +{ + "ingredient_text": "water, sugar, palm oil, emulsifier (soy lecithin)", + "barcode": "optional string", + "product_name": "optional string", + "locale": "optional string" +} +``` +`ingredient_text` is required and capped at 20,000 characters (longer → HTTP 422). + +Response — the shape mirrors the app's existing Kotlin contract +(`NovaClassification` + `IngredientListAnalysis` + `AllergenDetection`), so future client +integration is a direct deserialize: +```json +{ + "nova": { + "containsConsumableFoodItem": true, + "novaGroup": 4, + "summary": "Short plain-language explanation.", + "rejectionReason": "", + "confidence": 0.85, + "warnings": [] + }, + "ingredients": { + "correctedIngredients": ["water", "sugar", "palm oil", "emulsifier"], + "ultraProcessedIngredients": [ + { "name": "emulsifier", "reason": "Common ultra-processing marker." } + ], + "confidence": 0.8, + "warnings": [] + }, + "allergens": { + "allergens": ["soy"], + "confidence": 0.6, + "warnings": [] + }, + "model": "gemini-2.5-flash", + "usage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 } +} +``` +On model/auth/timeout failure the service returns HTTP 502 with +`{"detail": {"error": "...", "message": "..."}}`. + +## Environment variables + +| Variable | Default | Purpose | +|-------------------|--------------------|------------------------------------------| +| `GCP_PROJECT_ID` | `b2-ultra-processed` | GCP project for Vertex AI | +| `GCP_LOCATION` | `us-east1` | Vertex AI region | +| `GEMINI_MODEL` | `gemini-2.5-flash` | Model id | +| `GEMINI_TIMEOUT_MS` | `30000` | Per-request model timeout (ms) | +| `PORT` | `8080` | Server port (set by Cloud Run) | + +## Local development + +```bash +cd backend +python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1 +pip install -r requirements-dev.txt + +# Run tests (no GCP credentials needed — the model call is mocked): +pytest + +# Run the server: +uvicorn main:app --reload --port 8080 +``` + +For a **live** `/analyze` call locally you need Application Default Credentials with +Vertex AI access: +```bash +gcloud auth application-default login +gcloud config set project b2-ultra-processed +``` +Without ADC, `/healthz` works and `/analyze` returns a 502 model-call error — expected. + +### Example requests +```bash +curl http://localhost:8080/healthz + +curl -X POST http://localhost:8080/analyze \ + -H "Content-Type: application/json" \ + -d '{"ingredient_text":"water, sugar, palm oil, emulsifier (soy lecithin)","product_name":"Choco Wafer"}' +``` + +## Deploy to Cloud Run + +```bash +gcloud run deploy ultraprocessed-ai-proxy \ + --source backend \ + --project b2-ultra-processed \ + --region us-east1 \ + --service-account up-app-service@b2-ultra-processed.iam.gserviceaccount.com \ + --allow-unauthenticated \ + --set-env-vars GCP_PROJECT_ID=b2-ultra-processed,GCP_LOCATION=us-east1,GEMINI_MODEL=gemini-2.5-flash +``` + +The service account must have Vertex AI access (e.g. `roles/aiplatform.user`) on the project. + +## Security notes + +- **`--allow-unauthenticated` is for initial testing only.** Before production, protect the + endpoint with app-level auth — Firebase Auth / App Check, API Gateway, or another approved + mechanism — and remove public access. +- **CORS is wide open (`*`) for development.** Restrict `allow_origins` to known origins for + production in `main.py`. +- No secrets, API keys, or service-account JSON are committed or required at runtime; auth is + the Cloud Run service-account identity via ADC. + +## Notes / caveats + +- **Region + model:** Vertex AI regional availability for `gemini-2.5-flash` varies. If a deploy + reports the model is unavailable in `us-east1`, set `GCP_LOCATION=global` (or `us-central1`) + via `--set-env-vars` and redeploy. +- **Single combined call:** this proxy makes one Gemini call returning all three sections. The + app currently performs three staged LLM calls (NOVA, ingredient cleanup, allergens). Wiring the + app to this proxy is intended as follow-up work once the Cloud Run URL exists. diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 0000000..0c39c04 --- /dev/null +++ b/backend/conftest.py @@ -0,0 +1,6 @@ +import os +import sys + +# Ensure the backend root (where main.py / prompt.py live) is importable +# regardless of the directory pytest is invoked from. +sys.path.insert(0, os.path.dirname(__file__)) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..27162b9 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,226 @@ +"""FastAPI backend proxy for the Ultraprocessed (Zest) Android app. + +Receives OCR-extracted ingredient text and returns a NOVA classification, +ingredient analysis, ultra-processing markers, and likely allergens by calling +Gemini through **Vertex AI** using the Cloud Run service-account identity +(Application Default Credentials). No user API keys, no service-account JSON files. + +Response shape mirrors the app's existing Kotlin contract (see prompt.py). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, Optional + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from prompt import build_prompt + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("ultraprocessed-ai-proxy") + +# --- Configuration (env vars with spec defaults) --------------------------------- +GCP_PROJECT_ID = os.getenv("GCP_PROJECT_ID", "b2-ultra-processed") +GCP_LOCATION = os.getenv("GCP_LOCATION", "us-east1") +GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") +REQUEST_TIMEOUT_MS = int(os.getenv("GEMINI_TIMEOUT_MS", "30000")) +MAX_INGREDIENT_CHARS = 20_000 + +app = FastAPI(title="Ultraprocessed AI Proxy", version="1.0.0") + +# CORS: permissive for development. Production should restrict origins (see README). +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +# --- Request / response models ---------------------------------------------------- +class AnalyzeRequest(BaseModel): + ingredient_text: str = Field(..., min_length=1, max_length=MAX_INGREDIENT_CHARS) + barcode: Optional[str] = None + product_name: Optional[str] = None + locale: Optional[str] = None + + +# --- Vertex / Gemini client (lazy so import works without creds, e.g. in tests) --- +_client = None + + +def _get_client(): + """Create and cache the Vertex-mode google-genai client (ADC auth).""" + global _client + if _client is None: + from google import genai + from google.genai import types + + _client = genai.Client( + vertexai=True, + project=GCP_PROJECT_ID, + location=GCP_LOCATION, + http_options=types.HttpOptions(timeout=REQUEST_TIMEOUT_MS), + ) + return _client + + +def call_gemini(prompt_text: str) -> tuple[str, dict]: + """Call Gemini via Vertex AI. Returns (raw_text, usage_dict). + + Monkeypatched in tests so the suite runs without live credentials. + """ + from google.genai import types + + client = _get_client() + resp = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt_text, + config=types.GenerateContentConfig( + temperature=0.0, + top_p=1.0, + max_output_tokens=2048, + response_mime_type="application/json", + ), + ) + usage = {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0} + meta = getattr(resp, "usage_metadata", None) + if meta is not None: + usage = { + "inputTokens": getattr(meta, "prompt_token_count", 0) or 0, + "outputTokens": getattr(meta, "candidates_token_count", 0) or 0, + "totalTokens": getattr(meta, "total_token_count", 0) or 0, + } + return (resp.text or "", usage) + + +# --- Parsing helpers -------------------------------------------------------------- +_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) + + +def parse_model_json(text: str) -> dict: + """Parse the model output into a dict, tolerating fences / surrounding prose.""" + if not text or not text.strip(): + raise ValueError("empty model response") + try: + return json.loads(text) + except json.JSONDecodeError: + pass + # Strip a ```json ... ``` fence if present. + fence = _FENCE_RE.search(text) + if fence: + try: + return json.loads(fence.group(1)) + except json.JSONDecodeError: + pass + # Last resort: grab the outermost {...} span. + start, end = text.find("{"), text.rfind("}") + if start != -1 and end != -1 and end > start: + return json.loads(text[start : end + 1]) + raise ValueError("could not parse JSON from model response") + + +def _as_float(value: Any, default: float = 0.5) -> float: + try: + return max(0.0, min(1.0, float(value))) + except (TypeError, ValueError): + return default + + +def _as_str_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + return [] + + +def coerce_response(data: dict, model: str, usage: dict) -> dict: + """Coerce raw model JSON into the app-aligned response, with safe defaults.""" + nova = data.get("nova") or {} + ingredients = data.get("ingredients") or {} + allergens = data.get("allergens") or {} + + try: + nova_group = int(nova.get("novaGroup", 0)) + except (TypeError, ValueError): + nova_group = 0 + consumable = bool(nova.get("containsConsumableFoodItem", nova_group != 0)) + if consumable: + nova_group = min(4, max(1, nova_group)) + else: + nova_group = 0 + + markers = [] + for item in ingredients.get("ultraProcessedIngredients") or []: + if isinstance(item, dict) and item.get("name"): + markers.append( + {"name": str(item["name"]).strip(), "reason": str(item.get("reason", "")).strip()} + ) + + return { + "nova": { + "containsConsumableFoodItem": consumable, + "novaGroup": nova_group, + "summary": str(nova.get("summary", "")).strip(), + "rejectionReason": str(nova.get("rejectionReason", "")).strip(), + "confidence": _as_float(nova.get("confidence")), + "warnings": _as_str_list(nova.get("warnings")), + }, + "ingredients": { + "correctedIngredients": _as_str_list(ingredients.get("correctedIngredients")), + "ultraProcessedIngredients": markers, + "confidence": _as_float(ingredients.get("confidence")), + "warnings": _as_str_list(ingredients.get("warnings")), + }, + "allergens": { + "allergens": _as_str_list(allergens.get("allergens")), + "confidence": _as_float(allergens.get("confidence")), + "warnings": _as_str_list(allergens.get("warnings")), + }, + "model": model, + "usage": usage, + } + + +# --- Endpoints -------------------------------------------------------------------- +@app.get("/healthz") +def healthz() -> dict: + return {"status": "ok"} + + +@app.post("/analyze") +def analyze(req: AnalyzeRequest) -> dict: + prompt_text = build_prompt( + ingredient_text=req.ingredient_text, + product_name=req.product_name, + barcode=req.barcode, + locale=req.locale, + ) + try: + raw_text, usage = call_gemini(prompt_text) + except Exception as exc: # network, auth, timeout, model errors + logger.exception("Gemini call failed") + raise HTTPException( + status_code=502, + detail={"error": "model_call_failed", "message": str(exc)}, + ) + + try: + parsed = parse_model_json(raw_text) + except ValueError as exc: + logger.warning("Model returned unparseable output: %s", exc) + raise HTTPException( + status_code=502, + detail={ + "error": "model_response_unparseable", + "message": str(exc), + }, + ) + + return coerce_response(parsed, GEMINI_MODEL, usage) diff --git a/backend/prompt.py b/backend/prompt.py new file mode 100644 index 0000000..ca05fd5 --- /dev/null +++ b/backend/prompt.py @@ -0,0 +1,79 @@ +"""Prompt construction for the NOVA / ingredient / allergen analysis call. + +The response schema intentionally mirrors the Android app's existing Kotlin +contract (``NovaClassification`` + ``IngredientListAnalysis`` + ``AllergenDetection`` +in ``app/src/main/java/com/b2/ultraprocessed/network/llm``) so a future client can +deserialize the proxy response directly. +""" + +from __future__ import annotations + +import json +from typing import Optional + +# Shape the model is told to return. Keys match the app data classes exactly. +RESPONSE_SHAPE = { + "nova": { + "containsConsumableFoodItem": "boolean", + "novaGroup": "integer 1-4, or 0 if not a consumable food", + "summary": "2-3 line plain-language note for a shopper", + "rejectionReason": "string, why rejected if not consumable, else empty", + "confidence": "number 0.0-1.0", + "warnings": ["string"], + }, + "ingredients": { + "correctedIngredients": ["cleaned ingredient name"], + "ultraProcessedIngredients": [ + {"name": "ingredient", "reason": "why it is an ultra-processing marker"} + ], + "confidence": "number 0.0-1.0", + "warnings": ["string"], + }, + "allergens": { + "allergens": ["common allergen name"], + "confidence": "number 0.0-1.0", + "warnings": ["string"], + }, +} + +SYSTEM_RULES = """You are a food-label analysis service. You classify a packaged +food product using ONLY the ingredient text (and optional product name / barcode) +provided. You apply the NOVA food-processing framework (1 = unprocessed/minimally +processed, 2 = processed culinary ingredients, 3 = processed foods, 4 = ultra-processed). + +Hard rules: +- Return STRICT JSON only. No markdown, no code fences, no prose outside the JSON. +- Do NOT provide medical diagnosis or health advice. Describe processing, not disease. +- Do NOT invent ingredients that are not present in the input text. +- If the text is unclear, partial, or not a food label, set low confidence, add a + short note in "warnings", and (if not a consumable food) set + containsConsumableFoodItem=false with a rejectionReason. +- Allergens that are not explicitly stated must be treated as uncertain: include them + only when reasonably implied and keep confidence modest. Never assert a confirmed + allergen from ambiguous text. +- "correctedIngredients" should be the cleaned, de-OCR'd ingredient names in order. +- "ultraProcessedIngredients" lists only ingredients that are genuine NOVA-4 markers + (e.g. emulsifiers, flavour enhancers, colours, hydrogenated oils, sweeteners).""" + + +def build_prompt( + ingredient_text: str, + product_name: Optional[str] = None, + barcode: Optional[str] = None, + locale: Optional[str] = None, +) -> str: + """Assemble the full prompt string sent to Gemini.""" + payload = { + "productName": product_name or "", + "barcode": barcode or "", + "locale": locale or "", + "ingredientText": ingredient_text, + } + return ( + f"{SYSTEM_RULES}\n\n" + "## Required JSON output shape (return exactly these keys)\n" + f"{json.dumps(RESPONSE_SHAPE, indent=2)}\n\n" + "## Input\n" + f"{json.dumps(payload, ensure_ascii=False)}\n\n" + "Return only the JSON object." + ) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..7c3edca --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +# Dev/test only — not installed into the Cloud Run image. +-r requirements.txt +pytest>=8.0 +httpx>=0.27 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..176d753 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.34,<1.0 +google-genai>=1.0,<2.0 +pydantic>=2.9,<3.0 diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..d740bb5 --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,93 @@ +"""Tests run without live Vertex credentials: call_gemini is monkeypatched.""" + +import json + +from fastapi.testclient import TestClient + +import main + +client = TestClient(main.app) + +CANNED = { + "nova": { + "containsConsumableFoodItem": True, + "novaGroup": 4, + "summary": "Ultra-processed snack with additives.", + "rejectionReason": "", + "confidence": 0.85, + "warnings": [], + }, + "ingredients": { + "correctedIngredients": ["water", "sugar", "palm oil", "emulsifier"], + "ultraProcessedIngredients": [ + {"name": "emulsifier", "reason": "Common ultra-processing marker."} + ], + "confidence": 0.8, + "warnings": [], + }, + "allergens": {"allergens": ["milk"], "confidence": 0.6, "warnings": []}, +} + +USAGE = {"inputTokens": 12, "outputTokens": 34, "totalTokens": 46} + + +def test_healthz(): + r = client.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +def test_analyze_mocked(monkeypatch): + monkeypatch.setattr(main, "call_gemini", lambda p: (json.dumps(CANNED), USAGE)) + r = client.post("/analyze", json={"ingredient_text": "water, sugar, palm oil, emulsifier"}) + assert r.status_code == 200 + body = r.json() + assert body["nova"]["novaGroup"] == 4 + assert body["nova"]["containsConsumableFoodItem"] is True + assert body["ingredients"]["ultraProcessedIngredients"][0]["name"] == "emulsifier" + assert body["allergens"]["allergens"] == ["milk"] + assert body["model"] == main.GEMINI_MODEL + assert body["usage"] == USAGE + + +def test_analyze_strips_markdown_fence(monkeypatch): + fenced = "```json\n" + json.dumps(CANNED) + "\n```" + monkeypatch.setattr(main, "call_gemini", lambda p: (fenced, USAGE)) + r = client.post("/analyze", json={"ingredient_text": "water, sugar"}) + assert r.status_code == 200 + assert r.json()["nova"]["novaGroup"] == 4 + + +def test_analyze_clamps_nova_group(monkeypatch): + bad = json.loads(json.dumps(CANNED)) + bad["nova"]["novaGroup"] = 9 # out of range + monkeypatch.setattr(main, "call_gemini", lambda p: (json.dumps(bad), USAGE)) + r = client.post("/analyze", json={"ingredient_text": "x"}) + assert r.json()["nova"]["novaGroup"] == 4 + + +def test_analyze_rejects_overlong_text(): + r = client.post("/analyze", json={"ingredient_text": "x" * 20_001}) + assert r.status_code == 422 + + +def test_analyze_rejects_empty_text(): + r = client.post("/analyze", json={"ingredient_text": ""}) + assert r.status_code == 422 + + +def test_analyze_model_failure_returns_502(monkeypatch): + def boom(_): + raise RuntimeError("vertex unavailable") + + monkeypatch.setattr(main, "call_gemini", boom) + r = client.post("/analyze", json={"ingredient_text": "water"}) + assert r.status_code == 502 + assert r.json()["detail"]["error"] == "model_call_failed" + + +def test_analyze_unparseable_returns_502(monkeypatch): + monkeypatch.setattr(main, "call_gemini", lambda p: ("not json at all", USAGE)) + r = client.post("/analyze", json={"ingredient_text": "water"}) + assert r.status_code == 502 + assert r.json()["detail"]["error"] == "model_response_unparseable"