Abid Ali Awan Codex commited on
Commit
8ccb3e9
·
1 Parent(s): edecb11

Add local CUDA deployment and reject non-notice images

Browse files

Add a Docker Compose Transformers runtime for local NVIDIA GPUs and use Nemotron semantic classes to reject picture-only uploads before MiniCPM generation.

Co-authored-by: Codex <codex@openai.com>

Files changed (10) hide show
  1. .dockerignore +13 -0
  2. Dockerfile +26 -0
  3. README.md +53 -0
  4. app/cli.py +1 -1
  5. app/config.py +20 -3
  6. app/model_endpoint.py +45 -17
  7. app/ocr.py +31 -4
  8. compose.yaml +40 -0
  9. docs/local_model_setup.md +66 -49
  10. tests/test_tracing.py +135 -23
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .codex
3
+ .env
4
+ .venv
5
+ __pycache__
6
+ *.py[cod]
7
+ *.log
8
+ .pytest_cache
9
+ .mypy_cache
10
+ .ruff_cache
11
+ traces/pending
12
+ traces/export
13
+ experiments
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM pytorch/pytorch:2.9.1-cuda12.8-cudnn9-runtime
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive \
4
+ PYTHONUNBUFFERED=1 \
5
+ PYTHONDONTWRITEBYTECODE=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ HF_HOME=/root/.cache/huggingface
8
+
9
+ RUN apt-get update \
10
+ && apt-get install --yes --no-install-recommends \
11
+ git \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ WORKDIR /app
17
+
18
+ COPY requirements.txt .
19
+ RUN python -m pip install --upgrade pip \
20
+ && python -m pip install -r requirements.txt
21
+
22
+ COPY . .
23
+
24
+ EXPOSE 7860
25
+
26
+ CMD ["python", "app.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -77,10 +77,63 @@ fallback. Model and OCR failures are returned explicitly.
77
 
78
  Both models run through Transformers on the Hugging Face ZeroGPU deployment.
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ## Repository Layout
81
 
82
  ```text
83
  app.py Thin Space launcher
 
 
84
  app/
85
  cli.py CLI and startup
86
  config.py Environment configuration
 
77
 
78
  Both models run through Transformers on the Hugging Face ZeroGPU deployment.
79
 
80
+ ## Run Locally With Docker and CUDA
81
+
82
+ The included Compose setup runs the same Transformers pipeline entirely on a
83
+ local NVIDIA GPU. It does not use ZeroGPU or a remote inference API.
84
+
85
+ Prerequisites:
86
+
87
+ - Docker Engine with Docker Compose 2.30 or newer, or Docker Desktop with the
88
+ WSL 2 backend
89
+ - a supported NVIDIA GPU and current NVIDIA driver
90
+ - NVIDIA Container Toolkit when using Docker Engine on Linux
91
+ - enough GPU memory for MiniCPM5-1B and Nemotron-Parse v1.2
92
+
93
+ Start the application:
94
+
95
+ ```bash
96
+ docker compose up --build
97
+ ```
98
+
99
+ Open <http://localhost:7860>. The first startup takes longer because both model
100
+ repositories are downloaded. Downloads are retained in the
101
+ `huggingface-cache` Docker volume.
102
+
103
+ Verify that PyTorch can access the local GPU:
104
+
105
+ ```bash
106
+ docker compose run --rm noticecheck python -c \
107
+ "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
108
+ ```
109
+
110
+ Optional environment overrides can be placed in a local `.env` file:
111
+
112
+ ```dotenv
113
+ NOTICECHECK_PORT=7860
114
+ TRANSFORMERS_MODEL_REPO=openbmb/MiniCPM5-1B
115
+ MODEL_ENABLE_THINKING=0
116
+ HF_TOKEN=
117
+ ```
118
+
119
+ Stop the application without deleting downloaded models:
120
+
121
+ ```bash
122
+ docker compose down
123
+ ```
124
+
125
+ To also remove the model cache and trace volumes:
126
+
127
+ ```bash
128
+ docker compose down --volumes
129
+ ```
130
+
131
  ## Repository Layout
132
 
133
  ```text
134
  app.py Thin Space launcher
135
+ Dockerfile Local CUDA image
136
+ compose.yaml Local NVIDIA GPU deployment
137
  app/
138
  cli.py CLI and startup
139
  config.py Environment configuration
app/cli.py CHANGED
@@ -54,7 +54,7 @@ def run_self_tests() -> None:
54
 
55
  def test_endpoint() -> None:
56
  if not model_status()["connected"]:
57
- raise ModelRuntimeError("The local llama.cpp runtime is not ready.")
58
  sample = (
59
  "PAKISTAN POST: Pay Rs. 85 now at http://pakpost-delivery.example/verify "
60
  "or your parcel will be destroyed today."
 
54
 
55
  def test_endpoint() -> None:
56
  if not model_status()["connected"]:
57
+ raise ModelRuntimeError("The configured local model runtime is not ready.")
58
  sample = (
59
  "PAKISTAN POST: Pay Rs. 85 now at http://pakpost-delivery.example/verify "
60
  "or your parcel will be destroyed today."
app/config.py CHANGED
@@ -18,6 +18,23 @@ def _env_bool(name: str, default: bool) -> bool:
18
  return value.strip().lower() in {"1", "true", "yes", "on"}
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  @dataclass(frozen=True)
22
  class ModelConfig:
23
  repo_id: str
@@ -39,8 +56,8 @@ class ModelConfig:
39
 
40
 
41
  def model_config() -> ModelConfig:
42
- """Return llama.cpp settings for local GGUF inference."""
43
- on_space = bool(os.getenv("SPACE_ID"))
44
  return ModelConfig(
45
  repo_id=os.getenv(
46
  "MODEL_REPO_ID",
@@ -61,6 +78,6 @@ def model_config() -> ModelConfig:
61
  float(os.getenv("MODEL_RETRY_DELAY_SECONDS", "1")),
62
  ),
63
  verbose=_env_bool("MODEL_VERBOSE", False),
64
- keep_loaded=_env_bool("MODEL_KEEP_LOADED", not on_space),
65
  enable_thinking=_env_bool("MODEL_ENABLE_THINKING", False),
66
  )
 
18
  return value.strip().lower() in {"1", "true", "yes", "on"}
19
 
20
 
21
+ def model_runtime() -> str:
22
+ """Select Transformers on Spaces or when explicitly requested locally."""
23
+ configured = os.getenv("MODEL_RUNTIME", "").strip().lower()
24
+ if configured:
25
+ if configured not in {"transformers", "llama_cpp"}:
26
+ raise ValueError(
27
+ "MODEL_RUNTIME must be 'transformers' or 'llama_cpp'."
28
+ )
29
+ return configured
30
+ return "transformers" if os.getenv("SPACE_ID") else "llama_cpp"
31
+
32
+
33
+ def cuda_required() -> bool:
34
+ """Return whether startup should fail instead of falling back to CPU."""
35
+ return _env_bool("REQUIRE_CUDA", False)
36
+
37
+
38
  @dataclass(frozen=True)
39
  class ModelConfig:
40
  repo_id: str
 
56
 
57
 
58
  def model_config() -> ModelConfig:
59
+ """Return shared generation settings and llama.cpp fallback settings."""
60
+ using_transformers = model_runtime() == "transformers"
61
  return ModelConfig(
62
  repo_id=os.getenv(
63
  "MODEL_REPO_ID",
 
78
  float(os.getenv("MODEL_RETRY_DELAY_SECONDS", "1")),
79
  ),
80
  verbose=_env_bool("MODEL_VERBOSE", False),
81
+ keep_loaded=_env_bool("MODEL_KEEP_LOADED", not using_transformers),
82
  enable_thinking=_env_bool("MODEL_ENABLE_THINKING", False),
83
  )
app/model_endpoint.py CHANGED
@@ -15,7 +15,7 @@ from typing import Any
15
  import spaces
16
  from huggingface_hub import hf_hub_download
17
 
18
- from app.config import ModelConfig, model_config
19
  from app.ocr import NoReadableTextError, OCRRuntimeError, extract_text, ocr_installed
20
  from app.prompts import SYSTEM_PROMPT
21
  from app.schema import OUTPUT_SCHEMA, normalize_assessment
@@ -26,7 +26,10 @@ _MODEL_LOCK = threading.RLock()
26
  _TF_MODEL: Any | None = None
27
  _TF_TOKENIZER: Any | None = None
28
  _TF_LOCK = threading.RLock()
29
- SPACE_MODEL_REPO = os.getenv("SPACE_MODEL_REPO", "openbmb/MiniCPM5-1B").strip()
 
 
 
30
  URDU_SCRIPT_PATTERN = re.compile(r"[\u0600-\u06ff]")
31
 
32
 
@@ -40,24 +43,42 @@ class NoticeImageInputError(ModelRuntimeError):
40
 
41
  def model_status() -> dict[str, Any]:
42
  config = model_config()
43
- on_space = bool(os.getenv("SPACE_ID"))
 
44
  installed = importlib.util.find_spec(
45
- "transformers" if on_space else "llama_cpp"
46
  ) is not None
47
- configured = bool(SPACE_MODEL_REPO) if on_space else bool(
48
  config.model_path or (config.repo_id and config.filename)
49
  )
50
- ready = installed and configured
 
 
 
 
 
 
 
 
 
51
  return {
52
  "connected": ready,
53
- "label": (
54
- "Local models ready"
55
- if ready
56
- else "Local model setup required"
57
- ),
58
- "mode": "minicpm5_transformers" if on_space else "minicpm5_llama_cpp",
59
- "model": SPACE_MODEL_REPO if on_space else config.source,
60
- "compute": "zerogpu_cuda" if on_space else "local",
 
 
 
 
 
 
 
 
61
  "reasoning": config.enable_thinking,
62
  "ocr": {
63
  "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
@@ -218,10 +239,16 @@ def _get_transformers_model() -> tuple[Any, Any]:
218
  raise ModelRuntimeError(
219
  "Transformers MiniCPM runtime is not installed."
220
  ) from exc
 
 
 
 
221
  try:
222
- _TF_TOKENIZER = AutoTokenizer.from_pretrained(SPACE_MODEL_REPO)
 
 
223
  _TF_MODEL = AutoModelForCausalLM.from_pretrained(
224
- SPACE_MODEL_REPO,
225
  torch_dtype="auto",
226
  device_map="auto",
227
  ).eval()
@@ -297,6 +324,7 @@ def call_model(
297
  ) -> dict[str, Any]:
298
  """Run Transformers on Spaces and llama.cpp for local installations."""
299
  config = model_config()
 
300
  input_text = text.strip()
301
  if image_data_url:
302
  try:
@@ -317,7 +345,7 @@ def call_model(
317
  for attempt in range(attempts):
318
  ephemeral_model: Any | None = None
319
  try:
320
- if os.getenv("SPACE_ID"):
321
  return _run_transformers_completion(input_text, output_language)
322
  model = (
323
  _get_persistent_model(config)
 
15
  import spaces
16
  from huggingface_hub import hf_hub_download
17
 
18
+ from app.config import ModelConfig, cuda_required, model_config, model_runtime
19
  from app.ocr import NoReadableTextError, OCRRuntimeError, extract_text, ocr_installed
20
  from app.prompts import SYSTEM_PROMPT
21
  from app.schema import OUTPUT_SCHEMA, normalize_assessment
 
26
  _TF_MODEL: Any | None = None
27
  _TF_TOKENIZER: Any | None = None
28
  _TF_LOCK = threading.RLock()
29
+ TRANSFORMERS_MODEL_REPO = os.getenv(
30
+ "TRANSFORMERS_MODEL_REPO",
31
+ os.getenv("SPACE_MODEL_REPO", "openbmb/MiniCPM5-1B"),
32
+ ).strip()
33
  URDU_SCRIPT_PATTERN = re.compile(r"[\u0600-\u06ff]")
34
 
35
 
 
43
 
44
  def model_status() -> dict[str, Any]:
45
  config = model_config()
46
+ runtime = model_runtime()
47
+ using_transformers = runtime == "transformers"
48
  installed = importlib.util.find_spec(
49
+ "transformers" if using_transformers else "llama_cpp"
50
  ) is not None
51
+ configured = bool(TRANSFORMERS_MODEL_REPO) if using_transformers else bool(
52
  config.model_path or (config.repo_id and config.filename)
53
  )
54
+ cuda_ready = True
55
+ if using_transformers and cuda_required():
56
+ try:
57
+ import torch
58
+
59
+ cuda_ready = torch.cuda.is_available()
60
+ except ImportError:
61
+ cuda_ready = False
62
+ ready = installed and configured and cuda_ready
63
+ on_space = bool(os.getenv("SPACE_ID"))
64
  return {
65
  "connected": ready,
66
+ "label": (
67
+ "Local models ready"
68
+ if ready
69
+ else "CUDA is unavailable"
70
+ if using_transformers and not cuda_ready
71
+ else "Local model setup required"
72
+ ),
73
+ "mode": f"minicpm5_{runtime}",
74
+ "model": TRANSFORMERS_MODEL_REPO if using_transformers else config.source,
75
+ "compute": (
76
+ "zerogpu_cuda"
77
+ if on_space
78
+ else "local_cuda"
79
+ if using_transformers and cuda_ready
80
+ else "local"
81
+ ),
82
  "reasoning": config.enable_thinking,
83
  "ocr": {
84
  "model": "nvidia/NVIDIA-Nemotron-Parse-v1.2",
 
239
  raise ModelRuntimeError(
240
  "Transformers MiniCPM runtime is not installed."
241
  ) from exc
242
+ if cuda_required() and not torch.cuda.is_available():
243
+ raise ModelRuntimeError(
244
+ "CUDA is required but is not available to PyTorch."
245
+ )
246
  try:
247
+ _TF_TOKENIZER = AutoTokenizer.from_pretrained(
248
+ TRANSFORMERS_MODEL_REPO
249
+ )
250
  _TF_MODEL = AutoModelForCausalLM.from_pretrained(
251
+ TRANSFORMERS_MODEL_REPO,
252
  torch_dtype="auto",
253
  device_map="auto",
254
  ).eval()
 
324
  ) -> dict[str, Any]:
325
  """Run Transformers on Spaces and llama.cpp for local installations."""
326
  config = model_config()
327
+ runtime = model_runtime()
328
  input_text = text.strip()
329
  if image_data_url:
330
  try:
 
345
  for attempt in range(attempts):
346
  ephemeral_model: Any | None = None
347
  try:
348
+ if runtime == "transformers":
349
  return _run_transformers_completion(input_text, output_language)
350
  model = (
351
  _get_persistent_model(config)
app/ocr.py CHANGED
@@ -13,6 +13,8 @@ from typing import Any
13
 
14
  from PIL import Image
15
 
 
 
16
  MODEL_ID = "nvidia/NVIDIA-Nemotron-Parse-v1.2"
17
  TASK_PROMPT = "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
18
 
@@ -26,6 +28,7 @@ _PROCESSOR: Any | None = None
26
  _GEN_CONFIG: Any | None = None
27
  _POSTPROCESSING: Any | None = None
28
  _LOCK = threading.RLock()
 
29
 
30
 
31
  class OCRRuntimeError(RuntimeError):
@@ -43,6 +46,15 @@ def _has_readable_text(text: str) -> bool:
43
  return len(alphanumeric) >= 4 and any(char.isalpha() for char in alphanumeric)
44
 
45
 
 
 
 
 
 
 
 
 
 
46
  def ocr_installed() -> bool:
47
  try:
48
  from transformers import AutoModel, AutoProcessor # noqa: F401
@@ -96,6 +108,10 @@ def _get_model() -> tuple[Any, Any, Any]:
96
  raise OCRRuntimeError(
97
  "Transformers is not installed."
98
  ) from exc
 
 
 
 
99
  try:
100
  _MODEL = (
101
  AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True, dtype="auto")
@@ -145,11 +161,22 @@ def extract_text(image_data_url: str) -> str:
145
  if pp is not None:
146
  try:
147
  classes, bboxes, texts = pp.extract_classes_bboxes(generated_text)
148
- texts = [
149
- pp.postprocess_text(t, cls=c, text_format="markdown")
150
- for t, c in zip(texts, classes)
 
151
  ]
152
- text = "\n\n".join(t.strip() for t in texts if t.strip())
 
 
 
 
 
 
 
 
 
 
153
  except Exception:
154
  text = generated_text.strip()
155
  else:
 
13
 
14
  from PIL import Image
15
 
16
+ from app.config import cuda_required
17
+
18
  MODEL_ID = "nvidia/NVIDIA-Nemotron-Parse-v1.2"
19
  TASK_PROMPT = "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
20
 
 
28
  _GEN_CONFIG: Any | None = None
29
  _POSTPROCESSING: Any | None = None
30
  _LOCK = threading.RLock()
31
+ NON_TEXT_CLASSES = {"figure", "image", "picture"}
32
 
33
 
34
  class OCRRuntimeError(RuntimeError):
 
46
  return len(alphanumeric) >= 4 and any(char.isalpha() for char in alphanumeric)
47
 
48
 
49
+ def _is_text_class(value: Any) -> bool:
50
+ """Return whether a Nemotron region represents document text."""
51
+ normalized = re.sub(r"[^a-z]+", " ", str(value).lower()).strip()
52
+ return not any(
53
+ token in NON_TEXT_CLASSES
54
+ for token in normalized.split()
55
+ )
56
+
57
+
58
  def ocr_installed() -> bool:
59
  try:
60
  from transformers import AutoModel, AutoProcessor # noqa: F401
 
108
  raise OCRRuntimeError(
109
  "Transformers is not installed."
110
  ) from exc
111
+ if cuda_required() and not torch.cuda.is_available():
112
+ raise OCRRuntimeError(
113
+ "CUDA is required but is not available to PyTorch."
114
+ )
115
  try:
116
  _MODEL = (
117
  AutoModel.from_pretrained(MODEL_ID, trust_remote_code=True, dtype="auto")
 
161
  if pp is not None:
162
  try:
163
  classes, bboxes, texts = pp.extract_classes_bboxes(generated_text)
164
+ text_regions = [
165
+ pp.postprocess_text(region_text, cls=region_class, text_format="markdown")
166
+ for region_text, region_class in zip(texts, classes)
167
+ if _is_text_class(region_class)
168
  ]
169
+ text = "\n\n".join(
170
+ region.strip()
171
+ for region in text_regions
172
+ if _has_readable_text(region)
173
+ )
174
+ if not text and classes:
175
+ raise NoReadableTextError(
176
+ "No readable notice text was found in the screenshot."
177
+ )
178
+ except NoReadableTextError:
179
+ raise
180
  except Exception:
181
  text = generated_text.strip()
182
  else:
compose.yaml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ noticecheck:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ ports:
7
+ - "${NOTICECHECK_PORT:-7860}:7860"
8
+ environment:
9
+ MODEL_RUNTIME: transformers
10
+ REQUIRE_CUDA: "1"
11
+ TRANSFORMERS_MODEL_REPO: "${TRANSFORMERS_MODEL_REPO:-openbmb/MiniCPM5-1B}"
12
+ MODEL_ENABLE_THINKING: "${MODEL_ENABLE_THINKING:-0}"
13
+ HF_HOME: /root/.cache/huggingface
14
+ HF_TOKEN: "${HF_TOKEN:-}"
15
+ GRADIO_ANALYTICS_ENABLED: "False"
16
+ volumes:
17
+ - huggingface-cache:/root/.cache/huggingface
18
+ - noticecheck-traces:/app/traces/pending
19
+ deploy:
20
+ resources:
21
+ reservations:
22
+ devices:
23
+ - driver: nvidia
24
+ count: 1
25
+ capabilities: [gpu]
26
+ healthcheck:
27
+ test:
28
+ - CMD
29
+ - python
30
+ - -c
31
+ - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=5)"
32
+ interval: 30s
33
+ timeout: 10s
34
+ retries: 5
35
+ start_period: 10m
36
+ restart: unless-stopped
37
+
38
+ volumes:
39
+ huggingface-cache:
40
+ noticecheck-traces:
docs/local_model_setup.md CHANGED
@@ -1,76 +1,93 @@
1
- # Local model setup
2
 
3
- The application has separate Space and local runtimes:
4
 
5
- - **Space:** `openbmb/MiniCPM5-1B` through Transformers, plus
6
- `nvidia/nemotron-ocr-v2` for screenshots
7
- - **Local:** `openbmb/MiniCPM5-1B-GGUF` through `llama-cpp-python`
8
 
9
- No remote model API is required.
 
 
10
 
11
- ## Supported environment
12
 
13
- Nemotron OCR v2 requires Linux amd64, an NVIDIA GPU, CUDA build/runtime
14
- compatibility, and Python 3.12. The Space metadata pins Python 3.12.
15
 
16
- Install the Space dependencies:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ```bash
19
- python -m pip install -r requirements.txt
20
  ```
21
 
22
- The OCR dependency is NVIDIA's prebuilt `cp312` wheel from its official
23
- ZeroGPU Space. The Space uses the matching CUDA 12.8 pair `torch==2.9.1` and
24
- `torchvision==0.24.1` for NVIDIA's native OCR extension. MiniCPM uses
25
- Transformers on the same ZeroGPU allocation. The Space does not install
26
- `llama-cpp-python`.
27
 
28
- ## MiniCPM configuration
29
 
30
- Defaults:
 
 
31
 
32
  ```text
33
- openbmb/MiniCPM5-1B-GGUF
34
- MiniCPM5-1B-Q8_0.gguf
 
35
  ```
36
 
37
- Overrides:
38
 
39
- ```powershell
40
- $env:MODEL_REPO_ID = "openbmb/MiniCPM5-1B-GGUF"
41
- $env:MODEL_FILENAME = "MiniCPM5-1B-Q8_0.gguf"
42
- $env:MODEL_CONTEXT_SIZE = "8192"
43
- $env:MODEL_GPU_LAYERS = "0"
44
- $env:MODEL_ENABLE_THINKING = "0"
45
  ```
46
 
47
- Use `MODEL_PATH` for an existing GGUF. Schema-constrained generation is the
48
- default because it is more reliable for the application response contract.
49
- Set `MODEL_ENABLE_THINKING=1` only for experiments; it uses a larger token
50
- budget but still must produce schema-valid JSON.
 
51
 
52
- Set `MODEL_GPU_LAYERS=-1` when using a locally built CUDA-enabled
53
- `llama-cpp-python` installation outside ZeroGPU.
54
 
55
- ```powershell
56
- python -m pip install -r requirements-local.txt
57
- python app.py --download-model
58
- python app.py --test-endpoint
59
- python app.py
60
  ```
61
 
62
- ## ZeroGPU lifecycle
 
 
 
 
 
63
 
64
- Inference runs inside `@spaces.GPU(duration=60)`. Nemotron OCR first extracts
65
- paragraph text from a screenshot, then the Transformers MiniCPM5-1B model
66
- assesses that text. Both Space models are cached in the ZeroGPU worker. Local
67
- runs use only the separate GGUF/llama.cpp path.
68
 
69
- ## Language limits
 
 
70
 
71
- Nemotron OCR v2 officially supports English, Chinese, Japanese, Korean, and
72
- Russian. It does not officially support Urdu script. Roman Urdu uses Latin
73
- characters and may be readable, but accuracy is not guaranteed.
74
 
75
- MiniCPM5-1B is officially evaluated in English and Chinese. Urdu and Roman Urdu
76
- reasoning/output are best effort and must be validated on this app's data.
 
 
1
+ # Local CUDA model setup
2
 
3
+ The Docker Compose deployment runs the application completely locally:
4
 
5
+ - `openbmb/MiniCPM5-1B` through Transformers
6
+ - `nvidia/NVIDIA-Nemotron-Parse-v1.2` through Transformers
7
+ - PyTorch CUDA on one local NVIDIA GPU
8
 
9
+ It does not set `SPACE_ID`, request ZeroGPU, or call a remote inference API.
10
+ Internet access is required on the first run to download model files from
11
+ Hugging Face.
12
 
13
+ ## Prerequisites
14
 
15
+ Use a Linux amd64 Docker host with:
 
16
 
17
+ - Docker Engine and Docker Compose 2.30 or newer
18
+ - an NVIDIA GPU supported by CUDA 12.8
19
+ - a current NVIDIA driver
20
+ - NVIDIA Container Toolkit configured for Docker
21
+
22
+ On Windows, use Docker Desktop with its WSL 2 backend and an NVIDIA driver that
23
+ supports CUDA in WSL.
24
+
25
+ Confirm GPU access before building the application:
26
+
27
+ ```bash
28
+ docker run --rm --gpus all \
29
+ pytorch/pytorch:2.9.1-cuda12.8-cudnn9-runtime \
30
+ python -c "import torch; print(torch.cuda.is_available())"
31
+ ```
32
+
33
+ ## Start
34
 
35
  ```bash
36
+ docker compose up --build
37
  ```
38
 
39
+ The startup process preloads Nemotron-Parse before opening port `7860`, so the
40
+ first health check may take several minutes. MiniCPM5-1B loads on the first
41
+ non-cached assessment. Model files persist in the `huggingface-cache` volume.
 
 
42
 
43
+ Open <http://localhost:7860>.
44
 
45
+ ## Configuration
46
+
47
+ Compose sets these required values:
48
 
49
  ```text
50
+ MODEL_RUNTIME=transformers
51
+ REQUIRE_CUDA=1
52
+ HF_HOME=/root/.cache/huggingface
53
  ```
54
 
55
+ Optional `.env` values:
56
 
57
+ ```dotenv
58
+ NOTICECHECK_PORT=7860
59
+ TRANSFORMERS_MODEL_REPO=openbmb/MiniCPM5-1B
60
+ MODEL_ENABLE_THINKING=0
61
+ HF_TOKEN=
 
62
  ```
63
 
64
+ `REQUIRE_CUDA=1` prevents accidental CPU fallback. If Docker cannot expose the
65
+ GPU, model status reports that CUDA is unavailable and startup fails while
66
+ preloading OCR.
67
+
68
+ ## Operations
69
 
70
+ View logs:
 
71
 
72
+ ```bash
73
+ docker compose logs --follow noticecheck
 
 
 
74
  ```
75
 
76
+ Check the GPU from the application image:
77
+
78
+ ```bash
79
+ docker compose run --rm noticecheck python -c \
80
+ "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
81
+ ```
82
 
83
+ Stop containers while retaining downloaded models:
 
 
 
84
 
85
+ ```bash
86
+ docker compose down
87
+ ```
88
 
89
+ Delete containers and persistent model/trace data:
 
 
90
 
91
+ ```bash
92
+ docker compose down --volumes
93
+ ```
tests/test_tracing.py CHANGED
@@ -12,7 +12,7 @@ from pathlib import Path
12
  from unittest.mock import patch
13
 
14
  import app
15
- from app import model_endpoint, service
16
  from app import ocr
17
  from traces import runtime as trace_runtime
18
  from traces.scripts.validate_traces import validate_file
@@ -471,21 +471,41 @@ class TraceTests(unittest.TestCase):
471
  )
472
 
473
  def test_ocr_extracts_paragraph_text(self) -> None:
 
 
474
  image_data = (
475
  "data:image/png;base64,"
476
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNg"
477
  "YAAAAAMAASsJTYQAAAAASUVORK5CYII="
478
  )
479
- fake_pipeline = unittest.mock.Mock(
480
- return_value=[
481
- {"text": "PAKISTAN POST", "confidence": 0.99},
482
- {"text": "Pay Rs. 85 now", "confidence": 0.98},
483
- ]
 
 
 
 
 
 
 
484
  )
485
- with patch("app.ocr._get_pipeline", return_value=fake_pipeline):
 
 
 
 
 
 
 
 
 
 
486
  text = ocr.extract_text(image_data)
 
487
  self.assertEqual(text, "PAKISTAN POST\n\nPay Rs. 85 now")
488
- fake_pipeline.assert_called_once()
489
 
490
  def test_ocr_readability_rejects_parser_markup_without_notice_text(self) -> None:
491
  self.assertFalse(
@@ -496,6 +516,46 @@ class TraceTests(unittest.TestCase):
496
  self.assertTrue(ocr._has_readable_text("Pay Rs. 85 now"))
497
  self.assertTrue(ocr._has_readable_text("آپ کا بل 500 روپے ہے"))
498
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  def test_no_text_ocr_error_becomes_notice_image_input_error(self) -> None:
500
  with patch(
501
  "app.model_endpoint.extract_text",
@@ -573,6 +633,42 @@ class TraceTests(unittest.TestCase):
573
  )
574
  llama_mock.assert_not_called()
575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  def test_transformers_completion_uses_model_card_generation_flow(self) -> None:
577
  assessment = {
578
  "risk_label": "Likely scam",
@@ -654,23 +750,39 @@ class TraceTests(unittest.TestCase):
654
  self.assertEqual(result, assessment)
655
  self.assertEqual(model.generate.call_count, 2)
656
 
657
- def test_urdu_script_ocr_returns_language_error(self) -> None:
658
- with patch(
 
 
 
 
 
 
 
 
 
 
 
659
  "app.model_endpoint.extract_text",
660
  return_value="آپ کا اکاؤنٹ بند ہو جائے گا",
661
- ):
662
- with self.assertRaisesRegex(
663
- model_endpoint.ModelRuntimeError,
664
- "Urdu-script screenshots",
665
- ):
666
- model_endpoint.call_model(
667
- "",
668
- (
669
- "data:image/png;base64,"
670
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lE"
671
- "QVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="
672
- ),
673
- )
 
 
 
 
 
674
 
675
  def test_publisher_persists_batch(self) -> None:
676
  publisher = trace_runtime.TracePublisher()
 
12
  from unittest.mock import patch
13
 
14
  import app
15
+ from app import config, model_endpoint, service
16
  from app import ocr
17
  from traces import runtime as trace_runtime
18
  from traces.scripts.validate_traces import validate_file
 
471
  )
472
 
473
  def test_ocr_extracts_paragraph_text(self) -> None:
474
+ import torch
475
+
476
  image_data = (
477
  "data:image/png;base64,"
478
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNg"
479
  "YAAAAAMAASsJTYQAAAAASUVORK5CYII="
480
  )
481
+ parameter = unittest.mock.Mock(device=torch.device("cpu"), dtype=torch.float32)
482
+ model = unittest.mock.Mock()
483
+ model.parameters.side_effect = lambda: iter([parameter])
484
+ model.generate.return_value = torch.tensor([[1, 2, 3]])
485
+ processor = unittest.mock.Mock()
486
+ processor.return_value = {"pixel_values": torch.ones(1)}
487
+ processor.batch_decode.return_value = ["model output"]
488
+ postprocessing = unittest.mock.Mock()
489
+ postprocessing.extract_classes_bboxes.return_value = (
490
+ ["paragraph", "paragraph"],
491
+ [None, None],
492
+ ["PAKISTAN POST", "Pay Rs. 85 now"],
493
  )
494
+ postprocessing.postprocess_text.side_effect = (
495
+ lambda text, **_kwargs: text
496
+ )
497
+
498
+ with patch(
499
+ "app.ocr._get_model",
500
+ return_value=(model, processor, unittest.mock.Mock()),
501
+ ), patch(
502
+ "app.ocr._load_postprocessing",
503
+ return_value=postprocessing,
504
+ ):
505
  text = ocr.extract_text(image_data)
506
+
507
  self.assertEqual(text, "PAKISTAN POST\n\nPay Rs. 85 now")
508
+ model.generate.assert_called_once()
509
 
510
  def test_ocr_readability_rejects_parser_markup_without_notice_text(self) -> None:
511
  self.assertFalse(
 
516
  self.assertTrue(ocr._has_readable_text("Pay Rs. 85 now"))
517
  self.assertTrue(ocr._has_readable_text("آپ کا بل 500 روپے ہے"))
518
 
519
+ def test_ocr_rejects_picture_region_with_generated_description(self) -> None:
520
+ import torch
521
+
522
+ image_data = (
523
+ "data:image/png;base64,"
524
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNg"
525
+ "YAAAAAMAASsJTYQAAAAASUVORK5CYII="
526
+ )
527
+ parameter = unittest.mock.Mock(device=torch.device("cpu"), dtype=torch.float32)
528
+ model = unittest.mock.Mock()
529
+ model.parameters.side_effect = lambda: iter([parameter])
530
+ model.generate.return_value = torch.tensor([[1, 2, 3]])
531
+ processor = unittest.mock.Mock()
532
+ processor.return_value = {"pixel_values": torch.ones(1)}
533
+ processor.batch_decode.return_value = ["generated picture output"]
534
+ postprocessing = unittest.mock.Mock()
535
+ postprocessing.extract_classes_bboxes.return_value = (
536
+ ["Picture"],
537
+ [None],
538
+ ["A portrait of a woman"],
539
+ )
540
+
541
+ with patch(
542
+ "app.ocr._get_model",
543
+ return_value=(model, processor, unittest.mock.Mock()),
544
+ ), patch(
545
+ "app.ocr._load_postprocessing",
546
+ return_value=postprocessing,
547
+ ):
548
+ with self.assertRaises(ocr.NoReadableTextError):
549
+ ocr.extract_text(image_data)
550
+
551
+ postprocessing.postprocess_text.assert_not_called()
552
+
553
+ def test_ocr_keeps_text_regions_next_to_a_picture(self) -> None:
554
+ self.assertFalse(ocr._is_text_class("Picture"))
555
+ self.assertFalse(ocr._is_text_class("page-image"))
556
+ self.assertTrue(ocr._is_text_class("Paragraph"))
557
+ self.assertTrue(ocr._is_text_class("Table"))
558
+
559
  def test_no_text_ocr_error_becomes_notice_image_input_error(self) -> None:
560
  with patch(
561
  "app.model_endpoint.extract_text",
 
633
  )
634
  llama_mock.assert_not_called()
635
 
636
+ def test_explicit_local_transformers_runtime_avoids_llama_cpp(self) -> None:
637
+ assessment = {
638
+ "risk_label": "Verify first",
639
+ "simple_explanation": "Confirm the sender independently.",
640
+ "red_flags": ["Unverified sender"],
641
+ "safe_next_steps": ["Use an official contact channel."],
642
+ "reply_draft": "I will verify this independently.",
643
+ }
644
+ with patch.dict(
645
+ "os.environ",
646
+ {"MODEL_RUNTIME": "transformers"},
647
+ clear=False,
648
+ ), patch(
649
+ "app.model_endpoint._run_transformers_completion",
650
+ return_value=assessment,
651
+ ) as transformers_mock, patch(
652
+ "app.model_endpoint._build_model",
653
+ ) as llama_mock:
654
+ result = model_endpoint.call_model("Please pay this fee now.")
655
+
656
+ self.assertEqual(result, assessment)
657
+ transformers_mock.assert_called_once_with(
658
+ "Please pay this fee now.",
659
+ "en",
660
+ )
661
+ llama_mock.assert_not_called()
662
+
663
+ def test_invalid_model_runtime_is_rejected(self) -> None:
664
+ with patch.dict(
665
+ "os.environ",
666
+ {"MODEL_RUNTIME": "invalid"},
667
+ clear=False,
668
+ ):
669
+ with self.assertRaisesRegex(ValueError, "MODEL_RUNTIME"):
670
+ config.model_runtime()
671
+
672
  def test_transformers_completion_uses_model_card_generation_flow(self) -> None:
673
  assessment = {
674
  "risk_label": "Likely scam",
 
750
  self.assertEqual(result, assessment)
751
  self.assertEqual(model.generate.call_count, 2)
752
 
753
+ def test_urdu_script_ocr_is_assessed_with_english_output(self) -> None:
754
+ assessment = {
755
+ "risk_label": "Verify first",
756
+ "simple_explanation": "Confirm the sender independently.",
757
+ "red_flags": ["Unverified sender"],
758
+ "safe_next_steps": ["Use an official contact channel."],
759
+ "reply_draft": "I will verify this independently.",
760
+ }
761
+ with patch.dict(
762
+ "os.environ",
763
+ {"MODEL_RUNTIME": "transformers"},
764
+ clear=False,
765
+ ), patch(
766
  "app.model_endpoint.extract_text",
767
  return_value="آپ کا اکاؤنٹ بند ہو جائے گا",
768
+ ), patch(
769
+ "app.model_endpoint._run_transformers_completion",
770
+ return_value=assessment,
771
+ ) as completion_mock:
772
+ result = model_endpoint.call_model(
773
+ "",
774
+ (
775
+ "data:image/png;base64,"
776
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lE"
777
+ "QVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII="
778
+ ),
779
+ )
780
+
781
+ self.assertEqual(result, assessment)
782
+ completion_mock.assert_called_once_with(
783
+ "آپ کا اکاؤنٹ بند ہو جائے گا",
784
+ "en",
785
+ )
786
 
787
  def test_publisher_persists_batch(self) -> None:
788
  publisher = trace_runtime.TracePublisher()