7a91a37049
build / build (push) Successful in 3m4s
Deployments reachable outside a trusted LAN need some form of access control. Set FUELISH_API_KEY and the server requires a matching X-API-Key header on /jobs; unset, behavior is unchanged (no auth). The Android app gets a matching Settings field. This only checks the key - TLS termination is left to a reverse proxy in front of the server, not handled here.
145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""HTTP API that serves the pipeline (preprocess -> ocr -> structure) to
|
|
the Android app. Runs the same in-process functions the numbered host
|
|
scripts drive via `docker run` per stage, but skips the per-stage
|
|
container start-up since a live server has no need for that
|
|
inspectability - the host scripts remain the way to inspect each stage
|
|
independently during pipeline development.
|
|
|
|
Jobs are processed asynchronously so the app can show real per-stage
|
|
progress: POST /jobs returns immediately with a job id; GET /jobs/{id}
|
|
reports which of preprocessing/ocr/extracting is currently running, and
|
|
the final result once done. Job state lives in memory only - this is a
|
|
personal, single-instance server with no need for persistence here (the
|
|
Android app is the system of record for confirmed receipts).
|
|
|
|
Auth: set FUELISH_API_KEY to require a matching X-API-Key header on
|
|
/jobs (GET /health stays open for reverse-proxy health checks). Leave it
|
|
unset for the original LAN-only/no-auth setup. This server does not
|
|
terminate TLS itself - see require_api_key()'s docstring.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
import requests
|
|
from fastapi import Depends, FastAPI, HTTPException, Security, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.security.api_key import APIKeyHeader
|
|
|
|
from adaptive import preprocess_and_ocr_adaptive
|
|
from extract import structure
|
|
|
|
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
|
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
|
|
API_KEY = os.environ.get("FUELISH_API_KEY", "")
|
|
|
|
app = FastAPI(title="fuelish")
|
|
|
|
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
|
|
|
|
def require_api_key(provided: str | None = Security(_api_key_header)) -> None:
|
|
"""No-op when FUELISH_API_KEY isn't set - the unchanged default for a
|
|
LAN-only deployment with no key configured. When it is set, every
|
|
request to a protected route must carry a matching X-API-Key header.
|
|
This only checks the key; it doesn't terminate TLS - a reverse proxy
|
|
in front of this server (e.g. Traefik) is expected to do that for any
|
|
deployment reachable outside a trusted LAN."""
|
|
if not API_KEY:
|
|
return
|
|
if provided != API_KEY:
|
|
raise HTTPException(status_code=401, detail="invalid or missing API key")
|
|
|
|
_jobs: dict[str, dict] = {}
|
|
_jobs_lock = threading.Lock()
|
|
|
|
|
|
def _set_job(job_id: str, **fields) -> None:
|
|
with _jobs_lock:
|
|
_jobs[job_id].update(fields)
|
|
|
|
|
|
def _ensure_model_pulled() -> None:
|
|
"""Best-effort: wait for Ollama to come up (compose starts `api` and
|
|
`ollama` together, with no ordering guarantee beyond container start)
|
|
and pull OLLAMA_MODEL if it isn't already present, so a bare `docker
|
|
compose up` is enough without a manual `ollama pull` step. Runs in a
|
|
background thread - jobs submitted before this finishes will simply
|
|
fail with a clear error from Ollama."""
|
|
tags = None
|
|
for _ in range(60):
|
|
try:
|
|
tags = requests.get(f"{OLLAMA_BASE_URL}/api/tags", timeout=5).json()
|
|
break
|
|
except requests.RequestException:
|
|
time.sleep(2)
|
|
if tags is None:
|
|
return
|
|
|
|
have = {m["name"] for m in tags.get("models", [])}
|
|
if OLLAMA_MODEL in have or any(m.startswith(OLLAMA_MODEL + ":") for m in have):
|
|
return
|
|
try:
|
|
requests.post(
|
|
f"{OLLAMA_BASE_URL}/api/pull",
|
|
json={"name": OLLAMA_MODEL},
|
|
timeout=1800,
|
|
)
|
|
except requests.RequestException:
|
|
pass
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _on_startup() -> None:
|
|
threading.Thread(target=_ensure_model_pulled, daemon=True).start()
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _process_job(job_id: str, image_path: str, scratch_dir: str) -> None:
|
|
try:
|
|
_set_job(job_id, status="preprocessing")
|
|
adaptive_result = preprocess_and_ocr_adaptive(image_path, scratch_dir)
|
|
|
|
_set_job(job_id, status="extracting")
|
|
result = structure(adaptive_result["ocr_text"])
|
|
|
|
_set_job(job_id, status="done", result=result)
|
|
except Exception as exc: # noqa: BLE001 - reported to the client, not swallowed
|
|
_set_job(job_id, status="error", error=str(exc))
|
|
finally:
|
|
shutil.rmtree(scratch_dir, ignore_errors=True)
|
|
|
|
|
|
@app.post("/jobs", status_code=201, dependencies=[Depends(require_api_key)])
|
|
async def create_job(image: UploadFile) -> JSONResponse:
|
|
job_id = uuid.uuid4().hex
|
|
scratch_dir = tempfile.mkdtemp(prefix=f"fuelish-{job_id}-")
|
|
image_path = os.path.join(scratch_dir, "upload" + os.path.splitext(image.filename or "")[1])
|
|
with open(image_path, "wb") as f:
|
|
f.write(await image.read())
|
|
|
|
with _jobs_lock:
|
|
_jobs[job_id] = {"status": "queued", "result": None, "error": None, "_created": time.time()}
|
|
|
|
thread = threading.Thread(target=_process_job, args=(job_id, image_path, scratch_dir), daemon=True)
|
|
thread.start()
|
|
|
|
return JSONResponse({"job_id": job_id}, status_code=201)
|
|
|
|
|
|
@app.get("/jobs/{job_id}", dependencies=[Depends(require_api_key)])
|
|
def get_job(job_id: str) -> dict:
|
|
with _jobs_lock:
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="job not found")
|
|
return {"status": job["status"], "result": job["result"], "error": job["error"]}
|