github-actions[bot] commited on
Commit
a884809
·
1 Parent(s): ee54f79

Deploy latest changes from main branch

Browse files
.env.example CHANGED
@@ -5,7 +5,14 @@ MODEL_VERSION=v3.0.0
5
  REDIS_URL=redis://localhost:6379/0
6
 
7
  HF_TOKEN=
 
8
  MODEL_CHECKPOINT_PATH=/absolute/path/to/production_model.pt
 
 
 
 
 
 
9
  DINOV3_MODEL_ID=facebook/dinov3-vitb16-pretrain-lvd1689m
10
  EMBEDDING_BATCH_SIZE=16
11
  EMBEDDING_DEVICE=
 
5
  REDIS_URL=redis://localhost:6379/0
6
 
7
  HF_TOKEN=
8
+ # ABMIL: either local path OR Hub (Hub wins when ABMIL_HF_REPO_ID is set)
9
  MODEL_CHECKPOINT_PATH=/absolute/path/to/production_model.pt
10
+ ABMIL_HF_REPO_ID=
11
+ ABMIL_HF_FILENAME=production_model.pt
12
+ ABMIL_HF_REVISION=
13
+ ABMIL_HF_SUBFOLDER=
14
+ ABMIL_HF_LOCAL_FILES_ONLY=false
15
+
16
  DINOV3_MODEL_ID=facebook/dinov3-vitb16-pretrain-lvd1689m
17
  EMBEDDING_BATCH_SIZE=16
18
  EMBEDDING_DEVICE=
