diff --git a/.gitignore b/.gitignore index 7ca5e91..1dde794 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ surya-env .DS_Store __pycache__ .claude +CLAUDE.md diff --git a/lending-poc/.dockerignore b/lending-poc/.dockerignore index 02dfa58..748d8d0 100644 --- a/lending-poc/.dockerignore +++ b/lending-poc/.dockerignore @@ -5,10 +5,13 @@ **/surya-env **/node_modules **/__pycache__ -document_processing -field_mapping_poc -gateway +# document_processing/, field_mapping_poc/, gateway/, and scripts/ are all +# part of the root build context now — the root Dockerfile builds the single +# combined backend image (all five services) rather than just app/, so their +# requirements.txt, source, and the entrypoint script all have to be +# reachable from here. Only frontend/ and docs/ stay out: frontend has its +# own image, docs ship nothing. frontend -scripts docs *.log +.env diff --git a/lending-poc/.env.example b/lending-poc/.env.example index c1ea069..4c0223f 100644 --- a/lending-poc/.env.example +++ b/lending-poc/.env.example @@ -23,3 +23,4 @@ OLLAMA_MODEL=gemma4:e4b-it-qat # OCR_REQUEST_TIMEOUT_SECONDS=300 # TRANSLATION_REQUEST_TIMEOUT_SECONDS=300 # FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS=300 +# APP_REQUEST_TIMEOUT_SECONDS=300 diff --git a/lending-poc/Dockerfile b/lending-poc/Dockerfile index 679c8f1..d332874 100644 --- a/lending-poc/Dockerfile +++ b/lending-poc/Dockerfile @@ -1,16 +1,72 @@ -FROM python:3.12-slim +# Multi-stage build: gcc/libpq-dev and pip's build cache are only needed to +# *install* the dependencies below, not to run them (nothing here uses +# psycopg — asyncpg, the actual Postgres driver, ships its own compiled +# protocol implementation and needs neither). Keeping them confined to this +# "build" stage means they never end up in the final image, which the single- +# stage version of this Dockerfile used to ship unnecessarily. +FROM python:3.12-slim AS build RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev gcc \ && rm -rf /var/lib/apt/lists/* +# Installing into a venv (rather than the system site-packages) means the +# final stage can grab everything with one `COPY --from=build /opt/venv`, +# instead of having to know the exact site-packages path to copy. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +# CPU by default: pin the CPU-only torch wheel so the image doesn't pull +# CUDA deps it can't use (sentence-transformers, pulled in via pyproject.toml +# below, and surya-ocr, pulled in via document_processing/ocr/requirements.txt, +# both depend on torch). Paired with the "gpu" compose profile, build with +# --build-arg GPU=1 to skip the pin and let their default CUDA-enabled wheel +# install instead. +ARG GPU=0 +RUN if [ "$GPU" = "0" ]; then \ + pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu; \ + fi + +# app, field_mapping, translation, ocr, and gateway each keep their own +# dependency manifest, installed as-is — this image runs all five as +# separate uvicorn processes behind one entrypoint (scripts/start-combined.sh), +# it doesn't merge their code. Manifests are copied (and installed) before +# the rest of the source so this layer is only invalidated when a dependency +# changes, not on every code edit. +# +# The four requirements.txt files are staged in /deps rather than under their +# real paths on purpose: `pip install .` below relies on setuptools' flat- +# layout discovery in /app, which aborts if it finds sibling top-level package +# directories (field_mapping_poc/, document_processing/, gateway/) next to +# pyproject.toml. Keeping /app empty apart from pyproject.toml at install time +# preserves the behaviour this step had when it built app/ alone. +COPY pyproject.toml ./ +COPY field_mapping_poc/requirements.txt /deps/field_mapping.txt +COPY document_processing/translation/requirements.txt /deps/translation.txt +COPY document_processing/ocr/requirements.txt /deps/ocr.txt +COPY gateway/requirements.txt /deps/gateway.txt + +RUN pip install --no-cache-dir . \ + && pip install --no-cache-dir -r /deps/field_mapping.txt \ + && pip install --no-cache-dir -r /deps/translation.txt \ + && pip install --no-cache-dir -r /deps/ocr.txt \ + && pip install --no-cache-dir -r /deps/gateway.txt + +# Final stage: no compiler, no dev headers, no pip build cache — just the +# Python runtime, the installed venv, and the source. +FROM python:3.12-slim + WORKDIR /app -COPY pyproject.toml . -RUN pip install --no-cache-dir . +COPY --from=build /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" COPY . . -EXPOSE 8000 +RUN chmod +x scripts/start-combined.sh + +EXPOSE 8000 8001 8002 8010 8080 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["scripts/start-combined.sh"] diff --git a/lending-poc/README.md b/lending-poc/README.md index 88e8ac8..6cbb91b 100644 --- a/lending-poc/README.md +++ b/lending-poc/README.md @@ -45,11 +45,16 @@ see [Using a GPU](#using-a-gpu) below. docker compose up --build -d ``` -This builds and starts every service: `db`, `app`, `ollama`, `field_mapping`, -`translation`, `surya-inference` + `ocr`, `gateway`, and `frontend`. +This builds and starts every service: `db`, `ollama`, `surya-inference`, +`backend` (which runs `app`, `field_mapping`, `translation`, `ocr`, and +`gateway` as five processes in one container — see +[Inside the backend container](#inside-the-backend-container) below), and +`frontend`. The first run takes a while — Ollama and Surya both download models on -first use. Watch progress with: +first use, and `backend`'s image build is the slowest of the bunch (it +installs every service's dependencies, including two separate CPU-only +PyTorch installs). Watch progress with: ```bash docker compose logs -f @@ -57,10 +62,11 @@ docker compose logs -f ## 3. Run database migrations -The `app` container doesn't run migrations automatically on startup: +`app` (one of the five processes in the `backend` container) doesn't run +migrations automatically on startup: ```bash -docker compose exec app alembic -c db/alembic.ini upgrade head +docker compose exec backend alembic -c db/alembic.ini upgrade head ``` ## 4. Pull the Ollama model @@ -80,23 +86,45 @@ the command above.) | Service | URL | Notes | |---|---|---| | Frontend | http://localhost:5173 | Main UI | -| Gateway | http://localhost:8080 | Fronts OCR / translation / field-mapping | -| App (backend API) | http://localhost:8000 | Docs at `/docs`; health at `/health` | +| Gateway | http://localhost:8080 | Single public entrypoint — fronts app/OCR/translation/field-mapping | +| App (cases API) | http://localhost:8000 | Docs at `/docs`; also reachable via gateway at `/cases`, `/app/health` | | Postgres | localhost:55439 | pgvector-enabled | -| OCR | http://localhost:8010 | Not normally called directly | -| Translation | http://localhost:8001 | Not normally called directly | -| Field mapping | http://localhost:8002 | Not normally called directly | +| OCR | http://localhost:8010 | Also reachable via gateway at `/extract`, `/ocr/health` | +| Translation | http://localhost:8001 | Also reachable via gateway at `/translate/*`, `/translation/health` | +| Field mapping | http://localhost:8002 | Also reachable via gateway at `/map`, `/field-mapping/health` | | Surya inference | http://localhost:8500 | OCR's inference backend | -There are effectively two subsystems sharing this compose file: the -`app` + `db` lending backend, and a separate OCR/translation/field-mapping -pipeline fronted by `gateway`. The frontend talks to the gateway for -document processing and to the app for everything else. +`app`, `ocr`, `translation`, `field_mapping`, and `gateway` all run inside +the single `backend` container (see below) — the four backend ports above +are published straight from that container for direct debugging, but the +frontend and any external caller should go through the gateway on `:8080`, +which proxies to all four and exposes one aggregate `/health`. + +### Inside the backend container + +`backend` (and its GPU twin `backend-gpu`) runs five independent +processes rather than one — `scripts/start-combined.sh` starts each with +its own `uvicorn` command, on its own port, exactly as it would run +standalone: + +| Process | Port | Role | +|---|---|---| +| `app` | 8000 | Case submission and decisioning (`/cases`) | +| `translation` | 8001 | OCR-text translation | +| `field_mapping` | 8002 | Maps OCR text onto a target JSON schema | +| `ocr` | 8010 | Document text extraction (calls `surya-inference`) | +| `gateway` | 8080 | Reverse-proxies to the other four on one public port | + +None of the five services' own code is aware they share a container — +each is the same FastAPI app it would be if it ran alone, just co-located +for fewer containers to manage. `docker compose logs -f backend` shows +all five processes' output interleaved, prefixed the same way regardless +of which one logged it. ## Using a GPU -`surya-inference`/`ocr` and `ollama` each come in a CPU and a GPU variant, -selected by `COMPOSE_PROFILES` in `.env`: +`surya-inference`, `backend` (which includes `ocr`), and `ollama` each +come in a CPU and a GPU variant, selected by `COMPOSE_PROFILES` in `.env`: - `COMPOSE_PROFILES=cpu` (default) — always works, no GPU required. - `COMPOSE_PROFILES=gpu` — requires an NVIDIA GPU on the host plus the @@ -119,11 +147,13 @@ fail to create the containers at all if the toolkit isn't installed, since the GPU device reservation can't be satisfied. Ollama's own image auto-detects CUDA at runtime with no separate build, so -switching the profile is enough for it; `surya-inference`/`ocr` are built -from CUDA base images specifically for the `gpu` profile (see -[docker-compose.yml](docker-compose.yml) and +switching the profile is enough for it. `surya-inference` is built from a +CUDA base image for the `gpu` profile (see [document_processing/ocr/README.md](document_processing/ocr/README.md) -for details). +for details); `backend-gpu` instead passes a `GPU=1` build arg that skips +pinning the CPU-only PyTorch wheel, so `ocr` (surya-ocr) and `app` +(sentence-transformers) install CUDA-enabled PyTorch instead — see +[docker-compose.yml](docker-compose.yml) and the [Dockerfile](Dockerfile). ## Stopping and cleanup @@ -132,8 +162,8 @@ docker compose down ``` Add `-v` to also delete the named volumes (`pgdata`, `ollama_models`, -`surya_models`) — this wipes the database and downloaded models, so only -do this if you want a clean slate: +`surya_models`, `hf_cache`) — this wipes the database and downloaded +models, so only do this if you want a clean slate: ```bash docker compose down -v diff --git a/lending-poc/docker-compose.yml b/lending-poc/docker-compose.yml index a72a413..2ed3499 100644 --- a/lending-poc/docker-compose.yml +++ b/lending-poc/docker-compose.yml @@ -1,4 +1,20 @@ +# Local stack: Postgres, a local LLM (Ollama), OCR inference (Surya), the +# combined backend (app/translation/field_mapping/ocr/gateway), and the +# frontend. +# +# Most backend-facing services come in "cpu"/"gpu" pairs (e.g. ollama / +# ollama-gpu), selected at `docker compose up` time via COMPOSE_PROFILES in +# .env (defaults to "cpu" so a plain `docker compose up` always works with +# no GPU setup). Each gpu variant is written with YAML's `<<: *name` merge +# key to copy its cpu sibling's config and override only what differs (a +# GPU device reservation, a different build), rather than duplicating the +# whole block. services: + # Postgres with the pgvector extension, used for storing embeddings + # (see the sentence-transformers note on `backend` below). `healthcheck` + # lets `backend`'s `depends_on: condition: service_healthy` wait for + # Postgres to actually accept connections, not just for the container to + # start. db: image: pgvector/pgvector:pg16 env_file: @@ -9,6 +25,7 @@ services: POSTGRES_DB: ${POSTGRES_DB:-lending_poc} ports: - "${POSTGRES_HOST_PORT:-55439}:5432" + restart: unless-stopped volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -17,21 +34,6 @@ services: timeout: 5s retries: 5 - app: - build: . - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - ports: - - "8000:8000" - env_file: - - .env - environment: - DATABASE_URL: ${DATABASE_URL_DOCKER:-postgresql+asyncpg://postgres:postgres@db:5432/lending_poc} - volumes: - - .:/app - depends_on: - db: - condition: service_healthy - # Ollama's own image already auto-detects CUDA at runtime and falls back # to CPU on its own — no custom build needed here, just the same cpu/gpu # profile split used for surya-inference/ocr below, so GPU access is only @@ -40,6 +42,7 @@ services: &ollama image: ollama/ollama:latest profiles: ["cpu"] + restart: unless-stopped ports: - "11434:11434" environment: @@ -54,6 +57,10 @@ services: ollama-gpu: <<: *ollama profiles: ["gpu"] + # Only one of ollama/ollama-gpu is ever running (profile-gated), so this + # alias makes ollama-gpu also answer to the plain name "ollama" on the + # Docker network — backend's OLLAMA_HOST doesn't need to know which + # variant is active. networks: default: aliases: @@ -66,45 +73,6 @@ services: count: all capabilities: [gpu] - field_mapping: - build: ./field_mapping_poc - command: uvicorn api:app --host 0.0.0.0 --port 8002 --reload - ports: - - "8002:8002" - environment: - OLLAMA_HOST: ${OLLAMA_HOST:-http://ollama:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma4:e4b-it-qat} - # Cold model load alone can take 2+ minutes; give the first call after - # an idle gap enough headroom instead of timing out mid-load. - OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS:-300} - volumes: - - ./field_mapping_poc:/app - depends_on: - ollama: - condition: service_started - required: false - ollama-gpu: - condition: service_started - required: false - - translation: - build: ./document_processing/translation - command: uvicorn api_server:app --host 0.0.0.0 --port 8001 --reload - ports: - - "8001:8001" - environment: - OLLAMA_HOST: ${OLLAMA_HOST:-http://ollama:11434} - OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma4:e4b-it-qat} - volumes: - - ./document_processing/translation:/app - depends_on: - ollama: - condition: service_started - required: false - ollama-gpu: - condition: service_started - required: false - # surya-inference and ocr each come in a "cpu" and "gpu" variant, selected # via COMPOSE_PROFILES in .env (defaults to "cpu" so plain `docker compose # up` always works). Both variants of a pair share a network alias so @@ -136,6 +104,10 @@ services: default: aliases: - surya-inference + # This is the container that actually does GPU-heavy OCR recognition. + # backend's Surya client (SURYA_INFERENCE_AUTOSTART=false) never runs a + # model locally — it just calls this service over HTTP + # (SURYA_INFERENCE_URL), so backend-gpu's own GPU is not involved in OCR. deploy: resources: reservations: @@ -144,19 +116,81 @@ services: count: all capabilities: [gpu] - ocr: - &ocr - build: ./document_processing/ocr + # app, translation, field-mapping, ocr, and gateway run as five separate + # uvicorn processes inside one image (see scripts/start-combined.sh) — + # none of their own code changed, this just co-locates them. Internal + # *_BASE_URL vars point at 127.0.0.1 since they're now processes in the + # same container rather than separate services on the Docker network. + # Same cpu/gpu profile split as ollama/surya-inference above: the GPU + # build arg controls which torch wheel gets installed for the two + # torch-dependent deps in here (sentence-transformers, surya-ocr). + # Only sentence-transformers (embeddings for /cases) actually runs model + # inference in this container, so it's the only thing backend-gpu's GPU + # is used for — surya-ocr depends on torch too, but here it's just a thin + # HTTP client to the surya-inference service above, which does the real + # OCR GPU work. + backend: + &backend + build: + context: . + args: + GPU: "0" profiles: ["cpu"] - command: uvicorn api:app --host 0.0.0.0 --port 8010 --reload + restart: unless-stopped + # 8000 app, 8001 translation, 8002 field_mapping, 8010 ocr, 8080 gateway. ports: + - "8000:8000" + - "8001:8001" + - "8002:8002" - "8010:8010" + - "8080:8080" + env_file: + - .env environment: + DATABASE_URL: ${DATABASE_URL_DOCKER:-postgresql+asyncpg://postgres:postgres@db:5432/lending_poc} + OLLAMA_HOST: ${OLLAMA_HOST:-http://ollama:11434} + OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma4:e4b-it-qat} + # Cold model load alone can take 2+ minutes; give the first call after + # an idle gap enough headroom instead of timing out mid-load. + OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS:-300} SURYA_INFERENCE_URL: http://surya-inference:8000/v1 SURYA_INFERENCE_AUTOSTART: "false" + APP_BASE_URL: http://127.0.0.1:8000 + TRANSLATION_BASE_URL: http://127.0.0.1:8001 + FIELD_MAPPING_BASE_URL: http://127.0.0.1:8002 + OCR_BASE_URL: http://127.0.0.1:8010 + # Each backend has a model in its path and can legitimately run for + # minutes (OCR to Surya, translation/field-mapping to Ollama, /cases + # loading a sentence-transformers model on first call) — these match + # the timeouts the frontend already budgets for the same calls, so the + # gateway is never the first link in the chain to give up. + OCR_REQUEST_TIMEOUT_SECONDS: ${OCR_REQUEST_TIMEOUT_SECONDS:-300} + TRANSLATION_REQUEST_TIMEOUT_SECONDS: ${TRANSLATION_REQUEST_TIMEOUT_SECONDS:-300} + FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS: ${FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS:-300} + APP_REQUEST_TIMEOUT_SECONDS: ${APP_REQUEST_TIMEOUT_SECONDS:-300} volumes: - - ./document_processing/ocr:/app + - .:/app + # Both /cases (sentence-transformers) and OCR (surya-ocr) pull their + # models from the HuggingFace hub on first use. Without this the + # download repeats on every container recreate, which takes minutes and + # needs network — same reasoning as ollama_models/surya_models above. + - hf_cache:/root/.cache/huggingface + # Only one of ollama/ollama-gpu and one of surya-inference/surya-inference-gpu + # exists at runtime (profile-gated), so all four are listed here as + # required: false — backend can't hard-depend on whichever specific one + # isn't active for the current profile. Without required: false (the + # default), Compose would refuse to start backend at all: it would wait + # on a service that was never created because it belongs to the profile + # you're not running (e.g. waiting on ollama-gpu while running COMPOSE_PROFILES=cpu). depends_on: + db: + condition: service_healthy + ollama: + condition: service_started + required: false + ollama-gpu: + condition: service_started + required: false surya-inference: condition: service_started required: false @@ -164,17 +198,19 @@ services: condition: service_started required: false - ocr-gpu: - <<: *ocr + backend-gpu: + <<: *backend profiles: ["gpu"] build: - context: ./document_processing/ocr + context: . args: GPU: "1" + # Same alias trick as ollama-gpu: makes backend-gpu answer to the plain + # name "backend" so nothing downstream needs to know which is active. networks: default: aliases: - - ocr + - backend deploy: resources: reservations: @@ -183,36 +219,6 @@ services: count: all capabilities: [gpu] - gateway: - build: ./gateway - command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload - ports: - - "8080:8000" - environment: - FIELD_MAPPING_BASE_URL: http://field_mapping:8002 - OCR_BASE_URL: http://ocr:8010 - TRANSLATION_BASE_URL: http://translation:8001 - # Each backend calls out to a local model and can legitimately run for - # minutes (OCR to Surya, translation/field-mapping to Ollama) — these - # match the timeouts the frontend already budgets for the same calls, - # so the gateway is never the first link in the chain to give up. - OCR_REQUEST_TIMEOUT_SECONDS: ${OCR_REQUEST_TIMEOUT_SECONDS:-300} - TRANSLATION_REQUEST_TIMEOUT_SECONDS: ${TRANSLATION_REQUEST_TIMEOUT_SECONDS:-300} - FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS: ${FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS:-300} - volumes: - - ./gateway:/app - depends_on: - field_mapping: - condition: service_started - ocr: - condition: service_started - required: false - ocr-gpu: - condition: service_started - required: false - translation: - condition: service_started - frontend: build: ./frontend ports: @@ -226,7 +232,10 @@ services: - ./frontend:/app - /app/node_modules +# Named volumes so DB data and downloaded ML model weights survive +# container recreation instead of being re-fetched/reset every time. volumes: pgdata: ollama_models: surya_models: + hf_cache: diff --git a/lending-poc/document_processing/ocr/Dockerfile b/lending-poc/document_processing/ocr/Dockerfile deleted file mode 100644 index 0c068b3..0000000 --- a/lending-poc/document_processing/ocr/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -# Surya's recognition/layout models run in-process here. The LLM half runs -# in the separate surya-inference container, reached via SURYA_INFERENCE_URL -# — this image does not need llama-server. -# -# CPU by default: pin the CPU-only torch wheel so the image doesn't pull -# CUDA deps it can't use. Paired with the "gpu" compose profile, build with -# --build-arg GPU=1 to skip the pin and let requirements.txt's surya-ocr -# pull the default (CUDA-enabled) torch wheel instead — PyTorch's GPU wheels -# bundle their own CUDA runtime libs, so no CUDA base image is needed here, -# only GPU device access from the container (which the compose profile -# grants) and a host NVIDIA driver. -ARG GPU=0 -RUN if [ "$GPU" = "0" ]; then \ - pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu; \ - fi - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8010 - -CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8010"] diff --git a/lending-poc/document_processing/translation/Dockerfile b/lending-poc/document_processing/translation/Dockerfile deleted file mode 100644 index 819520c..0000000 --- a/lending-poc/document_processing/translation/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8001 - -CMD ["uvicorn", "api_server:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/lending-poc/document_processing/translation/translation_service/adapters/ollama_adapter.py b/lending-poc/document_processing/translation/translation_service/adapters/ollama_adapter.py index eabae7a..8a37b59 100644 --- a/lending-poc/document_processing/translation/translation_service/adapters/ollama_adapter.py +++ b/lending-poc/document_processing/translation/translation_service/adapters/ollama_adapter.py @@ -60,11 +60,16 @@ def _ping(self) -> None: """ Blocking probe shared by health_check() (CLI) and the background monitor (server). Deliberately untimed — see module docstring. + Uses the same model_options as translate() (only num_predict is + overridden) so the ping never causes Ollama to reload the model with + a different context size than real translate calls use — a mismatch + that was forcing a full llama-server restart (~250s) on every ping/ + translate interleaving. """ chat( model=self.model_name, messages=[{"role": "user", "content": "ping"}], - options={"num_predict": 1}, + options={**self.model_options, "num_predict": 1}, ) def health_check(self) -> bool: diff --git a/lending-poc/field_mapping_poc/Dockerfile b/lending-poc/field_mapping_poc/Dockerfile deleted file mode 100644 index e98c885..0000000 --- a/lending-poc/field_mapping_poc/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8002 - -CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/lending-poc/gateway/Dockerfile b/lending-poc/gateway/Dockerfile deleted file mode 100644 index b3a66cf..0000000 --- a/lending-poc/gateway/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8000 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/lending-poc/gateway/main.py b/lending-poc/gateway/main.py index 671fb90..9631c3d 100644 --- a/lending-poc/gateway/main.py +++ b/lending-poc/gateway/main.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -"""Single public FastAPI entrypoint that fronts the three independent -document-processing services (OCR, translation, field-mapping). +"""Single public FastAPI entrypoint that fronts the four independent +backend services (OCR, translation, field-mapping, and the core app/cases +API). Each backend module keeps running exactly as it already does today, in its own process/venv, on its own internal port. This gateway does not import or @@ -23,15 +24,20 @@ OCR_BASE_URL = os.environ.get("OCR_BASE_URL", "http://127.0.0.1:8010") TRANSLATION_BASE_URL = os.environ.get("TRANSLATION_BASE_URL", "http://127.0.0.1:8001") FIELD_MAPPING_BASE_URL = os.environ.get("FIELD_MAPPING_BASE_URL", "http://127.0.0.1:8002") - -# Per-service request timeouts. Each backend calls out to a local model (OCR -# to Surya, translation/field-mapping to Ollama) and can legitimately run for -# minutes — these match the timeouts the frontend already budgets for the +APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:8000") + +# Per-service request timeouts. Every backend has a model somewhere in its +# path and can legitimately run for minutes — OCR calls Surya, +# translation/field-mapping call Ollama, and /cases loads a +# sentence-transformers embedding model on its first request (downloading it +# from HuggingFace if it isn't cached yet, which alone outlasts any short +# timeout). These match the timeouts the frontend already budgets for the # same calls (see frontend/src/api/{extract,translation,fieldMapping}.ts), so # the gateway is never the first link in the chain to give up. OCR_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("OCR_REQUEST_TIMEOUT_SECONDS", "300")) TRANSLATION_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("TRANSLATION_REQUEST_TIMEOUT_SECONDS", "300")) FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS", "300")) +APP_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("APP_REQUEST_TIMEOUT_SECONDS", "300")) # Headers that must not be forwarded as-is between hops (RFC 7230) plus a few # that httpx/Starlette will recompute themselves and that would otherwise @@ -88,7 +94,7 @@ async def _proxy(request: Request, base_url: str, path: str, timeout: float) -> # --- Business endpoints (unprefixed — these paths don't collide across the -# three modules, so the frontend needs no path changes beyond one base URL) --- +# four modules, so the frontend needs no path changes beyond one base URL) --- @app.post("/extract") async def extract(request: Request) -> Response: @@ -110,7 +116,12 @@ async def map_fields(request: Request) -> Response: return await _proxy(request, FIELD_MAPPING_BASE_URL, "/map", timeout=FIELD_MAPPING_REQUEST_TIMEOUT_SECONDS) -# --- Per-service health (namespaced since all three modules define /health) --- +@app.post("/cases") +async def create_case(request: Request) -> Response: + return await _proxy(request, APP_BASE_URL, "/cases", timeout=APP_REQUEST_TIMEOUT_SECONDS) + + +# --- Per-service health (namespaced since all four modules define /health) --- # Liveness probes, not business calls — kept short regardless of the # per-service request timeouts above, matching the aggregate /health below. HEALTH_PROXY_TIMEOUT_SECONDS = 5.0 @@ -131,6 +142,11 @@ async def field_mapping_health(request: Request) -> Response: return await _proxy(request, FIELD_MAPPING_BASE_URL, "/health", timeout=HEALTH_PROXY_TIMEOUT_SECONDS) +@app.get("/app/health") +async def app_health(request: Request) -> Response: + return await _proxy(request, APP_BASE_URL, "/health", timeout=HEALTH_PROXY_TIMEOUT_SECONDS) + + @app.get("/health") async def health(request: Request) -> dict: client: httpx.AsyncClient = request.app.state.http @@ -139,6 +155,7 @@ async def health(request: Request) -> dict: ("ocr", OCR_BASE_URL), ("translation", TRANSLATION_BASE_URL), ("field_mapping", FIELD_MAPPING_BASE_URL), + ("app", APP_BASE_URL), ): try: resp = await client.get(f"{base}/health", timeout=HEALTH_PROXY_TIMEOUT_SECONDS) @@ -159,9 +176,11 @@ async def root() -> dict: "POST /translate/text": "Translate text (proxies document_processing/translation)", "POST /translate/files": "Translate files (proxies document_processing/translation)", "POST /map": "Field mapping (proxies field_mapping_poc)", - "GET /health": "Aggregated health of all three backend services", + "POST /cases": "Case submission and decisioning (proxies app)", + "GET /health": "Aggregated health of all four backend services", "GET /ocr/health": "OCR service health", "GET /translation/health": "Translation service health", "GET /field-mapping/health": "Field mapping service health", + "GET /app/health": "App (cases) service health", }, } diff --git a/lending-poc/scripts/start-combined.sh b/lending-poc/scripts/start-combined.sh new file mode 100644 index 0000000..0245cee --- /dev/null +++ b/lending-poc/scripts/start-combined.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Docker entrypoint for the combined "backend" image: runs app, translation, +# field-mapping, ocr, and gateway as five separate uvicorn processes in one +# container. Mirrors scripts/start-backend.sh (the bare-metal equivalent) — +# none of the five services' own code is touched here, this just launches +# their existing entrypoints on their existing internal ports. +set -euo pipefail + +RELOAD_FLAG=() +if [ "${AUTORELOAD:-1}" = "1" ]; then + RELOAD_FLAG=(--reload) +fi + +pids=() +cleanup() { + for pid in "${pids[@]:-}"; do + kill "$pid" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT INT TERM + +(cd /app && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 "${RELOAD_FLAG[@]}") & +pids+=("$!") + +(cd /app/document_processing/translation && exec uvicorn api_server:app --host 0.0.0.0 --port 8001 "${RELOAD_FLAG[@]}") & +pids+=("$!") + +(cd /app/field_mapping_poc && exec uvicorn api:app --host 0.0.0.0 --port 8002 "${RELOAD_FLAG[@]}") & +pids+=("$!") + +(cd /app/document_processing/ocr && exec uvicorn api:app --host 0.0.0.0 --port 8010 "${RELOAD_FLAG[@]}") & +pids+=("$!") + +(cd /app/gateway && exec uvicorn main:app --host 0.0.0.0 --port 8080 "${RELOAD_FLAG[@]}") & +pids+=("$!") + +# If any one process dies, bring the whole container down (rather than +# running silently degraded) so docker-compose's restart policy kicks in. +wait -n +exit $?