Abid Ali Awan Codex commited on
Commit ·
8ccb3e9
1
Parent(s): edecb11
Add local CUDA deployment and reject non-notice images
Browse filesAdd 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>
- .dockerignore +13 -0
- Dockerfile +26 -0
- README.md +53 -0
- app/cli.py +1 -1
- app/config.py +20 -3
- app/model_endpoint.py +45 -17
- app/ocr.py +31 -4
- compose.yaml +40 -0
- docs/local_model_setup.md +66 -49
- 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
|
| 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
|
| 43 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 44 |
installed = importlib.util.find_spec(
|
| 45 |
-
"transformers" if
|
| 46 |
) is not None
|
| 47 |
-
configured = bool(
|
| 48 |
config.model_path or (config.repo_id and config.filename)
|
| 49 |
)
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return {
|
| 52 |
"connected": ready,
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 223 |
_TF_MODEL = AutoModelForCausalLM.from_pretrained(
|
| 224 |
-
|
| 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
|
| 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 |
-
|
| 149 |
-
pp.postprocess_text(
|
| 150 |
-
for
|
|
|
|
| 151 |
]
|
| 152 |
-
text = "\n\n".join(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
|
| 5 |
-
-
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
##
|
| 12 |
|
| 13 |
-
|
| 14 |
-
compatibility, and Python 3.12. The Space metadata pins Python 3.12.
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
```bash
|
| 19 |
-
|
| 20 |
```
|
| 21 |
|
| 22 |
-
The
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
Transformers on the same ZeroGPU allocation. The Space does not install
|
| 26 |
-
`llama-cpp-python`.
|
| 27 |
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
|
| 32 |
```text
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 35 |
```
|
| 36 |
|
| 37 |
-
|
| 38 |
|
| 39 |
-
```
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
$env:MODEL_ENABLE_THINKING = "0"
|
| 45 |
```
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
`llama-cpp-python` installation outside ZeroGPU.
|
| 54 |
|
| 55 |
-
```
|
| 56 |
-
|
| 57 |
-
python app.py --download-model
|
| 58 |
-
python app.py --test-endpoint
|
| 59 |
-
python app.py
|
| 60 |
```
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
| 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 |
-
|
| 76 |
-
|
|
|
|
|
|
| 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 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
)
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
text = ocr.extract_text(image_data)
|
|
|
|
| 487 |
self.assertEqual(text, "PAKISTAN POST\n\nPay Rs. 85 now")
|
| 488 |
-
|
| 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
|
| 658 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
"app.model_endpoint.extract_text",
|
| 660 |
return_value="آپ کا اکاؤنٹ بند ہو جائے گا",
|
| 661 |
-
)
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 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()
|