From e36daad3d3bf2c80aa91c2123455db6deaaa85a4 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Mon, 24 Aug 2026 11:57:05 +0530 Subject: [PATCH 01/10] Setup locally --- lending-poc/.gitignore | 7 +- lending-poc/docker-compose.yml | 2 +- lending-poc/document_processing/ocr/README.md | 4 ++ lending-poc/document_processing/ocr/api.py | 64 ++++++++++++++---- .../ocr/extraction_input/test-image.jpg | Bin 0 -> 19332 bytes .../ocr/extractor/engines/surya_engine.py | 11 ++- .../translation/requirements.txt | 2 +- .../translation/translation_service/config.py | 3 +- lending-poc/field_mapping_poc/api.py | 4 +- lending-poc/field_mapping_poc/config.py | 2 +- lending-poc/frontend/package-lock.json | 54 --------------- lending-poc/frontend/src/api/extract.ts | 7 +- lending-poc/frontend/src/config/env.ts | 1 + lending-poc/gateway/main.py | 8 ++- lending-poc/scripts/start-backend.sh | 2 +- 15 files changed, 91 insertions(+), 80 deletions(-) create mode 100644 lending-poc/document_processing/ocr/extraction_input/test-image.jpg diff --git a/lending-poc/.gitignore b/lending-poc/.gitignore index a9f203b..e159458 100644 --- a/lending-poc/.gitignore +++ b/lending-poc/.gitignore @@ -3,10 +3,15 @@ __pycache__/ *.pyo .venv/ .env +**/.env *.egg-info/ dist/ build/ .mypy_cache/ .pytest_cache/ .ruff_cache/ -venv/ \ No newline at end of file +venv/ +**/.venv/ +.venv-windows/ +local.env +*.log \ No newline at end of file diff --git a/lending-poc/docker-compose.yml b/lending-poc/docker-compose.yml index 38239e6..9beda1a 100644 --- a/lending-poc/docker-compose.yml +++ b/lending-poc/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-lending_poc} ports: - - "${POSTGRES_HOST_PORT:-55432}:5432" + - "${POSTGRES_HOST_PORT:-55439}:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: diff --git a/lending-poc/document_processing/ocr/README.md b/lending-poc/document_processing/ocr/README.md index 210ca46..15725e6 100644 --- a/lending-poc/document_processing/ocr/README.md +++ b/lending-poc/document_processing/ocr/README.md @@ -200,6 +200,10 @@ make status # Show project status ### Prerequisites - Python 3.11+ - macOS/Linux (Surya requires local inference backend) +- A Surya inference backend: on CPU-only WSL install `llama.cpp` so that + `llama-server` is on `PATH`; on an NVIDIA WSL setup configure Surya's vLLM + backend and Docker/GPU passthrough. The API now verifies this at startup, + before reporting `/health` as healthy. ### Dependencies diff --git a/lending-poc/document_processing/ocr/api.py b/lending-poc/document_processing/ocr/api.py index bba47e4..4ea26e4 100644 --- a/lending-poc/document_processing/ocr/api.py +++ b/lending-poc/document_processing/ocr/api.py @@ -5,7 +5,7 @@ Built as a thin wrapper around the existing Extractor pipeline. Usage: - uvicorn api:app --host 0.0.0.0 --port 8000 --reload + uvicorn api:app --host 0.0.0.0 --port 8010 --reload Endpoints: POST /extract - Upload and process a document @@ -17,6 +17,7 @@ from pathlib import Path from typing import Any, Dict import os +from contextlib import asynccontextmanager from fastapi import FastAPI, File, UploadFile, HTTPException from fastapi.concurrency import run_in_threadpool @@ -25,16 +26,40 @@ from extractor import Extractor, DEFAULT_ENGINE from extractor.loader import SUPPORTED_EXTENSIONS +# Initialize extractor (reused across requests for efficiency) +extractor = Extractor(engine=DEFAULT_ENGINE) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Start Surya without making the HTTP service unavailable during warm-up.""" + app.state.ocr_ready = False + app.state.ocr_error = None + + async def warm_up() -> None: + try: + await run_in_threadpool(extractor.engine.warm_up) + except Exception as exc: + # Keep the API available for diagnostics. /extract will return a + # useful 503 instead of holding an upload open while Surya retries + # a missing/misconfigured WSL inference runtime. + app.state.ocr_error = str(exc) + else: + app.state.ocr_ready = True + + warm_up_task = asyncio.create_task(warm_up()) + yield + warm_up_task.cancel() + + # Initialize FastAPI app app = FastAPI( title="OCR Text Extraction API", description="Upload documents (PDF, PNG, JPEG) for OCR text extraction using Surya", - version="1.0.0" + version="1.0.0", + lifespan=lifespan, ) -# Initialize extractor (reused across requests for efficiency) -extractor = Extractor(engine=DEFAULT_ENGINE) - # File size limit (50MB) MAX_FILE_SIZE = 50 * 1024 * 1024 @@ -43,13 +68,18 @@ _extract_lock = asyncio.Lock() @app.get("/health") -async def health_check() -> Dict[str, str]: - """Health check endpoint to verify API is running.""" - return { - "status": "healthy", +async def health_check() -> Dict[str, Any]: + """Health check that distinguishes an online API from ready OCR inference.""" + response: Dict[str, Any] = { + "status": "healthy" if app.state.ocr_ready else "initializing", "service": "OCR Text Extraction API", - "engine": DEFAULT_ENGINE + "engine": DEFAULT_ENGINE, + "ocr_ready": app.state.ocr_ready, } + if app.state.ocr_error: + response["status"] = "unhealthy" + response["ocr_error"] = app.state.ocr_error + return response @app.post("/extract") async def extract_text(file: UploadFile = File(...)) -> Dict[str, Any]: @@ -60,6 +90,14 @@ async def extract_text(file: UploadFile = File(...)) -> Dict[str, Any]: Returns: Extracted text, HTML representation, and metadata """ + # Never accept an upload when the OCR runtime is not available. Without + # this guard Surya can block the request for its full backend timeout. + if not app.state.ocr_ready: + detail = "OCR inference is still initializing" + if app.state.ocr_error: + detail = f"OCR inference is unavailable: {app.state.ocr_error}" + raise HTTPException(status_code=503, detail=detail) + # Validate file type if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") @@ -126,7 +164,7 @@ async def extract_text(file: UploadFile = File(...)) -> Dict[str, Any]: finally: # Clean up temporary file - if temp_file and os.path.exists(temp_file_path): + if temp_file_path and os.path.exists(temp_file_path): try: os.unlink(temp_file_path) except OSError: @@ -152,6 +190,6 @@ async def root() -> Dict[str, Any]: uvicorn.run( "api:app", host="0.0.0.0", - port=8000, + port=8010, reload=True - ) \ No newline at end of file + ) diff --git a/lending-poc/document_processing/ocr/extraction_input/test-image.jpg b/lending-poc/document_processing/ocr/extraction_input/test-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..84c5ec2653e51701a84f582677a94124be19f6e1 GIT binary patch literal 19332 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 difnS27#%!*-jHGMW6B@;ejXE!5 None: def _ensure_ready(self) -> None: if self._manager is None: - self._manager = SuryaInferenceManager() # auto-spawns vllm or llama-server + self._manager = SuryaInferenceManager() + # Surya creates its inference manager lazily. Starting it here + # means an API startup check can fail fast when WSL is missing its + # backend (llama-server on CPU, or vLLM/Docker on CUDA), instead + # of making the first uploaded document appear to hang. + self._manager.start() self._recognizer = RecognitionPredictor(self._manager) + def warm_up(self) -> None: + """Start and validate Surya's inference backend without processing a file.""" + self._ensure_ready() + def run(self, images: List[Image.Image]) -> List[PageResult]: self._ensure_ready() raw_predictions = self._recognizer(images) diff --git a/lending-poc/document_processing/translation/requirements.txt b/lending-poc/document_processing/translation/requirements.txt index c7d3d78..b29dbac 100644 --- a/lending-poc/document_processing/translation/requirements.txt +++ b/lending-poc/document_processing/translation/requirements.txt @@ -1,5 +1,5 @@ # Translation service dependencies -ollama==0.4.7 +ollama==0.6.2 # FastAPI and server fastapi==0.115.6 diff --git a/lending-poc/document_processing/translation/translation_service/config.py b/lending-poc/document_processing/translation/translation_service/config.py index 79f938d..9bc7be7 100644 --- a/lending-poc/document_processing/translation/translation_service/config.py +++ b/lending-poc/document_processing/translation/translation_service/config.py @@ -8,6 +8,7 @@ Nothing outside this file needs to change for those operations. """ +import os from pathlib import Path # --------------------------------------------------------------------------- @@ -67,7 +68,7 @@ def get_kb_path(domain: str) -> Path: MODEL_ADAPTER = "ollama" # Model identifier passed to the chosen adapter. -MODEL_NAME = "gemma4:e4b" +MODEL_NAME = os.getenv("OLLAMA_MODEL", "gemma4:e4b-it-qat") # --------------------------------------------------------------------------- # Model options (adapter-specific — passed through as-is) diff --git a/lending-poc/field_mapping_poc/api.py b/lending-poc/field_mapping_poc/api.py index 29a7a01..d3e7b24 100644 --- a/lending-poc/field_mapping_poc/api.py +++ b/lending-poc/field_mapping_poc/api.py @@ -5,7 +5,7 @@ Built as a wrapper around the existing FieldMapper. Usage: - uvicorn api:app --host 0.0.0.0 --port 8000 --reload + uvicorn api:app --host 0.0.0.0 --port 8002 --reload Endpoints: POST /map - Map OCR text to the provided JSON schema @@ -109,6 +109,6 @@ async def root() -> Dict[str, Any]: uvicorn.run( "api:app", host="0.0.0.0", - port=8000, + port=8002, reload=True ) diff --git a/lending-poc/field_mapping_poc/config.py b/lending-poc/field_mapping_poc/config.py index cda02f2..7a19041 100644 --- a/lending-poc/field_mapping_poc/config.py +++ b/lending-poc/field_mapping_poc/config.py @@ -12,7 +12,7 @@ @dataclass(frozen=True) class OllamaConfig: host: str = os.getenv("OLLAMA_HOST", "http://localhost:11434") - model: str = os.getenv("OLLAMA_MODEL", "gemma4:e4b") + model: str = os.getenv("OLLAMA_MODEL", "gemma4:e4b-it-qat") temperature: float = float(os.getenv("OLLAMA_TEMPERATURE", "0.0")) num_ctx: int = int(os.getenv("OLLAMA_NUM_CTX", "8192")) request_timeout: int = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", "120")) diff --git a/lending-poc/frontend/package-lock.json b/lending-poc/frontend/package-lock.json index 57e38c8..7ebcb70 100644 --- a/lending-poc/frontend/package-lock.json +++ b/lending-poc/frontend/package-lock.json @@ -626,9 +626,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -646,9 +643,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -666,9 +660,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -686,9 +677,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +694,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -726,9 +711,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -955,9 +937,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -979,9 +958,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1003,9 +979,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1027,9 +1000,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1202,9 +1172,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1222,9 +1189,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1242,9 +1206,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1262,9 +1223,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2877,9 +2835,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2901,9 +2856,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2925,9 +2877,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2949,9 +2898,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/lending-poc/frontend/src/api/extract.ts b/lending-poc/frontend/src/api/extract.ts index 10cbcc5..f4aa2c1 100644 --- a/lending-poc/frontend/src/api/extract.ts +++ b/lending-poc/frontend/src/api/extract.ts @@ -4,9 +4,10 @@ import { extractResponseSchema, type ExtractResponse } from '@/schemas/extract.s // OCR extraction is CPU-bound and can take well over the client's default // 30s timeout, especially for multi-page documents or several concurrent // uploads (the backend also serializes concurrent extractions, so later -// documents in a batch wait on earlier ones). 5 minutes gives real -// documents room to finish instead of erroring out mid-processing. -const EXTRACT_TIMEOUT_MS = 5 * 60 * 1000 +// documents in a batch wait on earlier ones). CPU-only Surya can take +// several minutes per handwritten page, so this must stay aligned with the +// gateway's OCR_REQUEST_TIMEOUT_SECONDS default. +const EXTRACT_TIMEOUT_MS = 2 * 60 * 1000 /** * POSTs a single file to /extract. The backend processes one file per call, diff --git a/lending-poc/frontend/src/config/env.ts b/lending-poc/frontend/src/config/env.ts index 322b5c3..19ad126 100644 --- a/lending-poc/frontend/src/config/env.ts +++ b/lending-poc/frontend/src/config/env.ts @@ -1,5 +1,6 @@ import { z } from 'zod' + const envSchema = z.object({ VITE_API_BASE_URL: z.string().url().or(z.string().startsWith('/')), VITE_TRANSLATION_API_BASE_URL: z.string().url().or(z.string().startsWith('/')), diff --git a/lending-poc/gateway/main.py b/lending-poc/gateway/main.py index 6e7b551..4a5755d 100644 --- a/lending-poc/gateway/main.py +++ b/lending-poc/gateway/main.py @@ -23,6 +23,10 @@ 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") +# Surya on a CPU-only WSL host can take several minutes per handwritten page. +# Keep this aligned with the browser timeout so the gateway does not terminate +# a valid OCR request while the OCR worker is still generating text. +OCR_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("OCR_REQUEST_TIMEOUT_SECONDS", "1800")) # 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 @@ -37,7 +41,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): - app.state.http = httpx.AsyncClient(timeout=120.0) + app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) yield await app.state.http.aclose() @@ -58,12 +62,14 @@ async def _proxy(request: Request, base_url: str, path: str) -> Response: headers = {k: v for k, v in request.headers.items() if k.lower() not in REQUEST_STRIP_HEADERS} body = await request.body() try: + timeout = OCR_REQUEST_TIMEOUT_SECONDS if base_url == OCR_BASE_URL else None upstream = await client.request( request.method, f"{base_url}{path}", headers=headers, params=list(request.query_params.multi_items()), content=body, + timeout=timeout, ) except httpx.RequestError as exc: return JSONResponse( diff --git a/lending-poc/scripts/start-backend.sh b/lending-poc/scripts/start-backend.sh index 6b33855..645b480 100755 --- a/lending-poc/scripts/start-backend.sh +++ b/lending-poc/scripts/start-backend.sh @@ -10,7 +10,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PYTHON_BIN="python3.12" -OLLAMA_MODEL="${OLLAMA_MODEL:-gemma4:e4b}" +OLLAMA_MODEL="${OLLAMA_MODEL:-gemma4:e4b-it-qat}" GATEWAY_PORT=8000 OCR_PORT=8010 From f12b726a554effcd20e65ae70a844af8f0a69cb0 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Tue, 25 Aug 2026 10:47:59 +0530 Subject: [PATCH 02/10] Dockerized the application --- lending-poc/.dockerignore | 14 +++ lending-poc/app/config.py | 2 +- lending-poc/docker-compose.yml | 87 +++++++++++++++++++ .../document_processing/ocr/.dockerignore | 10 +++ .../document_processing/ocr/Dockerfile | 17 ++++ .../translation/.dockerignore | 7 ++ .../translation/Dockerfile | 12 +++ lending-poc/field_mapping_poc/.dockerignore | 6 ++ lending-poc/field_mapping_poc/Dockerfile | 12 +++ lending-poc/frontend/.dockerignore | 6 ++ lending-poc/frontend/Dockerfile | 12 +++ lending-poc/gateway/.dockerignore | 5 ++ lending-poc/gateway/Dockerfile | 12 +++ lending-poc/surya-inference/Dockerfile | 43 +++++++++ lending-poc/surya-inference/entrypoint.sh | 31 +++++++ 15 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 lending-poc/.dockerignore create mode 100644 lending-poc/document_processing/ocr/.dockerignore create mode 100644 lending-poc/document_processing/ocr/Dockerfile create mode 100644 lending-poc/document_processing/translation/.dockerignore create mode 100644 lending-poc/document_processing/translation/Dockerfile create mode 100644 lending-poc/field_mapping_poc/.dockerignore create mode 100644 lending-poc/field_mapping_poc/Dockerfile create mode 100644 lending-poc/frontend/.dockerignore create mode 100644 lending-poc/frontend/Dockerfile create mode 100644 lending-poc/gateway/.dockerignore create mode 100644 lending-poc/gateway/Dockerfile create mode 100644 lending-poc/surya-inference/Dockerfile create mode 100644 lending-poc/surya-inference/entrypoint.sh diff --git a/lending-poc/.dockerignore b/lending-poc/.dockerignore new file mode 100644 index 0000000..02dfa58 --- /dev/null +++ b/lending-poc/.dockerignore @@ -0,0 +1,14 @@ +.git +.venv +**/.venv +**/.venv-windows +**/surya-env +**/node_modules +**/__pycache__ +document_processing +field_mapping_poc +gateway +frontend +scripts +docs +*.log diff --git a/lending-poc/app/config.py b/lending-poc/app/config.py index 90c1779..ce7fab6 100644 --- a/lending-poc/app/config.py +++ b/lending-poc/app/config.py @@ -4,7 +4,7 @@ class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") APP_NAME: str = "lending-poc" APP_VERSION: str = "0.1.0" diff --git a/lending-poc/docker-compose.yml b/lending-poc/docker-compose.yml index 9beda1a..84e31c2 100644 --- a/lending-poc/docker-compose.yml +++ b/lending-poc/docker-compose.yml @@ -32,5 +32,92 @@ services: db: condition: service_healthy + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + volumes: + - ollama_models:/root/.ollama + + 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} + volumes: + - ./field_mapping_poc:/app + depends_on: + - ollama + + 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 + + surya-inference: + build: ./surya-inference + ports: + - "8500:8000" + environment: + SURYA_INFERENCE_PARALLEL: ${SURYA_INFERENCE_PARALLEL:-4} + SURYA_INFERENCE_CTX_SIZE: ${SURYA_INFERENCE_CTX_SIZE:-49152} + volumes: + - surya_models:/models + + ocr: + build: ./document_processing/ocr + command: uvicorn api:app --host 0.0.0.0 --port 8010 --reload + ports: + - "8010:8010" + environment: + SURYA_INFERENCE_URL: http://surya-inference:8000/v1 + SURYA_INFERENCE_AUTOSTART: "false" + volumes: + - ./document_processing/ocr:/app + depends_on: + - surya-inference + + 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 + volumes: + - ./gateway:/app + depends_on: + - field_mapping + - ocr + - translation + + frontend: + build: ./frontend + ports: + - "5173:5173" + environment: + # Browser-facing: must be host-reachable (localhost + published port), + # not a Docker-internal service name. + VITE_API_BASE_URL: http://localhost:8080 + VITE_TRANSLATION_API_BASE_URL: http://localhost:8080 + volumes: + - ./frontend:/app + - /app/node_modules + volumes: pgdata: + ollama_models: + surya_models: diff --git a/lending-poc/document_processing/ocr/.dockerignore b/lending-poc/document_processing/ocr/.dockerignore new file mode 100644 index 0000000..3071a79 --- /dev/null +++ b/lending-poc/document_processing/ocr/.dockerignore @@ -0,0 +1,10 @@ +__pycache__ +*.pyc +.venv +.venv-windows +surya-env +.env +.git +extraction_output +*.stdout.log +*.stderr.log diff --git a/lending-poc/document_processing/ocr/Dockerfile b/lending-poc/document_processing/ocr/Dockerfile new file mode 100644 index 0000000..7f63ad4 --- /dev/null +++ b/lending-poc/document_processing/ocr/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Surya's recognition/layout models run in-process here (CPU). The LLM half +# runs in the separate surya-inference container, reached via +# SURYA_INFERENCE_URL — this image does not need llama-server. +RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu + +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/.dockerignore b/lending-poc/document_processing/translation/.dockerignore new file mode 100644 index 0000000..677b168 --- /dev/null +++ b/lending-poc/document_processing/translation/.dockerignore @@ -0,0 +1,7 @@ +__pycache__ +*.pyc +.venv +.venv-windows +.env +.git +output diff --git a/lending-poc/document_processing/translation/Dockerfile b/lending-poc/document_processing/translation/Dockerfile new file mode 100644 index 0000000..819520c --- /dev/null +++ b/lending-poc/document_processing/translation/Dockerfile @@ -0,0 +1,12 @@ +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/field_mapping_poc/.dockerignore b/lending-poc/field_mapping_poc/.dockerignore new file mode 100644 index 0000000..d372f23 --- /dev/null +++ b/lending-poc/field_mapping_poc/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +.venv +.env +samples +.git diff --git a/lending-poc/field_mapping_poc/Dockerfile b/lending-poc/field_mapping_poc/Dockerfile new file mode 100644 index 0000000..e98c885 --- /dev/null +++ b/lending-poc/field_mapping_poc/Dockerfile @@ -0,0 +1,12 @@ +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/frontend/.dockerignore b/lending-poc/frontend/.dockerignore new file mode 100644 index 0000000..c071f14 --- /dev/null +++ b/lending-poc/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.local +*.log +.git diff --git a/lending-poc/frontend/Dockerfile b/lending-poc/frontend/Dockerfile new file mode 100644 index 0000000..0163475 --- /dev/null +++ b/lending-poc/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/lending-poc/gateway/.dockerignore b/lending-poc/gateway/.dockerignore new file mode 100644 index 0000000..2c46cf5 --- /dev/null +++ b/lending-poc/gateway/.dockerignore @@ -0,0 +1,5 @@ +__pycache__ +*.pyc +.venv +.env +.git diff --git a/lending-poc/gateway/Dockerfile b/lending-poc/gateway/Dockerfile new file mode 100644 index 0000000..b3a66cf --- /dev/null +++ b/lending-poc/gateway/Dockerfile @@ -0,0 +1,12 @@ +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/surya-inference/Dockerfile b/lending-poc/surya-inference/Dockerfile new file mode 100644 index 0000000..94ef34b --- /dev/null +++ b/lending-poc/surya-inference/Dockerfile @@ -0,0 +1,43 @@ +# Builds llama.cpp's CPU `llama-server` from source and serves it standalone, +# so ocr-api can point SURYA_INFERENCE_URL here instead of spawning its own +# copy in-process (see surya/inference/backends/llamacpp.py in the ocr-api +# image for the equivalent in-process spawn logic this mirrors). +FROM python:3.12-slim AS build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp /llama.cpp +WORKDIR /llama.cpp +RUN cmake -B build -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build -j"$(nproc)" --target llama-server + +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# llama-server is dynamically linked against the other .so files built +# alongside it (libllama-server-impl.so, libllama-common.so, libmtmd.so, ...), +# so the whole bin/ directory needs to come along, not just the executable. +COPY --from=build /llama.cpp/build/bin /opt/llama.cpp/bin +ENV LD_LIBRARY_PATH=/opt/llama.cpp/bin +RUN ln -s /opt/llama.cpp/bin/llama-server /usr/local/bin/llama-server + +ENV SURYA_GGUF_REPO=datalab-to/surya-ocr-2-gguf \ + SURYA_GGUF_MODEL_FILE=surya-2.gguf \ + SURYA_GGUF_MMPROJ_FILE=surya-2-mmproj.gguf \ + SURYA_MODEL_ALIAS=datalab-to/surya-ocr-2 \ + MODEL_DIR=/models \ + PORT=8000 + +RUN mkdir -p /models + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/lending-poc/surya-inference/entrypoint.sh b/lending-poc/surya-inference/entrypoint.sh new file mode 100644 index 0000000..7d640bd --- /dev/null +++ b/lending-poc/surya-inference/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Downloads the same GGUF files Surya's own llamacpp backend fetches when it +# spawns its own server (surya/inference/backends/llamacpp.py: +# SURYA_GGUF_REPO / SURYA_GGUF_MODEL_FILE / SURYA_GGUF_MMPROJ_FILE), then +# starts llama-server with the equivalent flags so ocr-api can attach to it +# via SURYA_INFERENCE_URL instead of spawning its own. +set -e + +MODEL_PATH="${MODEL_DIR}/${SURYA_GGUF_MODEL_FILE}" +MMPROJ_PATH="${MODEL_DIR}/${SURYA_GGUF_MMPROJ_FILE}" + +if [ ! -f "$MODEL_PATH" ]; then + echo "Downloading ${SURYA_GGUF_MODEL_FILE} from ${SURYA_GGUF_REPO}..." + curl -fL -o "$MODEL_PATH" "https://huggingface.co/${SURYA_GGUF_REPO}/resolve/main/${SURYA_GGUF_MODEL_FILE}" +fi + +if [ ! -f "$MMPROJ_PATH" ]; then + echo "Downloading ${SURYA_GGUF_MMPROJ_FILE} from ${SURYA_GGUF_REPO}..." + curl -fL -o "$MMPROJ_PATH" "https://huggingface.co/${SURYA_GGUF_REPO}/resolve/main/${SURYA_GGUF_MMPROJ_FILE}" +fi + +exec llama-server \ + -m "$MODEL_PATH" \ + --mmproj "$MMPROJ_PATH" \ + -ngl 0 \ + --host 0.0.0.0 \ + --port "${PORT:-8000}" \ + --parallel "${SURYA_INFERENCE_PARALLEL:-4}" \ + --ctx-size "${SURYA_INFERENCE_CTX_SIZE:-49152}" \ + --alias "$SURYA_MODEL_ALIAS" \ + --jinja From d293b13c0a1365ab350b02a042daf1012dbfea1d Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Tue, 25 Aug 2026 13:22:56 +0530 Subject: [PATCH 03/10] fix: keep Ollama model resident to prevent /map timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama unloaded gemma4:e4b-it-qat after 5m idle, and reloading it took ~140-150s — longer than field_mapping's 120s request timeout, so every call after a gap timed out on all retries. Set OLLAMA_KEEP_ALIVE=-1 to keep the model loaded, and raise field_mapping's timeout to 300s to cover the first cold load. Co-Authored-By: Claude Sonnet 5 --- lending-poc/docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lending-poc/docker-compose.yml b/lending-poc/docker-compose.yml index 84e31c2..4924b92 100644 --- a/lending-poc/docker-compose.yml +++ b/lending-poc/docker-compose.yml @@ -36,6 +36,12 @@ services: image: ollama/ollama:latest ports: - "11434:11434" + environment: + # Keep the model resident once loaded instead of unloading after the + # default 5m idle timeout — cold-loading this model takes 2+ minutes, + # which otherwise blows past callers' request timeouts on every call + # after a short idle gap. + OLLAMA_KEEP_ALIVE: -1 volumes: - ollama_models:/root/.ollama @@ -47,6 +53,9 @@ services: 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: From 83c04ade622050b3b6f4cbaea06422df9ddba076 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Tue, 25 Aug 2026 13:30:20 +0530 Subject: [PATCH 04/10] Undo some changes --- .../document_processing/translation/requirements.txt | 2 +- lending-poc/frontend/src/api/extract.ts | 2 +- lending-poc/gateway/main.py | 8 +------- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/lending-poc/document_processing/translation/requirements.txt b/lending-poc/document_processing/translation/requirements.txt index b29dbac..c7d3d78 100644 --- a/lending-poc/document_processing/translation/requirements.txt +++ b/lending-poc/document_processing/translation/requirements.txt @@ -1,5 +1,5 @@ # Translation service dependencies -ollama==0.6.2 +ollama==0.4.7 # FastAPI and server fastapi==0.115.6 diff --git a/lending-poc/frontend/src/api/extract.ts b/lending-poc/frontend/src/api/extract.ts index f4aa2c1..4c787cb 100644 --- a/lending-poc/frontend/src/api/extract.ts +++ b/lending-poc/frontend/src/api/extract.ts @@ -7,7 +7,7 @@ import { extractResponseSchema, type ExtractResponse } from '@/schemas/extract.s // documents in a batch wait on earlier ones). CPU-only Surya can take // several minutes per handwritten page, so this must stay aligned with the // gateway's OCR_REQUEST_TIMEOUT_SECONDS default. -const EXTRACT_TIMEOUT_MS = 2 * 60 * 1000 +const EXTRACT_TIMEOUT_MS = 5 * 60 * 1000 /** * POSTs a single file to /extract. The backend processes one file per call, diff --git a/lending-poc/gateway/main.py b/lending-poc/gateway/main.py index 4a5755d..6e7b551 100644 --- a/lending-poc/gateway/main.py +++ b/lending-poc/gateway/main.py @@ -23,10 +23,6 @@ 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") -# Surya on a CPU-only WSL host can take several minutes per handwritten page. -# Keep this aligned with the browser timeout so the gateway does not terminate -# a valid OCR request while the OCR worker is still generating text. -OCR_REQUEST_TIMEOUT_SECONDS = float(os.environ.get("OCR_REQUEST_TIMEOUT_SECONDS", "1800")) # 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 @@ -41,7 +37,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): - app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) + app.state.http = httpx.AsyncClient(timeout=120.0) yield await app.state.http.aclose() @@ -62,14 +58,12 @@ async def _proxy(request: Request, base_url: str, path: str) -> Response: headers = {k: v for k, v in request.headers.items() if k.lower() not in REQUEST_STRIP_HEADERS} body = await request.body() try: - timeout = OCR_REQUEST_TIMEOUT_SECONDS if base_url == OCR_BASE_URL else None upstream = await client.request( request.method, f"{base_url}{path}", headers=headers, params=list(request.query_params.multi_items()), content=body, - timeout=timeout, ) except httpx.RequestError as exc: return JSONResponse( From baa515edd76ea8911acee20a3346db8fc3fb7289 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Tue, 25 Aug 2026 16:46:12 +0530 Subject: [PATCH 05/10] feat: make GPU acceleration opt-in for Surya OCR and Ollama via compose profiles Adds a "gpu" compose profile (alongside the default "cpu" one, toggled via COMPOSE_PROFILES in .env) that requests GPU device access for surya-inference, ocr, and ollama. Each pair of variants shares a network alias so downstream services never need to know which is active. - surya-inference: optional CUDA build of llama.cpp (build args), with entrypoint.sh probing nvidia-smi at startup to pick -ngl and falling back to CPU even if GPU access was requested but isn't actually there. - ocr: optional CUDA torch wheel (build arg) instead of the pinned CPU one. - ollama: GPU device reservation only; the official image already auto-detects CUDA and falls back to CPU on its own. - Fixes entrypoint.sh CRLF line endings (broke its shebang when checked out on Windows) and adds .gitattributes to keep shell scripts LF going forward. --- .gitattributes | 1 + lending-poc/docker-compose.yml | 105 +++++++++++++++++- .../document_processing/ocr/Dockerfile | 19 +++- lending-poc/document_processing/ocr/README.md | 18 +++ lending-poc/surya-inference/Dockerfile | 27 +++-- lending-poc/surya-inference/entrypoint.sh | 14 ++- 6 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/lending-poc/docker-compose.yml b/lending-poc/docker-compose.yml index 4924b92..965aaea 100644 --- a/lending-poc/docker-compose.yml +++ b/lending-poc/docker-compose.yml @@ -32,8 +32,14 @@ services: 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 + # requested when COMPOSE_PROFILES=gpu. ollama: + &ollama image: ollama/ollama:latest + profiles: ["cpu"] ports: - "11434:11434" environment: @@ -45,6 +51,21 @@ services: volumes: - ollama_models:/root/.ollama + ollama-gpu: + <<: *ollama + profiles: ["gpu"] + networks: + default: + aliases: + - ollama + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + field_mapping: build: ./field_mapping_poc command: uvicorn api:app --host 0.0.0.0 --port 8002 --reload @@ -59,7 +80,12 @@ services: volumes: - ./field_mapping_poc:/app depends_on: - - ollama + ollama: + condition: service_started + required: false + ollama-gpu: + condition: service_started + required: false translation: build: ./document_processing/translation @@ -72,10 +98,23 @@ services: volumes: - ./document_processing/translation:/app depends_on: - - ollama + 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 + # downstream services (SURYA_INFERENCE_URL, OCR_BASE_URL) don't need to + # know which one is active. See document_processing/ocr/README.md for GPU + # prerequisites (NVIDIA Container Toolkit / WSL GPU passthrough). surya-inference: + &surya-inference build: ./surya-inference + profiles: ["cpu"] ports: - "8500:8000" environment: @@ -84,8 +123,31 @@ services: volumes: - surya_models:/models + surya-inference-gpu: + <<: *surya-inference + profiles: ["gpu"] + build: + context: ./surya-inference + args: + BASE_IMAGE: nvidia/cuda:12.4.1-devel-ubuntu22.04 + RUNTIME_IMAGE: nvidia/cuda:12.4.1-runtime-ubuntu22.04 + GGML_CUDA: "ON" + networks: + default: + aliases: + - surya-inference + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + ocr: + &ocr build: ./document_processing/ocr + profiles: ["cpu"] command: uvicorn api:app --host 0.0.0.0 --port 8010 --reload ports: - "8010:8010" @@ -95,7 +157,31 @@ services: volumes: - ./document_processing/ocr:/app depends_on: - - surya-inference + surya-inference: + condition: service_started + required: false + surya-inference-gpu: + condition: service_started + required: false + + ocr-gpu: + <<: *ocr + profiles: ["gpu"] + build: + context: ./document_processing/ocr + args: + GPU: "1" + networks: + default: + aliases: + - ocr + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] gateway: build: ./gateway @@ -109,9 +195,16 @@ services: volumes: - ./gateway:/app depends_on: - - field_mapping - - ocr - - translation + 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 diff --git a/lending-poc/document_processing/ocr/Dockerfile b/lending-poc/document_processing/ocr/Dockerfile index 7f63ad4..b4a2da8 100644 --- a/lending-poc/document_processing/ocr/Dockerfile +++ b/lending-poc/document_processing/ocr/Dockerfile @@ -2,10 +2,21 @@ FROM python:3.12-slim WORKDIR /app -# Surya's recognition/layout models run in-process here (CPU). The LLM half -# runs in the separate surya-inference container, reached via -# SURYA_INFERENCE_URL — this image does not need llama-server. -RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu +# 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 --index-url https://download.pytorch.org/whl/cpu; \ + fi COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/lending-poc/document_processing/ocr/README.md b/lending-poc/document_processing/ocr/README.md index 15725e6..c0a24b5 100644 --- a/lending-poc/document_processing/ocr/README.md +++ b/lending-poc/document_processing/ocr/README.md @@ -205,6 +205,24 @@ make status # Show project status backend and Docker/GPU passthrough. The API now verifies this at startup, before reporting `/health` as healthy. +### Running via Docker Compose (GPU or CPU) + +The top-level `docker-compose.yml` runs this service (and its `surya-inference` +backend) in a CPU or GPU variant, picked 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 + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) + (and, on Windows, WSL2 GPU passthrough). Both containers detect GPU access + at startup and fall back to CPU automatically if it isn't actually there, + so setting the profile without a working toolkit degrades to CPU rather + than failing outright — but `docker compose up` itself will fail to create + the containers if the toolkit isn't installed at all, since the GPU device + reservation can't be satisfied. + +Switch profiles by editing `COMPOSE_PROFILES` in `lending-poc/.env`, then +`docker compose up --build`. + ### Dependencies The project includes both core OCR dependencies and API dependencies: diff --git a/lending-poc/surya-inference/Dockerfile b/lending-poc/surya-inference/Dockerfile index 94ef34b..100edbe 100644 --- a/lending-poc/surya-inference/Dockerfile +++ b/lending-poc/surya-inference/Dockerfile @@ -1,8 +1,21 @@ -# Builds llama.cpp's CPU `llama-server` from source and serves it standalone, -# so ocr-api can point SURYA_INFERENCE_URL here instead of spawning its own -# copy in-process (see surya/inference/backends/llamacpp.py in the ocr-api -# image for the equivalent in-process spawn logic this mirrors). -FROM python:3.12-slim AS build +# Builds llama.cpp's `llama-server` from source and serves it standalone, so +# ocr-api can point SURYA_INFERENCE_URL here instead of spawning its own copy +# in-process (see surya/inference/backends/llamacpp.py in the ocr-api image +# for the equivalent in-process spawn logic this mirrors). +# +# CPU by default. For a CUDA-capable build (paired with the "gpu" compose +# profile), pass: +# --build-arg BASE_IMAGE=nvidia/cuda:12.4.1-devel-ubuntu22.04 +# --build-arg RUNTIME_IMAGE=nvidia/cuda:12.4.1-runtime-ubuntu22.04 +# --build-arg GGML_CUDA=ON +# entrypoint.sh still probes for a visible GPU at container start and falls +# back to -ngl 0 (CPU) if none is found, so this image works either way. +ARG BASE_IMAGE=python:3.12-slim +ARG RUNTIME_IMAGE=python:3.12-slim + +FROM ${BASE_IMAGE} AS build + +ARG GGML_CUDA=OFF RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential cmake git ca-certificates \ @@ -10,10 +23,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN git clone --depth 1 https://github.com/ggml-org/llama.cpp /llama.cpp WORKDIR /llama.cpp -RUN cmake -B build -DGGML_NATIVE=OFF -DLLAMA_CURL=OFF -DCMAKE_BUILD_TYPE=Release \ +RUN cmake -B build -DGGML_NATIVE=OFF -DGGML_CUDA=${GGML_CUDA} -DLLAMA_CURL=OFF -DCMAKE_BUILD_TYPE=Release \ && cmake --build build -j"$(nproc)" --target llama-server -FROM python:3.12-slim +FROM ${RUNTIME_IMAGE} RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates libgomp1 \ diff --git a/lending-poc/surya-inference/entrypoint.sh b/lending-poc/surya-inference/entrypoint.sh index 7d640bd..84a64c1 100644 --- a/lending-poc/surya-inference/entrypoint.sh +++ b/lending-poc/surya-inference/entrypoint.sh @@ -19,10 +19,22 @@ if [ ! -f "$MMPROJ_PATH" ]; then curl -fL -o "$MMPROJ_PATH" "https://huggingface.co/${SURYA_GGUF_REPO}/resolve/main/${SURYA_GGUF_MMPROJ_FILE}" fi +# nvidia-smi only shows up here if the container was actually started with +# GPU access (via the "gpu" compose profile + NVIDIA Container Toolkit) - +# so this doubles as the CPU/GPU decision regardless of how the image was +# built, and fails safe to CPU if GPU access was requested but isn't there. +if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + echo "GPU detected - offloading layers to GPU" + NGL="${SURYA_INFERENCE_NGL:-999}" +else + echo "No GPU detected - running on CPU" + NGL=0 +fi + exec llama-server \ -m "$MODEL_PATH" \ --mmproj "$MMPROJ_PATH" \ - -ngl 0 \ + -ngl "$NGL" \ --host 0.0.0.0 \ --port "${PORT:-8000}" \ --parallel "${SURYA_INFERENCE_PARALLEL:-4}" \ From 007e5a86f133e1caf7a589b14c74f095e03dedb9 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Tue, 25 Aug 2026 16:47:36 +0530 Subject: [PATCH 06/10] Added .env.example file --- lending-poc/.env.example | 12 ++++++++++++ lending-poc/Database_setup.md | 4 ++-- lending-poc/field_mapping_poc/.env.example | 3 +++ lending-poc/frontend/.env.example | 2 ++ 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 lending-poc/.env.example create mode 100644 lending-poc/field_mapping_poc/.env.example create mode 100644 lending-poc/frontend/.env.example diff --git a/lending-poc/.env.example b/lending-poc/.env.example new file mode 100644 index 0000000..8c90e26 --- /dev/null +++ b/lending-poc/.env.example @@ -0,0 +1,12 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=lending_poc +POSTGRES_HOST_PORT=55439 +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:55439/lending_poc +ENCRYPTION_KEY=changeme-generate-a-base64-fernet-key +DEBUG=true + +# Selects the surya-inference/ocr variant docker-compose.yml runs: "cpu" +# (default, always works) or "gpu" (requires an NVIDIA GPU + NVIDIA +# Container Toolkit / WSL GPU passthrough on the host). +COMPOSE_PROFILES=cpu diff --git a/lending-poc/Database_setup.md b/lending-poc/Database_setup.md index 35d443e..d8304a3 100644 --- a/lending-poc/Database_setup.md +++ b/lending-poc/Database_setup.md @@ -20,7 +20,7 @@ pip install -e ".[dev]" Create a `.env` file in the project root: ```bash -DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:55432/lending_poc +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:55439/lending_poc ENCRYPTION_KEY=<32-byte base64 key> DEBUG=true ``` @@ -39,7 +39,7 @@ python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())" docker compose up -d db ``` -This starts Postgres with pgvector on host port `55432` (mapped from container port `5432`), and waits until it reports healthy. +This starts Postgres with pgvector on host port `55439` (mapped from container port `5432`), and waits until it reports healthy. ## 4. Run database migrations diff --git a/lending-poc/field_mapping_poc/.env.example b/lending-poc/field_mapping_poc/.env.example new file mode 100644 index 0000000..12ae354 --- /dev/null +++ b/lending-poc/field_mapping_poc/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:55432/lending_poc +ENCRYPTION_KEY=changeme-generate-a-base64-fernet-key +DEBUG=true diff --git a/lending-poc/frontend/.env.example b/lending-poc/frontend/.env.example new file mode 100644 index 0000000..fe75629 --- /dev/null +++ b/lending-poc/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_BASE_URL=http://localhost:8000 +VITE_TRANSLATION_API_BASE_URL=http://localhost:8000 From e948b5b77f1003a7fbfd9109704b37fbf1053c5e Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Wed, 26 Aug 2026 13:19:40 +0530 Subject: [PATCH 07/10] fix: pin torchvision to CPU wheel in OCR Dockerfile torch was already pinned to the CPU-only index, but torchvision was left to resolve from default PyPI via surya-ocr's dependency, pulling in a CUDA-linked build. Mismatched torch/torchvision builds can break torchvision's compiled ops at runtime. Co-Authored-By: Claude Sonnet 5 --- lending-poc/document_processing/ocr/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lending-poc/document_processing/ocr/Dockerfile b/lending-poc/document_processing/ocr/Dockerfile index b4a2da8..0c068b3 100644 --- a/lending-poc/document_processing/ocr/Dockerfile +++ b/lending-poc/document_processing/ocr/Dockerfile @@ -15,7 +15,7 @@ WORKDIR /app # grants) and a host NVIDIA driver. ARG GPU=0 RUN if [ "$GPU" = "0" ]; then \ - pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu; \ + pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu; \ fi COPY requirements.txt . From bee85e01d4c60d8fe9d39e41ac9afae627ce2f05 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Wed, 26 Aug 2026 15:27:00 +0530 Subject: [PATCH 08/10] docs: add top-level README for running the full stack with Docker Consolidates setup instructions (env config, migrations, Ollama model pull, service ports, and GPU-profile usage) that were previously scattered across Database_setup.md and the OCR README, into one guide for a fresh clone. Co-Authored-By: Claude Sonnet 5 --- lending-poc/README.md | 140 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 lending-poc/README.md diff --git a/lending-poc/README.md b/lending-poc/README.md new file mode 100644 index 0000000..88e8ac8 --- /dev/null +++ b/lending-poc/README.md @@ -0,0 +1,140 @@ +# Lending POC + +Lending POC — FastAPI backend + PostgreSQL (pgvector), plus a document +processing pipeline (OCR, translation, field mapping) fronted by a gateway, +and a React frontend. This guide covers running the **entire stack in +Docker**. + +For running the `app` service natively against a containerized DB only +(e.g. for backend development with hot-reload outside Docker), see +[Database_setup.md](Database_setup.md). + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose v2 + (`docker compose version`) +- Git +- **Optional, for GPU acceleration**: an NVIDIA GPU, the + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html), + and (on Windows) WSL2 with GPU passthrough enabled + +## 1. Clone and configure environment + +```bash +git clone +cd lending-poc +cp .env.example .env +``` + +Generate a real `ENCRYPTION_KEY` — the app uses it to encrypt sensitive +database fields, and the placeholder value in `.env.example` is not a valid +key: + +```bash +python3 -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())" +``` + +Paste the result into `ENCRYPTION_KEY=` in `.env`. + +Leave `COMPOSE_PROFILES=cpu` as-is unless you have a working GPU setup — +see [Using a GPU](#using-a-gpu) below. + +## 2. Start the stack + +```bash +docker compose up --build -d +``` + +This builds and starts every service: `db`, `app`, `ollama`, `field_mapping`, +`translation`, `surya-inference` + `ocr`, `gateway`, and `frontend`. + +The first run takes a while — Ollama and Surya both download models on +first use. Watch progress with: + +```bash +docker compose logs -f +``` + +## 3. Run database migrations + +The `app` container doesn't run migrations automatically on startup: + +```bash +docker compose exec app alembic -c db/alembic.ini upgrade head +``` + +## 4. Pull the Ollama model + +Needed by the `translation` and `field_mapping` services: + +```bash +docker compose exec ollama ollama pull gemma4:e4b-it-qat +``` + +(Substitute whatever `OLLAMA_MODEL` is set to in `.env` if you changed it. +If you're running the GPU profile, use `ollama-gpu` instead of `ollama` in +the command above.) + +## 5. Verify it's running + +| 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` | +| 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 | +| 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. + +## Using a GPU + +`surya-inference`/`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 + NVIDIA Container Toolkit (and, on Windows, WSL2 GPU passthrough). + +To switch: + +```bash +# in .env +COMPOSE_PROFILES=gpu +``` + +```bash +docker compose up --build -d +``` + +Both GPU containers detect GPU access at startup and fall back to CPU +automatically if it isn't actually usable — but `docker compose up` will +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 +[document_processing/ocr/README.md](document_processing/ocr/README.md) +for details). + +## Stopping and cleanup + +```bash +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: + +```bash +docker compose down -v +``` \ No newline at end of file From a4e03c0e2268d6ccc42788a61582af898dec4de8 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Wed, 26 Aug 2026 15:28:20 +0530 Subject: [PATCH 09/10] docs: document OLLAMA_MODEL/OLLAMA_HOST in .env.example Surfaces the Ollama model config used by translation and field_mapping, with a note on pulling the model into the container before first use. Co-Authored-By: Claude Sonnet 5 --- lending-poc/.env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lending-poc/.env.example b/lending-poc/.env.example index 8c90e26..5419649 100644 --- a/lending-poc/.env.example +++ b/lending-poc/.env.example @@ -10,3 +10,9 @@ DEBUG=true # (default, always works) or "gpu" (requires an NVIDIA GPU + NVIDIA # Container Toolkit / WSL GPU passthrough on the host). COMPOSE_PROFILES=cpu + +# Model used by the translation and field_mapping services via Ollama. +# Must be pulled into the ollama container first: +# docker compose exec ollama ollama pull +OLLAMA_MODEL=gemma4:e4b-it-qat +# OLLAMA_HOST=http://ollama:11434 From 18ba5d77dfcbed056bca60e37237523076055034 Mon Sep 17 00:00:00 2001 From: Ayan-josh-05 Date: Wed, 2 Sep 2026 14:15:29 +0530 Subject: [PATCH 10/10] Potential fix for pull request finding cancel() + await keeps the asyncio side tidy by ensuring there is no unfinished task left when the event loop closes. It also makes shutdown deterministic from the async side and keeps the implementation consistent with stop_monitoring(). Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lending-poc/document_processing/ocr/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lending-poc/document_processing/ocr/api.py b/lending-poc/document_processing/ocr/api.py index 4ea26e4..06c4f3c 100644 --- a/lending-poc/document_processing/ocr/api.py +++ b/lending-poc/document_processing/ocr/api.py @@ -48,8 +48,14 @@ async def warm_up() -> None: app.state.ocr_ready = True warm_up_task = asyncio.create_task(warm_up()) - yield - warm_up_task.cancel() + try: + yield + finally: + warm_up_task.cancel() + try: + await warm_up_task + except asyncio.CancelledError: + pass # Initialize FastAPI app