src/config.py CHANGED
@@ -38,7 +38,28 @@ class Settings(BaseSettings):
38
  model_checkpoint_path: str = Field(
39
  default="",
40
  alias="MODEL_CHECKPOINT_PATH",
41
- description="Absolute path to ABMIL production checkpoint (.pt)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  )
43
 
44
  dinov3_model_id: str = Field(
 
38
  model_checkpoint_path: str = Field(
39
  default="",
40
  alias="MODEL_CHECKPOINT_PATH",
41
+ description="Local path to ABMIL .pt when ABMIL_HF_REPO_ID is unset; ignored when Hub repo id is set",
42
+ )
43
+
44
+ abmil_hf_repo_id: Optional[str] = Field(
45
+ default=None,
46
+ description="Hugging Face Hub repo id (e.g. org/private-repo). If set, ABMIL checkpoint is fetched via hf_hub_download.",
47
+ )
48
+ abmil_hf_filename: str = Field(
49
+ default="production_model.pt",
50
+ description="Checkpoint filename inside the Hub repo (top level or under ABMIL_HF_SUBFOLDER)",
51
+ )
52
+ abmil_hf_revision: Optional[str] = Field(
53
+ default=None,
54
+ description="Optional Hub revision: branch, tag, or commit SHA (pin in production)",
55
+ )
56
+ abmil_hf_subfolder: Optional[str] = Field(
57
+ default=None,
58
+ description="Optional folder inside the repo containing ABMIL_HF_FILENAME",
59
+ )
60
+ abmil_hf_local_files_only: bool = Field(
61
+ default=False,
62
+ description="If true, Hub resolution uses only local HF cache (no network)",
63
  )
64
 
65
  dinov3_model_id: str = Field(
src/core/services/abmil_checkpoint.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve ABMIL `.pt` checkpoint path from Hugging Face Hub or local filesystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+ from huggingface_hub import hf_hub_download
10
+ from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError, RepositoryNotFoundError
11
+
12
+ from ...config import settings
13
+ from ..logging import get_logger
14
+
15
+ logger = get_logger()
16
+
17
+
18
+ def _hub_auth_token() -> Union[bool, str, None]:
19
+ """Token for hf_hub_download: explicit string, or True to use cached CLI login."""
20
+ if settings.hf_token and settings.hf_token.strip():
21
+ return settings.hf_token.strip()
22
+ env_t = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
23
+ if env_t and env_t.strip():
24
+ return env_t.strip()
25
+ return True
26
+
27
+
28
+ def resolve_abmil_checkpoint_path() -> str:
29
+ """
30
+ Resolve the ABMIL checkpoint file path for ``torch.load``.
31
+
32
+ Precedence (mutually exclusive in practice):
33
+
34
+ 1. **Hugging Face Hub** when ``ABMIL_HF_REPO_ID`` is set: ``hf_hub_download`` caches under
35
+ ``HF_HOME`` / Hub layout and returns the local cache path.
36
+ 2. **Local file** when ``MODEL_CHECKPOINT_PATH`` is set: must exist and be a regular file.
37
+
38
+ Raises ``RuntimeError`` with an actionable message if neither source is valid.
39
+ """
40
+ repo_id = (settings.abmil_hf_repo_id or "").strip()
41
+ if repo_id:
42
+ filename = (settings.abmil_hf_filename or "").strip()
43
+ if not filename:
44
+ raise RuntimeError(
45
+ "ABMIL_HF_FILENAME must be set to a non-empty checkpoint filename when ABMIL_HF_REPO_ID is set.",
46
+ )
47
+
48
+ if (settings.model_checkpoint_path or "").strip():
49
+ logger.info(
50
+ "abmil_checkpoint_source_hub",
51
+ repo_id=repo_id,
52
+ filename=filename,
53
+ note="MODEL_CHECKPOINT_PATH is ignored while ABMIL_HF_REPO_ID is set.",
54
+ )
55
+
56
+ token = _hub_auth_token()
57
+ if token is True:
58
+ logger.warning(
59
+ "abmil_hf_download_implicit_token",
60
+ repo_id=repo_id,
61
+ message="No HF_TOKEN / hf_token in environment; using cached Hub credentials if any.",
62
+ )
63
+
64
+ subfolder = (settings.abmil_hf_subfolder or "").strip() or None
65
+ revision = (settings.abmil_hf_revision or "").strip() or None
66
+
67
+ try:
68
+ path = hf_hub_download(
69
+ repo_id=repo_id,
70
+ filename=filename,
71
+ subfolder=subfolder,
72
+ revision=revision,
73
+ token=token,
74
+ local_files_only=settings.abmil_hf_local_files_only,
75
+ )
76
+ except RepositoryNotFoundError as exc:
77
+ raise RuntimeError(
78
+ f"ABMIL Hub repository not found or no access: {repo_id!r}. "
79
+ "Verify ABMIL_HF_REPO_ID, HF_TOKEN read access, and network.",
80
+ ) from exc
81
+ except EntryNotFoundError as exc:
82
+ raise RuntimeError(
83
+ f"ABMIL checkpoint file not in Hub repo {repo_id!r}: {filename!r}"
84
+ + (f" (subfolder={subfolder!r})" if subfolder else "")
85
+ + ". Check ABMIL_HF_FILENAME / ABMIL_HF_SUBFOLDER.",
86
+ ) from exc
87
+ except HfHubHTTPError as exc:
88
+ raise RuntimeError(
89
+ f"Hugging Face Hub error while downloading ABMIL checkpoint: {exc}",
90
+ ) from exc
91
+ except OSError as exc:
92
+ raise RuntimeError(
93
+ f"Filesystem error while caching ABMIL checkpoint from Hub: {exc}",
94
+ ) from exc
95
+
96
+ resolved = Path(path)
97
+ if not resolved.is_file():
98
+ raise RuntimeError(f"Hub download did not produce a readable file: {path}")
99
+
100
+ logger.info(
101
+ "abmil_checkpoint_resolved_from_hub",
102
+ repo_id=repo_id,
103
+ filename=filename,
104
+ revision=revision,
105
+ local_files_only=settings.abmil_hf_local_files_only,
106
+ resolved_path=str(resolved),
107
+ )
108
+ return str(resolved)
109
+
110
+ local = (settings.model_checkpoint_path or "").strip()
111
+ if not local:
112
+ raise RuntimeError(
113
+ "ABMIL checkpoint not configured: set ABMIL_HF_REPO_ID and ABMIL_HF_FILENAME "
114
+ "(Hugging Face Hub), or set MODEL_CHECKPOINT_PATH to a local .pt file.",
115
+ )
116
+
117
+ p = Path(local).expanduser()
118
+ try:
119
+ p = p.resolve()
120
+ except OSError:
121
+ pass
122
+ if not p.is_file():
123
+ raise RuntimeError(
124
+ f"MODEL_CHECKPOINT_PATH is set but file not found or not a file: {local}",
125
+ )
126
+
127
+ logger.info("abmil_checkpoint_resolved_local", resolved_path=str(p))
128
+ return str(p)
src/core/services/ml_inference.py CHANGED
@@ -16,6 +16,7 @@ from starlette.concurrency import run_in_threadpool
16
  from ...config import settings
17
  from ..exceptions import InferenceServiceError, InvalidImageError, UnsupportedImageFormatError
18
  from ..logging import get_logger
 
19
  from .abmil_model import ABMILInferenceEngine
20
  from .dino_embedding import DINOv3Embedder
21
  from .preprocessing import extract_tiles_for_inference
@@ -32,11 +33,6 @@ DINO_EMBEDDER: Optional[DINOv3Embedder] = None
32
  ABMIL_ENGINE: Optional[ABMILInferenceEngine] = None
33
 
34
 
35
- def _assert_model_checkpoint_configured() -> None:
36
- if not settings.model_checkpoint_path:
37
- raise RuntimeError("MODEL_CHECKPOINT_PATH is required for ABMIL inference.")
38
-
39
-
40
  def _configure_torch_cpu_threads() -> None:
41
  """Optional CPU parallelism tuning (set before heavy torch work). See PyTorch threading docs."""
42
  try:
@@ -65,7 +61,7 @@ def initialize_models() -> None:
65
  logger.info("Inference models already initialized")
66
  return
67
 
68
- _assert_model_checkpoint_configured()
69
  _configure_torch_cpu_threads()
70
 
71
  embedder = DINOv3Embedder(
@@ -79,7 +75,7 @@ def initialize_models() -> None:
79
  embedder.load()
80
 
81
  engine = ABMILInferenceEngine(
82
- checkpoint_path=settings.model_checkpoint_path,
83
  device=settings.embedding_device,
84
  )
85
  engine.load()
 
16
  from ...config import settings
17
  from ..exceptions import InferenceServiceError, InvalidImageError, UnsupportedImageFormatError
18
  from ..logging import get_logger
19
+ from .abmil_checkpoint import resolve_abmil_checkpoint_path
20
  from .abmil_model import ABMILInferenceEngine
21
  from .dino_embedding import DINOv3Embedder
22
  from .preprocessing import extract_tiles_for_inference
 
33
  ABMIL_ENGINE: Optional[ABMILInferenceEngine] = None
34
 
35
 
 
 
 
 
 
36
  def _configure_torch_cpu_threads() -> None:
37
  """Optional CPU parallelism tuning (set before heavy torch work). See PyTorch threading docs."""
38
  try:
 
61
  logger.info("Inference models already initialized")
62
  return
63
 
64
+ abmil_checkpoint_path = resolve_abmil_checkpoint_path()
65
  _configure_torch_cpu_threads()
66
 
67
  embedder = DINOv3Embedder(
 
75
  embedder.load()
76
 
77
  engine = ABMILInferenceEngine(
78
+ checkpoint_path=abmil_checkpoint_path,
79
  device=settings.embedding_device,
80
  )
81
  engine.load()
tests/test_abmil_checkpoint.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from src.config import settings
8
+ from src.core.services.abmil_checkpoint import resolve_abmil_checkpoint_path
9
+
10
+
11
+ def test_resolve_abmil_checkpoint_local_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
12
+ pt = tmp_path / "production_model.pt"
13
+ pt.write_bytes(b"not-a-real-checkpoint")
14
+
15
+ monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
16
+ monkeypatch.setattr(settings, "model_checkpoint_path", str(pt))
17
+
18
+ resolved = resolve_abmil_checkpoint_path()
19
+ assert Path(resolved) == pt.resolve()
20
+
21
+
22
+ def test_resolve_abmil_checkpoint_local_missing(monkeypatch: pytest.MonkeyPatch) -> None:
23
+ monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
24
+ monkeypatch.setattr(settings, "model_checkpoint_path", "/nonexistent/path/model.pt")
25
+
26
+ with pytest.raises(RuntimeError, match="MODEL_CHECKPOINT_PATH"):
27
+ resolve_abmil_checkpoint_path()
28
+
29
+
30
+ def test_resolve_abmil_checkpoint_hub(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
31
+ cached = tmp_path / "from_hub.pt"
32
+ cached.write_bytes(b"x")
33
+
34
+ monkeypatch.setattr(settings, "abmil_hf_repo_id", "org/private-abmil")
35
+ monkeypatch.setattr(settings, "abmil_hf_filename", "weights.pt")
36
+ monkeypatch.setattr(settings, "abmil_hf_revision", "v1.0.0")
37
+ monkeypatch.setattr(settings, "abmil_hf_subfolder", "checkpoints")
38
+ monkeypatch.setattr(settings, "abmil_hf_local_files_only", False)
39
+ monkeypatch.setattr(settings, "model_checkpoint_path", "/ignored/local.pt")
40
+
41
+ captured: dict = {}
42
+
43
+ def fake_hf_hub_download(**kwargs: object) -> str:
44
+ captured.update(kwargs)
45
+ return str(cached)
46
+
47
+ monkeypatch.setattr(
48
+ "src.core.services.abmil_checkpoint.hf_hub_download",
49
+ fake_hf_hub_download,
50
+ )
51
+
52
+ out = resolve_abmil_checkpoint_path()
53
+ assert out == str(cached)
54
+ assert captured["repo_id"] == "org/private-abmil"
55
+ assert captured["filename"] == "weights.pt"
56
+ assert captured["revision"] == "v1.0.0"
57
+ assert captured["subfolder"] == "checkpoints"
58
+ assert captured["local_files_only"] is False
59
+
60
+
61
+ def test_resolve_abmil_checkpoint_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
62
+ monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
63
+ monkeypatch.setattr(settings, "model_checkpoint_path", "")
64
+
65
+ with pytest.raises(RuntimeError, match="ABMIL checkpoint not configured"):
66
+ resolve_abmil_checkpoint_path()
67
+
68
+
69
+ def test_resolve_abmil_checkpoint_hub_empty_filename(monkeypatch: pytest.MonkeyPatch) -> None:
70
+ monkeypatch.setattr(settings, "abmil_hf_repo_id", "org/repo")
71
+ monkeypatch.setattr(settings, "abmil_hf_filename", " ")
72
+
73
+ with pytest.raises(RuntimeError, match="ABMIL_HF_FILENAME"):
74
+ resolve_abmil_checkpoint_path()
tests/test_inference_smoke.py CHANGED
@@ -62,6 +62,7 @@ def test_initialize_models_requires_checkpoint(monkeypatch: pytest.MonkeyPatch)
62
  monkeypatch.setattr(ml_inference, "DINO_EMBEDDER", None)
63
  monkeypatch.setattr(ml_inference, "ABMIL_ENGINE", None)
64
  monkeypatch.setattr(ml_inference.settings, "model_checkpoint_path", "")
 
65
 
66
- with pytest.raises(RuntimeError, match="MODEL_CHECKPOINT_PATH"):
67
  ml_inference.initialize_models()
 
62
  monkeypatch.setattr(ml_inference, "DINO_EMBEDDER", None)
63
  monkeypatch.setattr(ml_inference, "ABMIL_ENGINE", None)
64
  monkeypatch.setattr(ml_inference.settings, "model_checkpoint_path", "")
65
+ monkeypatch.setattr(ml_inference.settings, "abmil_hf_repo_id", None)
66
 
67
+ with pytest.raises(RuntimeError, match="ABMIL checkpoint not configured"):
68
  ml_inference.initialize_models()