Add HTTP API server, Android app, and CI build/publish pipeline
build / build (push) Failing after 14s

- docker-compose.yml + tooling-container/src/server.py: serve
  preprocess/OCR/extract over an async job API (POST /jobs, GET
  /jobs/{id}) instead of manual host-script runs per receipt
- android/: Kotlin/Compose app - capture a receipt photo, poll job
  progress, review/edit extracted fields, save locally (Room), export
  to CSV/ODS
- .gitea/workflows/build.yml: build the debug APK and publish it to
  dl.ladkau.de on push
- build-image.sh: also pull ollama/ollama so all pipeline images are
  ready after one script
- add project logo (also used for the app's launcher icon/splash)
This commit is contained in:
ml
2026-09-29 12:58:58 +02:00
parent 7cea285ec3
commit 516f5d3b7d
54 changed files with 2101 additions and 3 deletions
+128
View File
@@ -0,0 +1,128 @@
#!/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, LAN-only, single-instance server with no need for persistence
here (the Android app is the system of record for confirmed receipts).
"""
import os
import shutil
import tempfile
import threading
import time
import uuid
import requests
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from extract import structure
from ocr import ocr as run_ocr
from preprocess import preprocess
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")
app = FastAPI(title="fuelish")
_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")
final_path = preprocess(image_path, scratch_dir)
_set_job(job_id, status="ocr")
ocr_txt_path = run_ocr(final_path, scratch_dir)
with open(ocr_txt_path, "r", encoding="utf-8") as f:
ocr_text = f.read()
_set_job(job_id, status="extracting")
result = structure(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)
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}")
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"]}